跳到主要内容

三路融合检索:召回是怎么打分的

本章讲什么: 一个查询进来,agentmemory 怎么从三套索引(关键词 / 向量 / 图谱)各拿一批候选,再融合成一个排好序的结果。这是项目「检索质量」的核心,也是最值得借鉴的工程。

1. 为什么要三路

单一信号都有盲区:

  • 纯关键词:查「认证」搜不到只写了「OAuth login」的观察(没有字面重叠)。
  • 纯向量:语义相近但会把精确的文件名/符号名召回得很糊。
  • 纯图谱:只有当查询里有可识别实体时才有用。

所以 agentmemory 三路都跑,各取所长,再融合。README 的总结(README.md:931-937):

干什么何时启用
BM25词干化关键词匹配 + 同义词扩展总是开
向量稠密嵌入的余弦相似度配了嵌入 provider 才开
图谱知识图谱按实体遍历查询里检测到实体才开

2. 第一路:BM25 倒排索引

它要解决的: 经典的关键词相关性打分——一个词在这篇文档里出现得越多、在整个语料里越罕见,越相关。

agentmemory 自己实现了 Okapi BM25(src/state/search-index.ts,符号 SearchIndex),k1=1.2b=0.75(行业默认值)。结构是三张表:entries(文档元信息)、invertedIndex(词 → 文档集合)、docTermCounts(文档 → 词频)。

BM25 打分公式直接看源码(search-index.ts:118-121):

// search-index.ts:118 —— 经典 Okapi BM25 单项打分
const numerator = tf * (this.k1 + 1);
const denominator = tf + this.k1 * (1 - this.b + this.b * (docLen / avgDocLen));
const bm25Score = idf * (numerator / denominator) * weight;

这一路有两个加分设计:

前缀匹配。auth 也能命中 authentication。实现是把所有词排序后二分定位(lowerBound,search-index.ts:127-150),对前缀匹配的词用 0.5 折扣的 IDF 打分。这让不完整的关键词也能召回,对 agent 的模糊查询很友好。

同义词扩展。 查询词先展开出同义词,原词权重 1.0、同义词 0.7(search-index.ts:90-101,用 getSynonyms)。

还有一处国际化细节:tokenize 时(search-index.ts:248-262)对 CJK(中日韩)走 segmentCjk 分词,对其他语言走 stem 词干化。没装可选分词器时 soft-fall 到整段 tokenization。

3. 第二路:向量余弦

它要解决的: 语义相似——「修复登录 bug」和「OAuth 回调报错」字面不重叠但意思相关。

实现是个朴素的暴力 KNN(src/state/vector-index.ts,符号 VectorIndex):一个 Map<obsId, {embedding, sessionId}>,查询时对每条算余弦相似度,用一个 size 受限的堆维护 top-k(vector-index.ts:49-77)。没有 HNSW/IVF 那类近似索引——这是个明确的边界(见第 5 章)。

这一路有个反复踩过的坑值得一提:Float32Array 的 base64 序列化必须显式传 byteOffset + byteLength(vector-index.ts:8-21),否则 Node 的 Buffer pool 会让一个本该 N 维的向量变成 2048 维的幽灵视图——issue #455/#469/#584/#587 都是这个。维度一旦串了,跨维余弦返回 0,搜索静默失效。所以在加载持久化索引时(src/index.ts:405-447)会逐条校验维度,有不匹配就拒绝启动(除非显式 AGENTMEMORY_DROP_STALE_INDEX=true)。

4. 第三路:知识图谱遍历

它要解决的: 当查询里有具体实体(文件名、函数、概念),沿图谱的边走一两跳能带出相关但不含查询词的记忆。

从查询里抽实体(extractEntitiesFromQuery),按实体搜图(searchByEntities,2 跳),再用向量 top-5 结果反向扩展图谱(expandFromChunks,src/state/hybrid-search.ts:117-126)。图谱检索全程 best-effort——任何一步抛错就吞掉、继续(hybrid-search.ts:104-126)。

5. 融合:RRF + 动态归一化

三路各返回一个排好序的列表,怎么合成一个?agentmemory 用 RRF(Reciprocal Rank Fusion,倒数排名融合),k=60(hybrid-search.ts:20,src/state/hybrid-search.ts 符号 HybridSearch)。

RRF 的直觉: 不看各路的原始分数(量纲不可比),只看排名。一个文档在某路排第 r 名,贡献 1/(60+r) 的分,三路加权求和。排得越靠前贡献越大,且对离群分数稳健。

// hybrid-search.ts:215 —— RRF 融合(真实源码)
combinedScore:
effectiveBm25W * (1 / (RRF_K + s.bm25Rank)) +
effectiveVectorW * (1 / (RRF_K + s.vectorRank)) +
effectiveGraphW * (1 / (RRF_K + s.graphRank)),

精妙处:动态权重归一化。 三路不一定都有结果——没配嵌入就没向量,查询没实体就没图谱。如果某路缺席却仍占着权重,会稀释有结果的那几路。所以融合前先把缺席流的权重置零、再把剩下的归一化到和为 1(hybrid-search.ts:194-206):

// hybrid-search.ts:197 —— 缺席的流权重归零,剩下的重新归一
let effectiveVectorW = hasVector ? this.vectorWeight : 0;
let effectiveGraphW = hasGraph ? this.graphWeight : 0;
const totalW = effectiveBm25W + effectiveVectorW + effectiveGraphW;
if (totalW > 0) { effectiveBm25W /= totalW; /* ... */ }

这样 BM25-only 模式(没嵌入)和三路全开模式,排序行为都合理,不用改配置。

6. 融合之后:去重、回填、可选重排

融合排序后还有三步收尾(hybrid-search.ts:223-239):

  1. 按会话去重(diversifyBySession,hybrid-search.ts:242):每个会话最多取 3 条(maxPerSession=3),避免一个高产会话霸屏,保多样性;不够再回填。
  2. 回填实体内容(enrichResults,hybrid-search.ts:278):索引里只有 id,要从 KV 把 CompressedObservation 拉回来。关键兜底:如果在观察 scope 里找不到,就去 KV.memories 找——因为 mem::remember 存的显式记忆也进了同一个 BM25 索引,要把它强转成观察形态一起召回(hybrid-search.ts:300-304)。
  3. 可选重排(rerank):RERANK_ENABLED=true 时对头部 20 条做 cross-encoder 重排。

7. 这一路的设计取舍

  • 量纲无关的融合:RRF 只用排名,所以不必校准 BM25 分数和余弦相似度的量纲——加一路新信号(比如重排)也不破坏融合。
  • 优雅降级:三路任意缺席都能跑;向量/图谱抛错被吞;BM25 永远兜底。这是「self-healing」在检索侧的体现。
  • 统一索引,异构来源:观察和显式记忆共用一套 BM25/向量索引,召回时再按来源回填——简化了检索路径。

下一章看召回回来的记忆怎么演化、衰减、被取代 → 03-memory-lifecycle.md