Skip to main content

04 - 读取路径:向量、图与结构化

前置02 篇的纯追加写入、03 篇的作废语义。库里现在有一堆条目,其中有些已作废、有些互相矛盾。

本篇回答:在用户等回复的那几十毫秒里,怎么从这堆条目里挑出该用的三到十条,以及挑出来之后放在请求的哪个位置。

本篇会用到的词

意思
BM25一种基于词频的经典文本检索算法。它匹配的是字面词,正好补上向量检索对专有名词不敏感的短板
RRF倒数排名融合。把多路检索的结果按各自排名的倒数加权合并,不需要各路分数可比
MMR最大边际相关性。在"跟查询相关"和"跟已选结果不重复"之间取平衡,用来去掉一批意思相同的结果
交叉编码器把查询和候选拼在一起送进模型打分的重排器。比向量点积准,但要为每个候选跑一次模型
前缀缓存模型服务对请求前缀的缓存。前缀有一个字节变化,后面全部要重算
召回预算允许注入上下文的记忆条数或 token 数上限

一、检索的查询不是用户那句话

最常见的实现是拿用户当前输入直接去检索。它在两类场景下必然失败:

用户输入「那家店还开着吗」,三种构造方式检索到的东西完全不同A · 直接用当前输入查询=「那家店还开着吗」检索到:与"店"相关的一切「那家」指代谁,向量里没有开销:0B · 拼上最近 N 轮查询=最近 3 轮对话全文检索到:多个话题的平均话题一多,向量哪个都不像开销:��只多一点嵌入 tokenC · 小模型改写成自包含查询=「用户上次去的川菜馆」检索到:那一条指代解析掉,话题收敛开销:热路径上多一次调用C 的那次调用在热路径上,用 Haiku 级模型约 200 到 400 毫秒。值不值取决于你的记忆库里同类条目多不多 —— 条目越多,A 的误召回越严重。
写入侧要求每条记忆自包含(02 篇 5.1),读取侧同样要求查询自包含 —— 两边是对称的。只做一边,另一边就成了瓶颈。

折中方案:只在检测到指代词("那个""上次""他")时才走 C,其余走 A。一个正则或轻量分类器就能做这个分流,代价接近零。

二、纯向量检索的四类失效

向量检索在记忆场景下的失效模式和在文档 RAG 里不太一样,因为记忆条目短、同质、且带时间属性。

四类失效有一个共同点:它们都不是"召回了不相关的",而是"召回了看起来很相关的错东西"① 否定「喜欢香菜」「不吃香菜」余弦相似度常在 0.9 以上召回了,但取反了这是最危险的一类② 时间「住北京」(已作废)「住上海」(生效中)两条对"住哪"同样相似时间不在向量里必须靠元数据过滤③ 多跳问:用户老板的偏好库里:用户老板是张三   张三偏好邮件沟通一次检索跨不过去图检索的主战场④ 精确标识符订单号、SKU、错误码被切成子词后语义相似度没有意义BM25 或精确匹配才是对的工具①靠重排缓解(交叉编码器对否定敏感得多),②靠元数据过滤解决,③需要图,④需要字面检索 —— 四类需要四种不同的手段。
第①类值得单独警惕:它不会在任何"召回率"指标上体现出来 —— 相关条目确实被召回了,只是意思相反。发现它的唯一办法是人读注入的记忆,或者专门构造否定样本做评测。

三、Graphiti 的检索:三路召回 + 五种重排

Graphiti 把检索拆成"用什么方法找"和"找到之后怎么排"两层,两层可以自由组合。graphiti_core/search/search_config.py 里定义得很清楚:

class EdgeSearchMethod(Enum):
cosine_similarity = 'cosine_similarity' # 向量
bm25 = 'bm25' # 字面词
bfs = 'breadth_first_search' # 从已知节点出发广度优先走图

class EdgeReranker(Enum):
rrf = 'reciprocal_rank_fusion' # 多路结果融合
node_distance = 'node_distance' # 离某个中心节点越近越靠前
episode_mentions = 'episode_mentions' # 被提到次数越多越靠前
mmr = 'mmr' # 去重复
cross_encoder = 'cross_encoder' # 模型逐条打分
三路各自解决第二节里的一类失效 —— 少一路就少堵一个洞查询已改写成自包含向量:cosine_similarity意思相近的,怕否定与专名字面:bm25订单号、错误码、人名图:breadth_first_search从已知实体出发走多跳重排rrf 融合多路排名或 cross_encoder 逐条打分过滤 + 截断去掉已作废(invalid_at)按预算留 3 到 10 条作废过滤要放在重排之后:放在检索层会让每一路都要带这个条件,图那一路尤其难写;放在最后一步只是一次列表过滤。但要留出余量 —— 过滤会砍掉一部分结果,检索时的 top_k 要比最终预算大两到三倍。
Graphiti 预置了十几种组合(search_config_recipes.py),实践中真正常用的是三个:默认场景 RRF,结果同质化严重时换 MMR,准确率优先且能接受几百毫秒时换交叉编码器。

3.1 五种重排各自适合什么

