Skip to content

第 6 章 检索:多信号融合与预算控制 ​

一家做餐饮供应链的公司给采购 Agent 配了记忆,用来记住「哪些供应商不能碰」。上线两周后出了事故:Agent 向一家农残超标的供应商下了单,而那条标记三个月前就写进库了。查日志,检索确实返回了它——排在第五位,而上下文只装得下前三条。另一次更隐蔽:库里有「用户对花生过敏」和「用户昨天吃了花生没事」,两句都返回了,模型选了后一句的结论。语义相似度对这两句都高,因为它们谈的是同一个对象;而它们说的根本不是同一件事。

两个案例指向两个不同的问题:排序正确但装不下,以及排序本身就错。前者是预算问题,后者是信号问题,表现都是「Agent 答错了」,修复手段却完全不同。

6.1 纯向量为什么在长周期记忆上失效 ​

6.1.1 语义相似不等于决策等价 ​

嵌入模型回答的是「这两段文本在说什么」,它不回答「这两段文本对当下这个决策意味着什么」。在单轮问答里两者高度重合,在长周期记忆里经常分叉。「我对花生过敏」和「昨天吃花生没事」在嵌入空间里非常接近:共享主语、共享宾语、共享话题,任何以余弦为主的排序都会把它们排在相邻位置。但决策含义相反——前者是必须遵守的长期约束,后者是一次可能推翻该约束的观察,也可能只是「那天恰好没发作」。区分依据不在语义里,而在类型、时间、来源这三个结构化维度里:过敏是长期约束,属于身份或偏好类;「昨天吃了没事」是一次事件,有明确的事件时间。这些差异向量看不见。所以问题不是「向量不够强」,而是只覆盖一个维度——加维度不解决,加信号才解决。

6.1.2 通道缺失 ​

还有一种失效与语义质量无关,纯粹是通道缺失。内置嵌入器是词频向量的随机投影:确定、离线、免费,但没有跨词泛化能力——它不知道「laptop」与「ThinkPad」是同类,所以「我有一台 ThinkPad」写入后,查「我的笔记本是什么」会返回空。这个空结果必须与「库里没有这条记忆」可区分:前者该配嵌入器,后者该写入;两者返回值一样,处置相反。所以主检索为空时会触发一层窄范围的词法回退——要求至少一个共享词,并标注这是词面命中而非语义命中,只保证「有共享词就一定找得到」。

6.2 五路信号 ​

6.2.1 五个通道各自回答什么 ​

通道回答什么强项弱项
语义这段话在讲什么跨词泛化、改写命中分不清相似与相关
BM25词面重合术语、编号、型号同义改写失效
实体图涉及哪些实体多跳关联、代词归一依赖抽取质量
时间何时发生、多新时序问题、新旧取舍无时间意图时是噪声
标签属于哪个话题族结构化兜底、跨词形需维护同义词表

表 6-1 五路检索信号的分工

合成不是加权平均,因为尺度不同:BM25 原始分无上界,余弦在零到一之间,时间衰减是指数形式,直接相加会让某一路凭尺度压死其余。所以每一路先归一化再按权重相加,权重有随配置调整——配置了真实嵌入器时语义权重最高,只有内置嵌入器时图与关键词权重相对提高。另有三类结构性加成直接加分、不参与归一化:字面 n-gram 重合、同义归一后同形、标签族命中,它们表示「几乎确定相关」而非「某种程度相关」。

6.2.2 相关性下限必须校准 ​

多路融合的分数有个反直觉性质:最高分恒为一(归一化所致),所以绝对分数没有意义,无关查询的 top1 可以高于相关查询的 top1,只靠融合分阈值筛相关性必然失效。所以下限要定在未归一化的原始信号上:满足任一条:标签结构性命中、同义等价、n-gram 达到下限、原始余弦达到下限、内容词有交集;都不满足的标为低相关,排序后剔除。阈值是标定的:在带同主题干扰项的语料上扫描,0.45 放进 5 条无关项,0.50 放进 2 条,0.55 放进 0 条且相关项全保留,0.60 开始丢真答案,最终落在 0.55。

调用侧写法如下,explain=True 让每条结果带上命中的信号,threshold 在这一层做二次过滤。

python
from mnemosyne import Memory

m = Memory()
hits = m.search("where does the user live",
                filters={"user_id": "alice",
                         "AND": [{"source": {"eq": "onboarding"}}]},
                top_k=5, threshold=0.1, rerank=False, explain=True)
