Agent Platform · Detail Design

Memory Retrieval 召回算法

scope 过滤 + Hybrid 候选生成 + 四维加权打分 + 截断双闸——把"召回"从一句话的算法名落到可实现、可观测、可调参的工程契约。

返回文档目录回到 Memory / Context

scope 优先于相似度

租户 / 用户 / persona / team 行级硬隔离,先过 scope 再算相似度,不允许"宽松匹配"

Hybrid 而非纯向量

向量召回 + BM25 关键词召回,RRF 融合,专有名词与 ID-like 词不漏召

阈值优先于 topK

低分条目宁可不要——噪音让 LLM 跑偏,召回质量优先于召回数量

详细设计说明

DESIGN DOCUMENT · MEMORY RETRIEVAL

把"召回"从一个标题展开成可实现、可调参、可观测的算法契约

本文档专门讲 Retrieval:从 T2 长期 store 取出与当前对话相关的记忆条目。其余四个 Memory 动作(Precheck / Compression / Writeback / Isolation)见 Memory / Context 设计。本文重点定义五阶段召回管道、四维打分公式骨架、双闸截断原则,以及 embedding / 向量库 / BM25 引擎的 Provider 抽象边界——具体模型与产品由实现选型决定,platform 层不锁死。

设计主张

召回不是"跑一次 cosine 取 topK"。它是 scope 过滤 + 候选生成 + 多维打分 + 双闸截断 + Trace 五个阶段的有序管道。任一阶段缺位,就会出现跨用户串数据、漏召专有名词、低分噪音污染 ctx、出问题没法回放四类典型故障。

scope-firsthybridmulti-signal scoringthreshold > topKprovider-abstract

评审关注点

  • scope 过滤是否在 DAO 层强制注入,跨租户绝对不串
  • 纯向量是否会漏掉专有名词 / ID-like 词,hybrid 是否兜住
  • 低分条目(< 阈值)是否被坚决砍掉,不靠 token 富余把噪音塞进 ctx
  • 向量索引故障 / store 超时 / 0 召回的降级路径是否清楚
  • memory_retrieve ToolTrace 是否能支持回放与权重调优
D1 scope 优先过滤先于相似度
D2 Hybrid向量+BM25 融合
D3 多维打分语义+时间+频次+来源
D4 双闸截断阈值优先于 topK
D5 Provider 抽象模型与库不锁死

1. 召回管道总览

SECTION GOAL

用一张图把召回的五个阶段、各阶段的输入输出、与 Memory / Context 子系统其他动作的边界讲清楚,作为后续章节的导航。

召回管道接收三类输入:当前 query(user 最新一条消息或子任务描述)、CallerIdentity(tenant_id / user_id / persona_id / team_ids)、token 预算余量(来自 Precheck 估算)。输出是一组 MemoryItem,每条带 content / scope / written_at / source_run_id / 最终 score。整个管道在 Harness 第二步 s2_context_assemble 里调用,调用一次出一个 ToolTrace。

Retrieval Pipeline · 五阶段管道
Inputs query · identity · budget ① scope 过滤 行级硬隔离 tenant / user / persona / team ② 候选生成 Hybrid · 向量 + BM25 RRF 融合 · topN_candidate ③ 打分排序 四维加权 语义 + 时间 + 频次 + 来源 ④ 截断双闸 阈值 + topK + 预算 质量优先于数量 Output · MemoryItem[] content + scope + score + source_run_id 拼进 ctx 的 system message 段 ⑤ Trace memory_retrieve 命中 / 分布 / 耗时 ⑥ 失败降级(向量索引挂 / store 超时 / scope 缺失 / 0 召回) 原则:宁可少召回,不可错召回;scope 缺失直接终止 Run
五阶段串行执行:scope 过滤 → 候选生成 → 打分排序 → 截断双闸 → 输出。Trace 横切,记录每阶段的关键指标。失败降级是单独通道,覆盖四类典型故障。

1.1 阶段定位与边界

