04 - 读取路径:向量、图与结构化
前置:02 篇的纯追加写入、03 篇的作废语义。库里现在有一堆条目,其中有些已作废、有些互相矛盾。
本篇回答:在用户等回复的那几十毫秒里,怎么从这堆条目里挑出该用的三到十条,以及挑出来之后放在请求的哪个位置。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| BM25 | 一种基于词频的经典文本检索算法。它匹配的是字面词,正好补上向量检索对专有名词不敏感的短板 |
| RRF | 倒数排名融合。把多路检索的结果按各自排名的倒数加权合并,不需要各路分数可比 |
| MMR | 最大边际相关性。在"跟查询相关"和"跟已选结果不重复"之间取平衡,用来去掉一批意思相同的结果 |
| 交叉编码器 | 把查询和候选拼在一起送进模型打分的重排器。比向量点积准,但要为每个候选跑一次模型 |
| 前缀缓存 | 模型服务对请求前缀的缓存。前缀有一个字节变化,后面全部要重算 |
| 召回预算 | 允许注入上下文的记忆条数或 token 数上限 |
一、检索的查询不是用户那句话
最常见的实现是拿用户当前输入直接去检索。它在两类场景下必然失败:
折中方案:只在检测到指代词("那个""上次""他")时才走 C,其余走 A。一个正则或轻量分类器就能做这个分流,代价接近零。
二、纯向量检索的四类失效
向量检索在记忆场景下的失效模式和在文档 RAG 里不太一样,因为记忆条目短、同质、且带时间属性。
三、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' # 模型逐条打分
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 的精确查询,
# 不能混在向量检索里 —— 向量检索不保证一定能召回它。
"安全类全量注入"这条不能省。把过敏源、禁忌药物这类记忆丢进向量检索里跟其他条目竞争排名,等于接受它有一定概率不被召回。
五、注入位置:会不会打掉前缀缓存
这是记忆读取里最容易被忽略的工程细节。请求的渲染顺序是 tools → system → messages,前缀缓存按字节前缀匹配 —— 前缀里任何一个字节变了,后面全部要重算。
怎么确认:看响应里的缓存读取 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 以上,就是用户能感知的了。