## 前言

这篇文章记录的是我在 `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 检索真正接上来,这一层就会开始释放更大的价值。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