阶段核心职责本文档章节
① scope 过滤按 CallerIdentity 在 DAO 层做行级硬隔离,把不可见数据挡在管道入口§ 2
② 候选生成Hybrid 检索:向量召回 + BM25 关键词召回,RRF 融合产出 topN_candidate(典型 30–50)§ 3
③ 打分排序对候选集做四维加权打分(语义 / 时间 / 频次 / 来源),输出每条最终 score§ 4
④ 截断双闸score 阈值 + topK 上限 + token 预算三者取交集,留下进 ctx 的最终条目§ 5
⑤ Trace每次召回写一条 memory_retrieve ToolTrace,含命中、分布、耗时、降级标记§ 7
⑥ 失败降级四类故障的明确降级动作,遵循"宁可少召回,不可错召回"§ 6

1.2 与其他 Memory 动作的边界

动作本文档memory-context-design
Retrieval(召回)本文专属仅保留概要(§3 一段总览 + 跳转)
Precheck(预检)不展开,仅引用其输出(token 预算余量)§2 主写
Compression(压缩)不展开,仅引用其触发条件(召回 + history 超预算)§4 主写
Writeback(写回)不展开,但 source_quality 维度引用其写入元数据§5 主写
Isolation(隔离)scope 过滤即是 Isolation 在召回路径的具体落地§6 主写隔离总则

2. scope 过滤(第一道闸)

SECTION GOAL

说明为什么 scope 过滤必须在相似度排序之前、必须在 DAO 行级强制注入,以及 scope 缺失时为什么直接终止 Run 而不是"宽松匹配"。

scope 过滤是召回的第一道也是最硬的一道闸。所有候选条目至少携带四个 scope 标签:tenant_id(必)、user_id(必)、persona_id(写入 persona 私有记忆时必)、team_id(写入团队共享记忆时必)。召回时按当前 RunContext 的 CallerIdentity 做行级过滤——这一步是后续相似度排序的前提,不是优化项,缺一不可。

2.1 四维 scope

维度过滤强度典型 store
tenant_idDAO 行级强制注入,一票否决所有 store 必走,跨租户绝不串
user_id行级过滤user_memory · 个人 session 摘要
persona_id行级过滤persona_memory · 角色私有经验(运营助手 / 代码 reviewer 各自独立)
team_id应用层过滤 + 双向校验team_memory · 当前 caller 必须在 team 成员表内才允许召回

2.2 行级强制注入

scope 过滤的实现要求不能依赖业务代码自觉——任何 store 的 DAO 层在构造查询时必须强制把 tenant_id / user_id 加进 WHERE 子句,业务层无法绕过。这一约束的工程意义:即使某个 Skill 写错了 SQL,跨租户数据也不会泄漏,因为 DAO 层会拒绝没有 scope 谓词的查询。

具体落地由 Provider 决定:

2.3 失败语义

故障动作原因
tenant_id 为空拒绝召回,立即终止 Run,写 ErrorTrace没 tenant 等于"宽松匹配 + 全租户可见",比错答更危险
user_id 为空,但 tenant_id 存在仅召回该 tenant 的 team_memory(公开维度),不召回 user_memory系统级 / 平台维护场景的合理路径
team_id 列表为空跳过 team_memory 维度,其余正常合法状态——caller 不在任何 team 内
persona_id 为空跳过 persona_memory 维度,其余正常合法状态——通用 agent 没有 persona 绑定

3. 候选生成 · Hybrid Search

SECTION GOAL

说明为什么纯向量召回会在中文 + 专有名词场景下漏召回,为什么必须叠加 BM25 关键词路径,以及如何用 RRF 把两路结果融合成同一个候选集。

scope 过滤通过后进入候选生成。这一阶段的目标是从可能上万条记忆里粗筛出几十条候选——既不能漏(漏召回比错召回更难发现),也不能太多(候选越多打分阶段越慢)。Hybrid Search 是工业级 RAG 系统验证过的标准做法:向量召回处理"语义近",关键词召回处理"字面命中",二者用 RRF(Reciprocal Rank Fusion)融合。