重排器排序依据什么时候用它
rrf各路排名的倒数之和默认。不需要各路分数可比,实现简单,几乎没有额外开销
mmr相关性减去与已选结果的相似度召回结果高度同质时 —— 记忆库里常见「用户喜欢川菜」「用户偏好川菜」这类近义条目
cross_encoder模型对每个候选逐条打分准确率优先。对否定句的判别显著优于向量,代价是每个候选一次推理
node_distance离指定中心节点的图距离已知当前话题的中心实体时,比如"关于张三的所有事"
episode_mentions这条事实被多少段对话提到过用提及频次近似"重要性"。注意它会系统性偏向老记忆

episode_mentions 的偏差值得注意:一条存在半年的记忆自然比昨天新增的被提到得多。单独用它会让记忆库越老越僵化,通常要和时间衰减一起用。

四、召回预算:注入几条

预算有两个约束,取更紧的那个:

  • token 预算:记忆挤占的是上下文里本可以给对话历史或工具结果的额度
  • 注意力预算:注入的条目越多,其中无关条目把模型带偏的概率越高。这是比 token 更硬的约束

一个可用的起点:3 到 10 条,总量控制在上下文的 5% 以内。判断是否合适的方法不是看命中率,而是关掉记忆跑一遍对照:如果注入 10 条和注入 3 条的回答质量没有差别,说明后 7 条是纯噪声加纯成本。

# 预算不该是一个固定数字,而是按记忆类型分配的
# 理由:安全类记忆漏掉一条的代价,和偏好类漏掉一条完全不是一个量级
BUDGET = {
"safety": {"max_items": None, "always_inject": True}, # 全量注入,不参与排序竞争
"preference": {"max_items": 5, "always_inject": False}, # 参与检索排序
"episodic": {"max_items": 3, "always_inject": False}, # 当少样本用,多了反而干扰
}
# always_inject 的那一档要单独走一次按 user_id 的精确查询,
# 不能混在向量检索里 —— 向量检索不保证一定能召回它。

"安全类全量注入"这条不能省。把过敏源、禁忌药物这类记忆丢进向量检索里跟其他条目竞争排名,等于接受它有一定概率不被召回。

五、注入位置:会不会打掉前缀缓存

这是记忆读取里最容易被忽略的工程细节。请求的渲染顺序是 toolssystemmessages,前缀缓存按字节前缀匹配 —— 前缀里任何一个字节变了,后面全部要重算

同样注入 300 token 的记忆,放的位置不同,需要重算的 token 差二十倍❌ 拼进 systemtoolssystem + 记忆对话历史本轮命中缓存:只有 tools重算:约 12,000 token —— 记忆每轮都在变,缓存永远命不中✅ 放在提问前作为最后一条用户消息的前缀toolssystem(不动)对话历史(不动)记忆本轮命中缓存:tools + system + 全部历史重算:约 600 token代价:记忆离系统指令远了,模型对它的遵循度略降。可以在 system 里放一句固定的「用户相关事实会在提问前给出」来补。
这条规则和「静态内容在前、易变内容在后」是同一件事。记忆是每轮都变的内容,它属于最尾部 —— 把它当成系统提示词的一部分是很自然的直觉,也正是这个直觉让缓存命中率归零。

怎么确认:看响应里的缓存读取 token 数。如果重复请求下它一直是 0,说明前缀里有东西在变 —— 记忆注入位置是首要嫌疑,其次是系统提示词里的时间戳。

六、延迟预算

读取全程在用户等待期间,逐项列出来:

步骤典型耗时能不能省
查询改写(小模型)200–400 ms能。只在检测到指代词时才做
查询嵌入10–30 ms不能,但可以和上一步并行发起
向量检索10–50 ms不能
BM25 检索5–20 ms可以和向量检索并行
图遍历(多跳)30–150 ms只在需要多跳时才走
RRF 融合<1 ms
交叉编码器重排100–500 ms能,绝大多数场景不必要
作废过滤 + 截断<1 ms

默认配置(不改写、不重排、向量 + BM25 并行)落在 30–60 ms,相对一次 1.8 秒的模型调用可以忽略。全都打开会到 700 ms 以上,就是用户能感知的了。

七、"读不到"和"读错"是两个问题

排查方向完全不同,先分清是哪一个:

症状先查什么
模型表现得完全不知道这件事记忆到底写进去了没有(查写入侧的 history 表)→ 写进去了但没召回(把检索结果打进 trace)→ 召回了但被截断(看预算)
模型知道,但用的是过期或错误的版本作废过滤有没有生效 → 同一事实的多条并存里排序对不对 → 是不是第二节①的否定翻转
模型引用了别人的信息检索的租户过滤 —— 这是事故不是缺陷,见 07 篇第三节

三条链路都要求一件事:把每次读回的记忆条目 ID 和文本打进 trace。没有这个,上面每一步都只能靠复现来猜。做法见可观测性 01 篇

八、小结

  • 检索查询要和记忆条目一样自包含;只在检测到指代词时才花那次改写调用
  • 纯向量在记忆场景有四类失效,其中否定翻转最危险,因为它在召回率指标上看不出来
  • 混合检索三路各堵一个洞,RRF 是默认;交叉编码器留给准确率优先的场景
  • 安全类记忆要走精确查询全量注入,不能丢进向量检索里跟别的条目竞争排名
  • 记忆放在最后一条用户消息之前,不要拼进系统提示词 —— 这一条能把前缀缓存命中率从 0 拉回正常
  • 排查前先分清"读不到"和"读错",两者的链路完全不同

下一篇:05 - 文件系统式记忆,另一条路线 —— 不建向量库不建图库,记忆就是一堆模型自己读写的文件。

← 回到 专题索引