数据截至 (上游 commit 549301e79f1d)
CORE 检索 — 意图路由 + 6 种查询处理器 + 压缩重排
本章讲什么: 读出侧。当一个 agent 问「John 的电话是多少」或「上周 CORE 项目有啥进展」,CORE 怎么从图谱里取到对的东西。核心思想:不同类型的问题走不同的路。
1. 直觉:为什么不能一套向量搜索打天下
朴素做法:把查询嵌入,向量搜最相似的事实,返回。但这对很多问题很烂:
- 「John 的电话号码」→ 你要的是实体的一个属性,不是「相似的句子」。
- 「John 和 Mike 怎么认识的」→ 你要的是两实体间的连接路径。
- 「上周我聊过哪些话题」→ 你要的是一份枚举清单,根本不用读对话内容。
所以 CORE 先给问题分类,再分流。这就是 search-v2 的两段式:路由(router)→ 处理器(handler)。
自然语言查询
│
▼
① 路由 routeIntent ──► 标签向量召回 + LLM 抽取
│ └─► {queryType, aspects, entityHints, temporal, selectedLabels}
▼
② 按 queryType 分流到 6 个处理器之一
▼
③ 压缩(同会话→compact 文档)+ Cohere 重排 ──► 结果
2. 路由:先分类,再取数
routeIntent(apps/webapp/app/services/search-v2/router.ts:299-340)两步:
Step 1 标签向量召回 —— searchLabels(:173-230)。CORE 给每个「话题标签(label)」存了嵌入;先用查询嵌入召回最相关的标签(top 8),作为 LLM 的候选上下文。
Step 2 LLM 抽取 —— extractAspects(:237-293)把查询连同候选标签喂给 LLM,结构化输出这些字段:
| 字段 | 含义 |
|---|---|
queryType | 6 类之一(见下) |
aspects | 涉及哪些 aspect(Goal/Identity/Event…) |
entityHints | 提到的实体名(John、CORE…) |
temporal | 时间窗(recent/range/before/after/all) |
selectedLabels | 选中哪些候选标签 |
lookupMode | 实体查询是要「属性」还是「泛查」 |
shouldSearch | 这查询到底要不要搜(「你好!」就不搜) |
防幻觉细节: prompt 反复强调「selectedLabels 只能从给定候选里选,没候选就返回空数组,绝不编造标签名」(:41-43、:80)。
3. 6 种查询类型与处理器
routeToHandler(handlers.ts:1438-1611)按 queryType 分流:
| queryType | 典型问题 | 处理器干什么 |
|---|---|---|
aspect_query | 「我的健身目标是什么」 | 按 标签+aspect 取 episode(最常见) |
entity_lookup | 「John 的电话」/「John 是谁」 | 属性模式直取属性;泛查模式取实体相关 episode |
temporal | 「上周 CORE 有啥进展」 | 按时间窗 + 标签取 episode |
temporal_facets | 「上周我聊了哪些话题/人」 | 只枚举话题/实体/aspect,不读内容 |
exploratory | 「CORE 里的搜索实现」 | 按标签取整段会话的压缩文档 |
relationship | 「John 和 Mike 怎么认识」 | 找连接两实体的语句 |
3.1 多路并行 + 兜底
大多数处理器并行跑多条取数路再合并去重。以最常见的 handleAspectQuery(handlers.ts:250-300)为例:
┌─ 标签+aspect 路:getEpisodesForAspect ─┐
查询 ──┤ ─ 实体提示路:getEpisodesViaEntityHints ├─► mergeEpisodes(按 uuid 去重)
└─ 向量兜底路:仅当没匹配到标签时启用 ──┘
第三路是兜底(:281-283):只有当标签一个都没匹配上,才退化成纯向量搜 episode——保证「冷启动 / 标签没建好」时也有结果。
3.2 entity_lookup 的「属性优先」
handleEntityLookup(:316-470)体现了「按问题取数」的精髓。先向量搜出实体,然后:
- 若是 attribute 模式(问「电话」「邮箱」)→ 直接在实体的
attributes里找那个键(大小写不敏感、支持包含匹配,:413-420),找到就只返回实体,不去翻对话。 - 找不到那个属性 → 回退到 broad 模式,取实体相关的 episode(
:444-447)。
3.3 temporal_facets:枚举而非阅读
handleTemporalFacets(:1169-1357)很特别——它回答「上周有什么」时,不返回对话内容,只返回三个维度的清单:话题(带 episode 数)、实体(带提及数)、aspect(带语句数),三路并行用专门的图查询(getTopicsForFacets 等)聚合。适合做「每周摘要」「晨间简报」。
4. 压缩与重排:把结果收拾干净
取到一堆 episode 后,两道收尾:
4.1 用压缩文档替换零散 episode
replaceWithCompacts(handlers.ts:877-999):同一个会话(session)往往有很多条 episode。CORE 在摄取时已为会话生成了压缩文档(compact,一段总结)。检索时若某会话命中超过 2 条 episode,就用那份压缩文档替换它们(:939),用命中的最高分作为该压缩文档的相关度:
命中:会话X 的 ep1, ep2, ep3, ep4 (零散、重复)
│ replaceWithCompacts(>2 条才替换)
▼
返回:会话X 的压缩文档(1 段总结) + relevanceScore=该会话最高分
这让返回给 agent 的上下文更紧凑、不重复。
4.2 Cohere 重排,失败回退向量
applyEpisodeReranking(:754-821):有 COHERE_API_KEY 就用 Cohere rerank-v3.5 按查询给 episode 打分、过滤低分;没 key 或失败时回退到向量相似度重排(applyVectorReranking,:707-748),再不行就保持原序。多层降级保证「永远有结果」。
4.3 broad recall 兜底
还有一层 augmentEpisodesWithBroadRecallBackstop(:1140-1163):用旧版 SearchService 再做一次宽召回,把没覆盖到的候选补进来(受 env 开关控制)。这是「宁可多召回也别漏」的保险。
4.4 失效事实也一并带回
normalizeToRecallResult(:1027-1065)在返回结果时,会额外把这些 episode 上已失效的事实抽出来(extractInvalidatedFacts,:988-1021)单独放在 invalidatedFacts 里。这样消费方既知道「现在如何」,也能看到「曾经如何」——呼应 §2 的双时间轴设计。
5. 巧妙之处
- 先分类再取数:6 种 queryType 各走最优路径,避免「一套向量搜索打天下」的低质量。
- 属性查询不读对话:问电话就从
attributes直取,快且准。 - 枚举型查询不读内容:temporal_facets 只聚合计数,做摘要时省大量 token。
- 层层降级:Cohere→向量→原序;标签→向量兜底;broad recall 兜底——任何外部依赖挂掉都还能出结果。
- 压缩替换 + 带回失效事实:上下文既紧凑又完整(含历史)。
6. 边界与局限(整组文档收口)
诚实说几点:
- 重度依赖 LLM:摄取要 normalize/extract/reflect/classify 多次调用,检索要路由调用——延迟和成本都不低,所以才有同步/异步分离、稀疏输出、diff-only 等省钱设计。
- 去重/矛盾判定的对错取决于 LLM:召回缩小了候选,但最终裁决是 LLM,误判会让记忆变脏或丢失。代码里大量「失败回退保留原样」就是对此的防御。
- 存储绑定多后端:Neo4j(图)+ Postgres(Aspects/Document/队列)+ 向量库(pgvector/qdrant/turbopuffer 可选,
packages/providers/src/vector/)。自托管要把这套都拉起来。 - provider 抽象但默认 Neo4j:
ProviderFactory.getGraphProvider()是接口,但本仓只见 Neo4j 实现(packages/providers/src/graph/neo4j/)。
7. 横向对比
同 shelf 的 memory-context 兄弟项目里,CORE 的取舍辨识度很高:
- 对 比「上下文窗口/摘要型」记忆(如 Letta/MemGPT 思路):那类把记忆当「可分页的上下文」,靠 agent 自己读写内存块;CORE 走的是外部化的时序知识图谱——事实是结构化节点、带双时间轴、有显式失效语义,更像一个「带历史的数据库」而非「更大的上下文」。
- 强项:时间演化(失效而非删除)、世界/心声分存、检索按意图分流。
- 代价:工程更重(多 LLM 阶段 + 多存储),不是「丢进 prompt」那么轻。
8. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 意图路由(分类+抽取) | apps/webapp/app/services/search-v2/router.ts | routeIntent / extractAspects |
| 标签向量召回 | apps/webapp/app/services/search-v2/router.ts | searchLabels |
| 处理器分流 | apps/webapp/app/services/search-v2/handlers.ts | routeToHandler |
| aspect 查询(多路并行) | apps/webapp/app/services/search-v2/handlers.ts | handleAspectQuery |
| 实体查询(属性/泛查) | apps/webapp/app/services/search-v2/handlers.ts | handleEntityLookup |
| 时间窗枚举 | apps/webapp/app/services/search-v2/handlers.ts | handleTemporalFacets |
| 关系查询 | apps/webapp/app/services/search-v2/handlers.ts | handleRelationship |
| 压缩替换 | apps/webapp/app/services/search-v2/handlers.ts | replaceWithCompacts |
| Cohere/向量重排 | apps/webapp/app/services/search-v2/handlers.ts | applyEpisodeReranking / applyVectorReranking |
| 带回失效事实 | apps/webapp/app/services/search-v2/handlers.ts | extractInvalidatedFacts |