3.1 向量召回

预先用 embedding 模型把每条 memory 的 content 转成定长向量,存入向量索引。召回时把 query 转成同一空间的向量,用余弦相似度(或 inner product)找 top-N 最近邻。这条路径擅长"意思接近但用词不同"的场景,比如 query 说"继续推进",能召回写着"上线 / 落地 / 推进 / 实施"的旧记忆。

子项规则
向量维度由 embedding 模型决定(典型 768 / 1024 / 1536);同一 store 内必须一致,模型升级 = 全量重算
距离度量余弦相似度归一到 [0, 1],作为后续打分的 semantic_sim 输入
索引结构HNSW / IVF-PQ 等近似最近邻索引;精确度可调,召回率优先于延迟
top-N 上限典型 30;过大会让候选集稀释,过小会漏掉边缘相关条目
scope 注入必须以 metadata pre-filter 形式参与检索,不允许 post-filter

3.2 关键词召回(BM25)

纯向量在三类场景下会漏召回:专有名词("Q2 launch" / "模块 9" / "项目代号 X")、ID-like 词(run_id / 文件路径 / 错误码)、低频专业术语(embedding 训练语料没覆盖到的)。BM25 走全文倒排索引补一手——它对字面命中很敏感,能把"提到 Q2 launch"的旧记忆稳稳召回。

子项规则
分词中英混合走基础分词器(jieba / ik / ES analyzer);专有名词维护词典,避免被切碎
评分BM25 标准公式(k1 ≈ 1.2,b ≈ 0.75);输出原始分数,召回阶段不归一
top-N 上限典型 20;BM25 在长尾词上排序更稳,召回数量略少于向量路径
停用词常规中英文停用词;不过滤短查询(避免"@ 协作群"被切完没了)
scope 注入同向量路径——BM25 引擎的 filter 子句强制带 scope 谓词

3.3 RRF 融合

两路召回各自给出有序列表,RRF(Reciprocal Rank Fusion)按"排名倒数"相加,得到融合排序。它的好处是不依赖两路分数同尺度——向量的 cosine 和 BM25 的原始分数本来就不可比,RRF 只看 rank 不看 score,融合鲁棒。

rrf_score(doc) = Σ (over 各检索器 i) 1 / (k + rank_i(doc))

其中 k 是平滑常数,工业界经验取 60。某条记忆只在向量路径出现、不在 BM25 出现时,BM25 项视为 0(不参与求和);反之亦然。融合后按 rrf_score 倒序,取前 topN_candidate(典型 30–50)进入下一阶段打分。

3.4 候选集大小

topN_candidate权衡典型场景
20打分快,但可能漏掉边缘相关条目实时对话、低延迟优先
30 – 50默认值;打分阶段在毫秒级,覆盖足够主流场景
100+打分耗时显著,效益边际递减;除非做研究 / 评估离线评估管道

候选集大小不固定写死,由 Provider 配置参数 retrieval.candidate_top_n 控制;同一部署可按 user / agent 维度做 override,灰度调参不需要发版。


4. 打分排序 · 四维加权

SECTION GOAL

定义召回打分的四个维度(语义 / 时间 / 频次 / 来源)各自的物理意义、归一区间、加权公式骨架,以及为什么权重要给区间不给死值。

候选集进来后要做精排。单靠 RRF 排名不够——RRF 只看相对位置,不看绝对相关性,也不看时效。打分阶段把四个独立信号融合成最终 score,作为后续截断的依据。

final_score = w_sem · f_sem + w_rec · f_rec + w_freq · f_freq + w_src · f_src

四个分量都归一到 [0, 1],权重 w_* 由配置决定。下面分别说每一维。

4.1 语义相似度(f_sem)

来源是 §3.1 向量召回的余弦相似度,归一到 [0, 1]。它衡量的是"这条记忆和 query 在语义空间里有多近"——主轴信号,权重最大。

4.2 时间衰减(f_rec)

