Semantic Search Architecture
语义搜索架构
FlareMo 的语义搜索只做派生检索能力。D1 仍然是 memo、关系、分享、附件元数据和状态的事实源;Vectorize 只保存可重建的 embedding 索引。
语义搜索已经端到端上线:Workers AI 生成 embeddings,Vectorize 提供向量查询,/api/app/search/semantic 提供 API,前端搜索栏提供「语义搜索(找一找)」入口。本文描述已实现的架构与持续有效的边界约束。
目标
- 让用户用自然语言找到相关 memo。
- 保持普通搜索、标签、状态、时间过滤仍由 D1 提供。
- 保持
/api/v1/*Memos-compatible API 的权威数据来自 D1。 - 用 Workers AI 生成 embeddings,用 Vectorize 查询相似 memo。
非目标
- 不把 Vectorize 当主数据库。
- 不把 Workers AI 返回结果当权威笔记内容。
- 不在 Vectorize 里保存完整 memo 内容、附件正文或私密大段文本。
- 不因为接入语义搜索而绕过 Cloudflare Access。
数据归属
| 数据 | 事实源 | 说明 |
|---|---|---|
| memo 内容、状态、可见性、时间戳 | D1 | 权威业务数据。 |
| 标签、payload、relations、shares | D1 | 参与过滤和权限判断。 |
| 附件二进制和导出包 | R2 | D1 只保存对象元数据和 R2 key。 |
| embedding vector | Vectorize | 可删除、可重建的派生索引。 |
| 少量检索元数据 | Vectorize metadata | 只放回查 D1 和工具观测所需字段。 |
Vectorize 返回命中后,FlareMo 必须回 D1 读取 memo,并重新校验 status、visibility、share 状态和 Access 边界。搜索响应不会直接把 Vectorize metadata 当作最终 memo DTO。
索引记录
memo 向量 ID:
memos/{memo_id}#chunks/{chunk_index}
查询时去掉 #chunks/ 后缀即可还原 memo id。memo 向量 metadata 只保留两个字段:
{
"memo_id": "…",
"user_id": "…"
}
- memo 向量按可见性分区写入 namespace:私密 memo 的向量写在作者的个人 namespace(
user:{userId}),对团队可见的 memo(protected/public)写在共享团队 namespace(team:{teamId},当前部署用常量default)。metadata 里的user_id只是归属标注,不作为 Vectorize 侧的查询预过滤。可见性转换通过 outbox 的relocate任务把向量搬家到目标 namespace(getByIds原样读回向量值,不重新生成 embedding),写入路径在 upsert 前先做 index 级deleteByIds清掉旧分区的残留。授权边界是查询时回 D1 的memoReadScope复查(见「查询流程」),而不是 namespace 或 metadata 过滤。设计见 vector-namespace-design.md。 - memory 向量保留 per-user namespace(namespace = 记忆所属用户),因为记忆召回只允许触达调用者自己的条目。
- metadata 只用于过滤和回查。不要把完整
content、附件正文、share token、Access Service Token、用户邮箱或其他凭据放进 Vectorize metadata。
写入流程
创建或更新 memo 时:
- 先写 D1,同时在
embedding_tasksoutbox 表记录一条索引任务(内容/状态变化投reindex,可见性变化投relocate)。 - 请求路径的 outbox sweep 和每日 Cron(
17 3 * * *)都会派发任务;失败任务保留重试计数,D1 的embedding_status记录not_indexed / pending / indexed / error。 - 派发时从 D1 重新读取 memo,按内容切成稳定 chunk,用 Workers AI(默认
@cf/qwen/qwen3-embedding-0.6b,1024 维)生成 embeddings。 - 批量 upsert 到由当时可见性决定的 namespace(metadata 带
memo_id/user_id,并记录embedding_chunks),并在 D1 记录indexed。写入前先对该 memo 的全部 chunk id 做 index 级deleteByIds,保证向量在任一时刻只存在于一个分区。
可见性转换(发布到团队 / 撤回个人)由 relocate 任务处理:从索引 getByIds 原样读回 chunk 向量值,deleteByIds 清除旧位置后按目标 namespace 重新 upsert——内容未变就不重新生成 embedding;若读回的向量缺失则回退到重新 embed(同时自愈索引)。搬迁任务的派发同样走请求路径 sweep + Cron。
如果 embedding 或 Vectorize 写入失败,D1 写入仍然成功。失败只影响语义搜索召回,不影响 memo 创建、更新、导出、分享或 Memos-compatible API。
更新和删除同步
更新 memo 时:
- D1 是先行写入。
- 内容变化通过新的 outbox 任务重新生成 chunk 和 embeddings。
- 重新派发会对该 memo 的全部 chunk vector 重新 upsert,旧的 chunk id 集合被覆盖。
删除、进入回收站或状态不可索引时:
- 派发时会从 D1 重读 memo;只有
normal/archived的 memo 会被索引。 - memo 不存在、被 hard delete 或状态不可索引时,对应 chunk vectors 会被删除,D1 的 embedding 状态被清理。
Vectorize V2 的 mutation 可能不是立刻对查询可见,因此查询层始终以 D1 状态为最终过滤条件。
重建索引
必须支持从 D1 全量重建 Vectorize。备份演练与恢复流程(pnpm backup:drill / pnpm backup:drill:remote)会把目标 Vectorize index 视为空,并为正常/归档 memo 重新写入 reindex 任务,让请求 outbox 或 Cron 完成重建;不要把旧 D1 的 indexed 标记误当成新 index 中真的存在向量。
重建是可重复的。只要 D1 和 R2 还在,Vectorize 丢失不应造成业务数据丢失。
查询流程
语义查询(/api/app/search/semantic)时:
- 对用户查询生成 embedding(同一 provider、同一模型)。
- 在调用者有权检索的 namespace 分区内做向量查询:个人 namespace 总是查;团队 namespace 在默认
team布局下加入(FLAREMO_VECTORIZE_TEAM_LAYOUT=solo时跳过)。候选总预算topK = min(limit * 5, 100)在分区之间拆分,两组结果按 memo 聚合去重、取每 memo 最高 chunk 分。 - 去掉
#chunks/后缀得到 memo id,回 D1 用memoReadScope(私密 / 团队可见 / 公开的三档可见性权限矩阵)加status in (normal, archived)批量读取。这一步 D1 复查是唯一的授权边界,路由不得绕过它直接查memos表。 - 按分数排序,返回 FlareMo 自己的 search result DTO,而不是直接返回 Vectorize match。
普通关键词搜索仍走 D1 FTS5。降级路径:embedding provider 或 Vectorize index 缺失/报错时,语义搜索回退到 D1 FTS5 关键词搜索;FLAREMO_EMBEDDING_PROVIDER=none 时索引派发直接跳过,FTS5 搜索完全不受影响。
隐私边界
- 生产实例仍由 Cloudflare Access 保护。
- 脚本、MCP 和 Memos-compatible 客户端使用 FlareMo
memos_pat_PAT;如果生产入口仍启用 Cloudflare Access,再附加 Access Service Token。Access Service Token 不能替代应用层 PAT。 - embedding provider 只能接收必要的 memo 文本。
- 不索引已删除或不可见 memo;trash / hard delete 的向量会被清除。
- 私密 memo 可以进入共享语义索引,但查询结果必须经过
memoReadScope复查,私密内容只会回到作者本人的搜索结果里。 - 公开分享不自动开放语义搜索结果;分享只暴露 share token 对应的 memo 内容。
失败恢复
语义搜索是可降级能力:
- Vectorize 不可用时,保留 D1 FTS5 普通搜索。
- Workers AI 不可用时,memo CRUD 仍然成功,任务留在
embedding_tasks待重试。 - 部分 memo 索引失败时,搜索结果可以缺失该 memo,但不能返回过期或无权访问的数据。
- 索引版本不一致时,以 D1 为准,必要时通过恢复流程触发重建。
实现状态
语义搜索已上线,对应的基础设施:
wrangler.jsonc的VECTORIZE_MEMOS(flaremo-memos)、VECTORIZE_MEMORIES(flaremo-memories)和 Workers AIAIbinding。- D1 的
embedding_tasksoutbox 表,以及memos/memory_items上的embedding_*状态字段。 - 派发通道:请求路径 outbox sweep + 每日 Cron,不依赖 GitHub Actions 或额外队列。
- API contract:
/api/app/search/semantic(Zod schema、每月语义搜索配额检查)与前端搜索栏的「语义搜索」入口。 - D1 回查(
memoReadScope)、状态过滤、删除同步和降级路径均有 Vitest 覆盖。 - Agent Memory 的语义召回共用同一套 provider / outbox 基础设施,但向量保留在 per-user namespace,见 agent-memory.md。
