在 Flutter 智能相册中引入 ObjectBox 向量索引层:Memoria 的第一阶段向量数据库接入实践
本文记录了Memoria项目中向量数据库能力的第一阶段落地过程。项目在保留原有Isar业务主库的同时,并行接入了ObjectBox向量索引层,将图片/人脸embedding从业务字段附件提升为独立索引对象。改造内容包括:1)设计专用向量索引实体;2)建立ObjectBox存储服务;3)实现双写双读机制;4)精确版本控制;5)数据治理优化。关键成果包括完成了向量索引的基础设施建设,解决了模型版本混用
## 前言
这篇文章记录的是我在 `Memoria` 项目里完成的一次比较关键的架构演进:
**不是把原有数据库整库替换掉,而是在现有 `Isar` 业务主库之上,并行接入一层 `ObjectBox` 向量索引层。**
这次改造的目标很明确:
- 先把图片/人脸 embedding 从“业务字段附件”提升为“独立索引对象”
- 先把 `modelVersion`、索引语义、读写边界做对
- 暂时不强求 ObjectBox 在“精确 key 批量读取”上比 Isar 更快
- 把它作为后续 ANN 检索、相似图召回、主题候选缩小的基础设施
如果一句话概括这次工作的本质,我会这样说:
> 这不是“数据库替换”,而是“向量数据库能力的第一阶段落地”。
---
## 一、项目背景
`Memoria` 不是一个单纯的相册 CRUD 应用,而是一个本地智能相册系统。
它当前已经具备这些能力:
- 扫描系统相册照片
- 时间/地点聚类生成事件
- 本地视觉标签
- OCR 文本提取
- 人脸分析与聚类
- 标题/故事生成
- 主题聚类
- 语义检索
随着功能演进,`PhotoEntity.imageEmbedding` 这种“把向量直接挂在业务实体字段上”的做法,开始出现明显问题:
- embedding 已经不再只是附属字段,而是独立索引数据
- 未来会有多种 embedding:image、face、text、OCR text、event summary
- 需要 `modelVersion`、`isStale`、更新时间等元信息
- 需要向量近邻查询能力,而不是持续全量扫库
所以这次改造的核心目标,不是“换个存储库”,而是把 embedding 这类数据从业务层里抽出来。
---
## 二、为什么不是“把 Isar 全量换成 ObjectBox”
很多人一看到向量数据库,会下意识想做“整库迁移”。
但对这个项目来说,更合理的路线是:
### 1. `Isar` 继续做业务真相层
负责:
- `PhotoEntity`
- `FaceEntity`
- `EventEntity`
- `StoryEntity`
- 任务状态
- 刷新状态
- AI 处理状态
### 2. `ObjectBox` 负责向量索引层
负责:
- photo embedding
- face embedding
- 精确版本读取
- ANN 检索入口
- 独立索引生命周期管理
也就是说,当前架构不是:
> Isar -> ObjectBox 全量替换
而是:
> Isar 主数据 + ObjectBox 向量索引
这是一次“分层”而不是“替换”。
---
## 三、第一阶段改造目标
这次落地的范围,我刻意控制在一个比较稳的切面内:
### 已完成
- 引入 ObjectBox 依赖与生成链
- 新建 ObjectBox Store
- 新建 photo/face 向量索引实体
- 新建 photo/face 向量 repository
- 生产链路双写到 ObjectBox
- 读取链路优先走 ObjectBox
- `modelVersion` 精确命中
- 加入 Isar/ObjectBox 读取 benchmark
- 处理系统相册里已删除照片的治理逻辑
### 暂未完成
- 把 ANN 真的接到主题聚类候选召回
- 把 ANN 接到相似图搜索
- 把 OCR text embedding / event summary embedding 也迁进来
- 把业务主数据整体迁到 ObjectBox
所以更准确的说法应该是:
**完成了向量数据库接入的第一阶段。**
---
## 四、核心设计:向量索引实体
这次我没有做一个超级通用的 `EmbeddingRecord` 大表,而是拆成了两个实体:
- `PhotoEmbeddingIndexEntity`
- `FaceEmbeddingIndexEntity`
原因很简单:
- 图片向量和人脸向量虽然现在都是 512 维,但语义不同
- 后续维度、距离度量、生命周期可能不同
- 独立实体更利于后续扩展
### 1. PhotoEmbeddingIndexEntity
位置:
- `lib/storage/objectbox/entities/photo_embedding_index_entity.dart`
核心字段:
```dart
@Entity()
class PhotoEmbeddingIndexEntity {
@Id()
int id;
@Unique(onConflict: ConflictStrategy.replace)
String lookupKey;
@Index()
int photoId;
@Index()
String modelVersion;
int updatedAtMillis;
bool isStale;
@HnswIndex(
dimensions: 512,
distanceType: VectorDistanceType.cosine,
)
@Property(type: PropertyType.floatVector)
List<double>? vector;
}
```
这里最关键的是 `lookupKey`:
```dart
'$photoId::$modelVersion'
```
这意味着同一张照片理论上可以共存多个版本的 embedding,但主路径读取必须明确指定当前版本。
### 2. FaceEmbeddingIndexEntity
位置:
- `lib/storage/objectbox/entities/face_embedding_index_entity.dart`
核心字段和 photo 类似,只是 owner 换成:
- `faceId`
- `photoId`
- `qualityScore`
`lookupKey` 设计为:
```dart
'$faceId::$modelVersion'
```
这样 face embedding 的版本读取也能做到精确命中。
---
## 五、ObjectBox Store 接入
位置:
- `lib/storage/objectbox/objectbox_service.dart`
核心职责:
- 初始化 ObjectBox Store
- 提供 `store`
- 提供 `tryBox<T>()`
- 统一管理生命周期
实现很轻:
```dart
class ObjectBoxService {
Store? _store;
Future<void> init() async {
if (_store != null) return;
final directory = await getApplicationDocumentsDirectory();
_store = await openStore(directory: p.join(directory.path, 'objectbox'));
}
}
```
应用启动时,在 `main.dart` 中初始化它。
这一步的意义在于:以后所有向量 repository 都不需要自己关心 Store 怎么开,只关心 box 怎么用。
---
## 六、Repository 层:把向量访问从业务 service 中抽出来
### 1. PhotoEmbeddingIndexRepository
位置:
- `lib/storage/vector_index/photo_embedding_index_repository.dart`
它负责:
- 精确版本读取
- 批量读取
- 写入
- 删除
- `nearestNeighborsF32(...)` 查询入口
最关键的改动是:**读取时必须带 `modelVersion`。**
例如:
```dart
List<double>? readEmbeddingForPhoto(
PhotoEntity photo, {
required String modelVersion,
bool allowLegacyFallback = false,
})
```
这一步非常重要,因为我前一版实现里曾经踩到一个典型风险:
> 如果只是按 `photoId` 取“最新可用记录”,那么当不同 backend 或不同模型版本共存时,就可能静默复用错误版本的向量。
现在的策略是:
- 主路径必须精确 `lookupKey(photoId + modelVersion)`
- 默认不允许 legacy fallback
- 如果要 fallback,必须显式打开
### 2. FaceEmbeddingIndexRepository
位置:
- `lib/storage/vector_index/face_embedding_index_repository.dart`
思路一样:
- 主路径按 `faceId + embeddingModelVersion` 精确命中
- 不再“随便取最新”
这解决了另一个长期风险:
> 人脸模型升级后,如果还沿用“取最新记录”的语义,很容易把旧模型和新模型混在一起。
---
## 七、生产链路如何写入 ObjectBox
### 1. 图片 embedding 生产链路
位置:
- `lib/service/mobileclip_embedding_service.dart`
- `lib/service/ai_service.dart`
流程是:
1. `MobileClipEmbeddingService` 根据当前 backend 计算当前 `modelVersion`
2. 先尝试从 ObjectBox 精确命中当前版本 embedding
3. 如果没有命中,再调用 ONNX 或 NCNN 生成 embedding
4. 生成后同步写回 ObjectBox
关键代码结构大致是:
```dart
final activeModelVersion = await getSelectedModelVersion(
backend: effectiveBackend,
);
final existing = _photoEmbeddingIndexRepository.readEmbeddingForPhoto(
photo,
modelVersion: activeModelVersion,
);
if (existing != null) {
photo.imageEmbedding = existing;
return reusedCache;
}
// 否则重新生成并写入索引
_photoEmbeddingIndexRepository.upsertEmbedding(
photoId: photo.id,
vector: embedding,
modelVersion: activeModelVersion,
);
```
### 2. 人脸 embedding 生产链路
位置:
- `lib/service/face_pipeline_service.dart`
当某张照片做人脸检测后:
- 生成 `FaceEntity`
- 生成 face embedding
- 按当前 photo 的 face 列表整体替换对应的 face 向量索引
这个“按 photoId replace”语义很适合 face,因为一张照片的人脸列表通常需要整体刷新,而不是单条 patch。
---
## 八、消费链路如何读取 ObjectBox
### 1. 主题聚类
位置:
- `lib/service/theme_cluster_service.dart`
主题聚类现在会先拿当前 `modelVersion`,再优先走 ObjectBox 读取图片向量。
这一步做完之后,主题聚类至少已经不再依赖“随缘读老版本向量”。
不过要说明的是:
**当前主题聚类还没有真正把 ANN 候选召回接上。**
也就是说它现在做到了:
- 读当前版本向量
- 统一走索引层
但还没有做到:
- 先用 ANN 缩候选,再做局部聚类
### 2. 语义检索
位置:
- `lib/service/semantic_photo_search_service.dart`
- `lib/view/pages/create_page.dart`
现在语义检索和创建页语义筛图,都会先拿当前模型版本,再从 ObjectBox 中读取对应 photo embedding。
这一步的意义很大:
- 不会混用旧 embedding
- 可以为后续 ANN 搜索直接铺路
### 3. 人脸聚类
位置:
- `lib/service/face_cluster_service.dart`
人脸聚类会在聚类前通过 `FaceEmbeddingIndexRepository` 读取 face 向量,再把结果回填到本次内存对象中参与聚类。
这里我做了一个边界上的取舍:
- repository 只负责返回结果
- 具体要不要把结果装回当前业务对象,由 service 自己决定
这样能避免 repository 带副作用。
---
## 九、为什么后来又补了一轮“已删除照片治理”
ObjectBox 接入跑通之后,应用在运行时暴露出一个更现实的问题:
> 系统相册里有些照片已经删除了,但应用侧仍然保留旧的 `path`,导致页面里要么抛 `PathNotFoundException`,要么展示一大片灰色占位图。
这说明问题已经不再只是“UI 渲染失败”,而是:
**业务数据与系统相册真实状态发生了偏移。**
所以后面我又补了一层更完整的数据治理逻辑。
---
## 十、PathImage:先把崩溃止住
位置:
- `lib/view/widgets/path_image.dart`
最先做的是安全兜底:
```dart
final file = _resolveLocalFile(uri);
if (!file.existsSync()) {
return _fallback();
}
return Image.file(file, ...)
```
这样可以做到:
- 文件不存在时不再抛路径异常
- 直接降级显示占位图
这一步解决的是:
> “别因为路径失效直接炸 UI”
但它不是最终方案,因为如果长期保留灰块,就意味着应用仍然把已经被删除的系统照片当作正常内容。
---
## 十一、reconcileAccessiblePhotos:把“读路径止血”升级成“数据治理”
位置:
- `lib/service/photo_service.dart`
这是这次我最认可的一刀。
它的目标不是“优雅显示坏图”,而是:
> 页面拿到照片列表后,先和系统相册做一次轻量对账。
它的语义是:
- path 仍然可访问:保留
- path 失效但 `assetId` 还能重新解析:修复 path 并写回
- 系统相册里已经找不到:从 app 中删除
而且删除时不是只删 `PhotoEntity`,还会同步清理:
- `FaceEntity`
- photo embedding 索引
- face embedding 索引
这就把问题从“显示逻辑”升级成了“数据治理逻辑”。
---
## 十二、为什么我又给 reconcile 加了缓存和统计
这个接口一旦被用于页面读路径,就会变成一个有副作用的入口:
- 读库
- 查相册
- 修路径
- 写库
- 删记录
这在产品语义上是合理的,但在工程上必须考虑性能和观测。
所以我又补了两件事:
### 1. 短时缓存
给 `photoId / assetId` 做短期访问缓存,避免首页、发现卡片、相册页在短时间内重复对同一批照片做 I/O 检查。
### 2. 调试统计
输出类似:
```text
🧹 [photo-reconcile] total=... result=... cacheHits=... repaired=... removed=... elapsedMs=...
```
这样以后排查页面首开卡顿时,就能知道:
- 扫了多少张
- 命中了多少缓存
- 修了多少
- 删了多少
- 耗时多少
这一步非常有必要,因为从这个阶段开始,“页面打开”已经不再是单纯读数据,而是“读 + 修 + 删”的混合行为。
---
## 十三、哪些页面已经接入 reconcile
### 已接入
- 首页轮播候选
- 首页发现卡片
- 首页进入详情前的候选照片
- 相册主来源
具体来说:
- `home_page.dart`
- `album_page.dart`
也就是说,现在像“3 月碎片”这种页面,在展示候选照片前就已经会先做一次 reconcile,不会再把大量已经删除的系统照片继续当正常内容展示。
---
## 十四、Benchmark:一个很诚实的结果
我还补了一个存储 benchmark。
位置:
- `lib/service/vector_index_benchmark_service.dart`
- `MobileCLIPVectorProbePage`
它比较的是:
### Isar 路径
- `getAll(ids)`
- 直接读取 `PhotoEntity.imageEmbedding`
### ObjectBox 路径
- 通过 `lookupKey(photoId + modelVersion)` 精确批量读取当前版本向量索引
这个 benchmark 的结果很有意思:
**在当前这组 exact key 批量读取场景里,ObjectBox 并没有比 Isar 更快。**
例如一次实际结果里:
- Isar mean/p90:`5.433 ms / 8.351 ms`
- ObjectBox mean/p90:`11.590 ms / 27.212 ms`
- speedup:`0.47x`
这个结果反而帮我确认了一件重要的事:
> ObjectBox 在这个项目里的价值,不在于把 exact read 微基准打赢 Isar。
它真正的价值在于:
- 让 embedding 成为独立索引对象
- 支持精确版本读取
- 为后续 ANN 提供基础能力
换句话说:
**它是为向量索引和 ANN 服务的,不是为“证明精确读取一定更快”服务的。**
---
## 十五、这次到底算不算“完成了向量数据库的一部分”
我认为答案是:
**是,而且是非常明确的一部分。**
更准确的说法是:
### 已完成的部分
- 向量索引实体建模
- 向量索引存储引擎接入
- photo/face embedding 双写入索引层
- 精确 `modelVersion` 读取
- repository 抽象层
- benchmark 验证
- 与业务数据生命周期联动的删除/修复逻辑
### 还没完成的部分
- 真正用 ANN 做相似图召回
- 用 ANN 做主题聚类候选集缩小
- 更多 embedding 类型迁入
- 更完整的向量检索产品能力
## 十六、这次改造涉及的主要文件
### ObjectBox 相关
- `pubspec.yaml`
- `lib/objectbox.g.dart`
- `lib/objectbox-model.json`
- `lib/storage/objectbox/objectbox_service.dart`
- `lib/storage/objectbox/entities/photo_embedding_index_entity.dart`
- `lib/storage/objectbox/entities/face_embedding_index_entity.dart`
- `lib/storage/vector_index/photo_embedding_index_repository.dart`
- `lib/storage/vector_index/face_embedding_index_repository.dart`
- `lib/storage/vector_index/vector_index_constants.dart`
### 接入生产/消费链路
- `lib/service/mobileclip_embedding_service.dart`
- `lib/service/ai_service.dart`
- `lib/service/face_pipeline_service.dart`
- `lib/service/face_cluster_service.dart`
- `lib/service/semantic_photo_search_service.dart`
- `lib/service/theme_cluster_service.dart`
- `lib/view/pages/create_page.dart`
- `lib/view/pages/mobileclip_vector_probe_page.dart`
- `lib/service/vector_index_benchmark_service.dart`
### 数据治理与 UI 修复
- `lib/service/photo_service.dart`
- `lib/view/widgets/path_image.dart`
- `lib/view/pages/home_page.dart`
- `lib/view/pages/album_page.dart`
- `lib/view/pages/story_video_page.dart`
### 文档
- `README.md`
---
## 十七、经验总结
这次改造最重要的经验,我觉得有三条:
### 1. 不要把“向量数据库接入”理解成“整库替换”
如果业务主数据和向量索引的数据语义不同,那就应该分层。
### 2. `modelVersion` 一定要尽早做对
如果一开始不把版本精确读取钉死,后面模型一升级,向量就会静默错乱。
### 3. 数据治理不能只靠 UI 兜底
UI 占位图只能止血,真正的闭环是:
- 能修的修
- 该删的删
- 把系统相册和应用内索引重新对齐
---
## 十八、下一阶段计划
接下来我更想继续推进的是:
### 1. ANN 召回
把 `queryNearest(...)` 真正接到:
- 相似图推荐
- 主题候选缩小
- 人脸候选召回
### 2. 更多 embedding 类型
例如:
- OCR text embedding
- event summary embedding
- theme centroid embedding
### 3. reconcile 去重和观测继续加强
虽然现在已经有缓存和统计了,但后面还可以继续优化:
- 页面级一次性 reconcile 结果透传
- 更精细的缓存策略
- 更明确的慢路径观测
---
## 结语
这次改造之后,`Memoria` 的底层已经不再是“所有东西都堆在业务主库里”。
它开始长出更明确的分层:
- `Isar` 承载业务真相
- `ObjectBox` 承载向量索引
- `PhotoService` 承担系统相册与应用内数据的对账治理
所以我对这次工作的定义不是“调了几个文件”,而是:
**给这个项目补上了向量数据库接入的第一阶段基础设施。**
如果后面 ANN 检索真正接上来,这一层就会开始释放更大的价值。
更多推荐
所有评论(0)