越新的记忆权重越高。时间衰减用指数函数,让旧条目优雅退场而非一刀切:

f_rec = exp( -Δt / τ )

Δt 是当前时间到 written_at 的天数;τ 是半衰期(典型 30 天)。直观理解:

Δtτ=30 天时的 f_recτ=90 天时的 f_rec
1 天0.970.99
7 天0.790.93
30 天0.370.72
90 天0.050.37
365 天≈ 00.02

τ 由场景决定:

不同 memory 类型在写入时通过 decay_class 字段标记,召回时查表取对应 τ。

4.3 频次加成(f_freq)

被多次写入或多次确认的记忆更可信。用对数归一避免高频条目过度压制其他维度:

f_freq = log( write_count + 1 ) / log( 11 ) (写 10 次封顶为 1)
write_countf_freq
10.29
30.58
50.75
10+1.00

频次的工程含义:用户反复说同一件事("再说一次,不要 emoji")= 强偏好,应该跑赢只说过一次的偏好。

4.4 来源可信度(f_src)

记忆的写入来源不同,可信度也不同。这一维主要用于压制 LLM 自动写回的噪音条目:

来源f_src说明
用户显式 "记一下 X"1.00最强信号
从 SUCCEEDED Run 写出 + 用户未否认1.00正常路径
用户反馈纠正写出("以后不要再这样做")1.00纠偏类强信号
LLM 主动写回(无人确认)0.60压制噪音;如果用户后续没纠正,频次会自动累计补偿
从 FAILED / REJECTED Run 写出0.30原则上 Writeback 已经过滤这类,0.30 是双保险
批量导入 / 迁移而来0.50无 Run 上下文兜底,给中性权重

4.5 加权公式与权重边界

把四维合起来:

final_score = w_sem · f_sem + w_rec · f_rec + w_freq · f_freq + w_src · f_src (Σ w = 1.0,所有 w ∈ [0, 1])

权重不写死具体数值,给一个工业经验的参考区间和场景说明,由 Provider 配置:

权重参考区间调高的场景调低的场景
w_sem0.5 – 0.7对话型场景,语义匹配最重要项目管理 / 状态追踪场景,时效更重要
w_rec0.15 – 0.30状态类记忆为主、时效敏感偏好类记忆为主、长期稳定
w_freq0.05 – 0.15用户反复确认的偏好极重要每条记忆都只写一次的场景,频次没区分度
w_src0.03 – 0.10LLM 自动写回噪音多,需要压制所有写回都有人审,来源差异不大

默认配置建议:0.60 / 0.25 / 0.10 / 0.05,作为冷启动权重;上线后根据 ToolTrace 的命中分布与用户反馈周期性调整。不要把权重当固定参数——它是平台可调的运营参数,跟 LLM 温度、HITL 阈值同一类。


5. 截断双闸

SECTION GOAL

定义打分输出到最终进 ctx 之间的两道闸:score 阈值(质量优先)+ topK 上限(数量上限),并说明 token 预算如何与 Precheck 对齐。

打分排完序之后并不直接全部塞进 ctx——要过两道独立的闸。这两道闸是平等的,任一不满足都不进 ctx。这条原则在工程上的反向表述更清楚:"即使 token 预算还有富余,分数低于阈值的也坚决不要"。

5.1 score 阈值(质量闸)

设一个 absolute 阈值 score_threshold(典型 0.4–0.5),低于该值的候选条目直接砍掉,不进入 topK 排队。这一闸的工程意义:低分条目对 LLM 是噪音而不是信息——塞进 ctx 反而让模型注意力被无关历史冲淡。

阈值含义调整方向
0.30宽松,召回多但夹杂边缘条目用户反馈"AI 经常提无关旧事"时调高
0.40默认偏宽主流场景
0.50偏严,只留高置信条目对幻觉敏感的合规场景
0.60+非常严,可能召回为空仅在准确率压倒一切的窄场景

5.2 topK 上限(数量闸)