for h in hits["results"]:
    print(h["score"], h["reason"], h["memory"])

reason 是这套设计里最值得读的字段:它把「为什么这条被选中」摊开成「语义 + 关键词 + 近期」,让可疑结果能被定位到具体通道,而不是笼统归因为「模型不行」。

阈值的已知边界必须写清:它对语料分布敏感。若整库围绕同一主题,背景相似度整体偏高,绝对阈值会同时放过无关项,所以指标里额外暴露「候选原始余弦中位数」与「top1 原始余弦」,用于发现整体偏高的库并重新标定。允许返回空结果是刻意选择:对 Agent 而言「没找到」比「给你五条无关记忆」安全;代价是调用方须处理空结果。

6.3 渐进式披露 ​

召回失败还有第三种形态:检索对了,但注入方式把预算烧光了。把 top-10 的正文全塞进上下文,等于让十段文字争夺注意力,而其中只有一条相关。渐进式披露把它拆成两段:第一段只返回目录,含标题、一行摘要、展开成本,不含正文;调用方挑一到两条展开,得到正文、统一信封、图邻居与证据链。

python
from mnemosyne import Memory
m = Memory()

cat = m.recall_catalog("last quarter's pricing decision", k=10)
# 含 title / summary / expand_cost,不含正文
detail = m.expand_memory(cat["results"][0]["memory_id"])
print(detail["content"], detail["neighbors"])

成本从 O(全部) 降到 O(目录 + 少量),前提是目录本身足够判别——如果标题与一行摘要无法区分两条记忆,调用方只能全展开,省下的预算又还回去了。代价是:多一次往返;目录层选错条目时后面的展开再准也无用;目录成本随候选数线性增长,k 从 10 提到 50 就不再便宜。判据是问题的确定性:问题明确时直接召回更省,问题含糊时目录加展开更省。

6.4 预算感知 ​

6.4.1 预算必须真正参与取舍 ​

早期实现的预算是假的:装不下下一条时用 continue 跳过,于是 120、60、30 三种预算产出完全相同的结果。真正参与取舍要分三级降级:放得下给正文;放不下压成胶囊(指针加结构化事实加内容原子);连最小胶囊都放不下就进引用索引,以指针形式回传、不计入正文预算。这样「不超预算」与「事实不丢」同时成立。

python
from mnemosyne import MemoryBrain

brain = MemoryBrain("./memories", enable_embeddings=True)
brain.ensure_init()

results, cost = brain.recall("that machine's specs", k=5, budget_tokens=100)
print(cost["budget_used"], cost["dropped_count"], cost["truncated"])

返回的 cost 里四个字段必须一起读:预算上限、实际用量、丢弃条数、是否截断。只有上限没有用量,无法判断是「库里就这么多」还是「被砍掉了」;没有截断标志,则无法判断结果是否被悄悄缩水。

6.4.2 按层丢弃而不是句中截断 ​

画像卡的 max_tokens 是硬上限,超预算时按层丢弃,顺序是情境 → 未决 → 偏好。这个顺序不是随意排的:情境时效最短,未决事项次之,偏好与约束最稳定也最不该丢——丢掉「用户对花生过敏」的代价远高于丢掉「上周在改报销流程」。

关键是从不句中截断。半句话比没有更糟,原因有三层。第一,截断会改变语义极性:「用户对花生过敏,但上次……」被切在逗号处,剩下的半句仍是个完整断言,而它可能恰好是错的。第二,模型无法识别自己被截断了——它看到的是语法完整的句子,会当成完整信息使用。第三,截断破坏可校验性:返回的文本与库里的记录不再字节一致,基于哈希的核对全部失效。所以预算不足时的正确动作是「整层不注入」,而不是「少注入几个字」——这与 5.4 的胶囊降级是同一条原则:宁可换一种表示,不可切掉一半。

6.5 一站式注入路径 ​

把召回、融合、预算串起来时,最容易做错的是「每一步都对,合起来不一致」——召回用一套权重、重排用另一套尺度、预算按第三种口径数 token。memory_inject 的意义是把这五步固定成一条流水线:查询构建 → 四路召回 → RRF 融合 → 重排 → 预算内注入。RRF 按名次融合而非按分数加权,因为各通道量纲不同,加权合并必然有一边被压死;代价是丢掉「强多少」的信息——第一名与第二名只差一个名次,哪怕原始分差一倍。四个配额账本切开预算:

配额占比装什么超支先砍谁
常驻画像30%稳定身份、偏好与约束不砍
任务相关35%与本次查询相关的记忆按边际价值
经验与禁忌20%可复用方法、负记忆低适用性
未决事项15%待确认、待跟进只留最近

表 6-2 注入管线的四段配额

分账的意义在于防止某一类内容挤占全部预算。没有配额时,一段长会话的召回结果会把「未决事项」整段挤掉,而它恰恰是下次对话最该被提起的东西。代价是总有一部分预算用不满——某类内容不足时配额不会自动转移给别的类。这个浪费是刻意的:动态重分配会让每次注入的结构都不一样,而结构稳定本身是可预期性的一部分。

6.6 失败模式与调参判据 ​

6.6.1 阈值与重排器 ​

阈值调错的两个方向,症状不同,处置也不同。设得太高,召回不足:明明库里有,却返回空或只返回一两条,此时先看下限剔除条数——接近候选总数说明阈值过紧。设得太低,噪声淹没:每次返回满额但一半无关,此时看 top1 与中位数的差距——差距小说明整个候选池都在同一个相似度带上。重排器解决的是另一个问题:候选集里相关项都在,但前 k 条排错了,判据是「召回率够、精确率不够」;候选集里没有正确答案时,重排器无能为力。

症状先查处置
返回空但库里有下限剔除数、嵌入器降阈值或配嵌入器
满额且一半无关top1 与中位数差升阈值、补同义词
候选有但排不进权重与名次加重排器
结果对但装不下预算用量改目录加展开

表 6-3 检索失败模式的判别与处置

第四行最常被误诊:它看起来像排序问题(「重要的没排前面」),实际是预算问题。判别方法很简单——把 top_k 调大看结果是否包含目标条目:包含就是预算,不包含才是排序。

6.6.2 什么时候不该加重排器 ​

重排器的成本是明确的:一次额外模型调用、一段额外延迟,且它的分数会替换而不是融合原分数。替换是必要的——两套不同尺度的分数平均之后得到一个什么都不代表的数——但它也意味着重排判断错了就没有兜底。三种情况不该加:候选数少于五条时收益低于延迟;精确查找(找某个编号、型号)时 BM25 已经足够;语料高度同质时重排器区分不出差别却仍付全部成本。还有一个常被忽略的失败模式:重排器不可用时的行为——如果它的异常导致整个检索失败,那么一个可选的精度提升就变成了可用性风险。正确做法是失败时保留原排序。

6.7 本章小结 ​

本章确立了三条判断。

第一,纯向量在长周期记忆上失效的原因不是精度不够,而是维度不够。 语义相似与决策等价在短文本里重合,在长期记忆里分叉,因为区分依据在类型、时间、来源这些结构化字段里,而向量看不见。「过敏」与「昨天吃了没事」的排序错误无法靠更好的嵌入模型修复,只能靠多信号融合。成立条件是这些字段真被写入并维护(第 4 章);字段缺失时,五路信号退化成一路半。

第二,预算必须参与取舍,降级方式决定失败是否可观测。 装不下就跳过,会让预算参数形同虚设;装不下就截断,会让模型把半句话当完整事实用。正确顺序是正文、胶囊、引用索引三级降级,且必须回传成本账本。这条判断的成立条件是调用方会读账本;不读账本的接入方看到的只是一个「有时多有时少」的结果集。它在预算小于最小胶囊时失效——此时唯一诚实的做法是返回引用索引并明确告知装不下。

第三,检索质量的诊断必须先分清「排序错」还是「预算错」。 两者症状都是「Agent 没用上该用的记忆」,但一个要改信号与阈值,另一个要改注入形态。误诊的代价是把预算问题当排序问题治,于是加权重、加重排、加阈值全部无效。判别只有一条:调大 top_k,看目标条目是否出现。

一个未解决的问题留到第 7 章:本章讨论的所有能力最终都要通过某个接口暴露给 Agent,而接口形态本身会改变使用方式——十一个工具名的接口和五十多个工具的接口,会让调用方写出完全不同的代码。当能力面宽到一定程度,「怎么接」就不再是接线问题,而是设计问题。


Copyright © 2026 胡景堃(Hu Jingkun). 采用 CC BY-NC-ND 4.0 许可。未经授权禁止传播、复制或用于商业化。