哪怕分数都过阈值,最多也只取 topK 条进 ctx(典型 5–10)。这一闸保证记忆段不会无限膨胀,给 system + history + 工具 schema 留出预算空间。

topK典型 token 占用适用
3 – 5100 – 250 tokens短对话 / 低预算模型
5 – 10250 – 600 tokens默认
10 – 20600 – 1500 tokens长上下文模型 + 高复杂度任务

5.3 token 预算对齐 Precheck

第三道隐式闸:召回结果总 token 不能超过 Precheck 给出的预算。Memory / Context 设计文档 §3.2 已经定义了召回部分占 target_budget × 0.4 的总配额——召回管道在打分排序后做累加截断,超预算时按 score 倒序丢弃尾部条目。

三闸的优先级:

  1. score 阈值——绝对的,低分一律不要
  2. topK 上限——数量硬上限
  3. token 预算——动态调整,按 Precheck 余量决定能塞多少

三者取交集:候选 ⊃ (过阈值) ∩ (前 K) ∩ (累计 token ≤ 预算)。


6. 失败降级

SECTION GOAL

列举召回管道四类典型故障的明确降级动作;核心原则是宁可少召回也不要错召回。

召回链路涉及向量索引、关系型 store、BM25 引擎、缓存层多个外部依赖,故障概率不低。降级原则有两条:(1)scope 缺失永远不允许"宽松匹配"——错答比无答更难发现;(2)打分维度的某一路挂了允许降级,整路都挂了直接走"无记忆"路径。

故障降级动作状态影响
向量索引不可用跳过 §3.1 向量路径,仅走 BM25;打分阶段 f_sem 取 BM25 rank 反推的近似值召回率下降但不返错;写 WarnTrace
BM25 引擎不可用跳过 §3.2 关键词路径,仅走向量;打分照常专有名词召回率下降;写 WarnTrace
两路检索都不可用跳过 Hybrid,仅走"按 (user_id, persona_id) 直查最近 N 条"的 fallback 列表,绕过相似度排序召回质量明显下降;写 ErrorTrace + 告警
store 整体超时退到只用 system + 当前 input,按"无记忆"路径执行对话能继续;写 WarnTrace
scope 上下文缺失(tenant_id 为空)拒绝召回,立即终止 RunRunState → FAILED → TRACING → CLOSED_FAILED;写 ErrorTrace
召回结果 0 条(合法)正常进入装配,按"无记忆"路径执行无影响;不写 Warn
打分阶段超时用 RRF 融合排名直接当 final_score,跳过四维加权排序质量下降但不阻塞;写 WarnTrace

所有降级路径都必须留 Trace 字段(§7),这样事后可以查"这次 Run 是不是因为某路降级导致召回质量下降,进而影响了 LLM 输出"。


7. Trace 与可观测

SECTION GOAL

定义 memory_retrieve ToolTrace 的字段集合、用于权重调优的召回质量指标,让"为什么这次召回了这几条而不是那几条"成为可机器查询的事实。

每次召回写一条 ToolInvocationTrace(虚拟 tool 名 memory_retrieve)。它和真实 Tool 调用走同一套 trace schema,区别仅在于 skill_id = memory.retrieve、不进沙箱。

7.1 memory_retrieve ToolTrace 字段

字段族关键字段用途
调用上下文run_id · step_id · caller_identity(tenant / user / persona / team_ids)· budget定位是哪次 Run 的召回
查询query_hash · query_token_count不写原文(隐私),只留 hash 用于聚合统计
scope 过滤scope_filter(生效的 SQL 谓词摘要)· filtered_count验证 scope 注入是否符合预期
候选生成vector_top_n · bm25_top_n · rrf_k · candidate_count_after_fusion调试 hybrid 是否正常工作
打分weights(w_sem / w_rec / w_freq / w_src)· score_distribution(min / median / max)· score_threshold调权重必看
截断topK · token_budget · final_count · final_token_total验证三闸是否按预期截断
命中条目memory_id 列表 + 各自 final_score(不存 content,按 id 关联)具体召回了什么
降级标记degradation: vector_unavailable / bm25_unavailable / store_timeout / scope_missing / 无事后查"这次为什么质量下降"
耗时各阶段独立 latency(scope_ms / candidate_ms / scoring_ms / truncation_ms)定位性能瓶颈

7.2 召回质量指标

在 ToolTrace 之上做聚合,得到指标看板,作为权重调优 / 阈值调优的输入:

指标定义用途
召回命中率final_count > 0 的占比过低 → 阈值或 topK 太严 / 候选池太小
平均最终 score命中条目 final_score 的均值稳定下降 → 提示数据陈旧或权重失衡
token 利用率final_token_total / token_budget持续低于 0.5 → topK 太小,可放开;持续 1.0 → 预算压力大
降级路径占比带 degradation 标记的 trace 占比非零项需要立即排查依赖健康度
scope_filter 异常率filtered_count = 0 但理论应有候选的情况排查 DAO 注入 / 索引 metadata 一致性
用户反馈 vs 召回用户在对话中纠正"AI 提到了无关旧事" 与召回 score 分布的关联调整阈值的最终决策依据

这些指标对接 可观测与 Replay 的总体看板,按 tenant / persona / agent 三维度切分。


8. 与其他模块关系

SECTION GOAL

说明本召回设计被哪些上游模块调用、依赖哪些下游能力、与哪些子系统在边界上需要明确契约。

模块关系边界对齐
Memory / Context 设计本文档是其 §3 召回章节的展开版。memory-context §3 仅保留管道总览 + 跳转链接命名 / 字段 / 阶段编号必须一致;任一处变更需双向同步
Harness 内核设计召回管道在 s2_context_assemble 内调用,输入 RunContext + token 预算余量,输出 MemoryItem 列表本管道是 Harness 第二步的内部调用,不暴露到 Tool 控制器;不进入沙箱
Skill 版本治理memory_retrieve 作为 platform 内置虚拟 skill,manifest 同样走 manifest_hash 锁定,权重 / 阈值升级走 minor,重大算法变更走 major和普通 Skill 一样有版本生命周期;权重 hot-swap 在 manifest 之外的 RolloutConfig 里
可观测与 Replaymemory_retrieve ToolTrace 入主 Trace 表;Replay 时按 trace 内的 weights / threshold / scope_filter 重建当时的召回上下文不写 query 原文(隐私);命中条目按 memory_id 关联,replay 时再去 store 查
治理与 HITL降级路径走 ErrorTrace 时按风险矩阵处理(scope 缺失类视为 high)不为召回单独造一套治理通道

9. 实现选型边界

SECTION GOAL

明确 platform 层不锁死具体 embedding 模型、向量库、BM25 引擎;只定义 Provider 接口契约,留给实现侧按场景选型。

召回管道的所有外部能力都走 Provider 抽象。Provider 之间不需要换源代码,只需要换配置 + 换 manifest_hash。这条原则对应"沙箱执行后端抽象"的同一思路——platform 不绑死具体后端。

9.1 Provider 接口契约

Provider接口要求切换粒度
EmbeddingProvider输入:文本;输出:定长向量(维度由 Provider 自报)。同一 store 内必须保持模型版本一致;切换模型 = 全量 re-indextenant 级
VectorIndexProvider支持 metadata pre-filter + ANN 检索;返回 (memory_id, similarity) 列表tenant 级
KeywordIndexProviderBM25 风格全文检索;支持 filter 子句 + 中英混合分词;返回 (memory_id, raw_score) 列表tenant 级
MemoryStore(DAO)scope 谓词强制注入;按 memory_id 批量查 content 与 metadata;写入时同步更新 vector / keyword 索引部署级

9.2 选型示例(仅举例,不构成绑定)

9.3 不锁死的事

本文档不规定具体使用哪家模型、哪个向量库;这些属于实现选型,由部署环境决定。文档只规定:

这五条是 platform 层的硬契约;除此之外的选型自由。