Hindsight Recall 检索机制深度解析:TEMPR 四路并行检索、RRF 融合与交叉编码器重排序
【免费下载链接】hindsightHindsight: Agent Memory That Learns项目地址: https://gitcode.com/GitHub_Trending/hindsight2/hindsight
本文基于 Hindsight 官方文档(version-0.7)中《Recall: How Hindsight Retrieves Memories》一文展开,并结合hindsight-api-slim检索管线的源码实现进行逐层剖析。读完后,你将理解 Hindsight 的recall()调用如何在语义、关键词、图谱、时间四个维度并行检索记忆,如何通过 Reciprocal Rank Fusion 融合各路结果、用交叉编码器重排序并施加乘性提升(boosts),以及如何用budget与max_tokens两个独立参数在检索质量与延迟之间做出取舍。
一、为什么单一检索策略不够:记忆召回的核心挑战
当你调用recall()时,Hindsight 会并行运行多种搜索策略,无论你如何措辞查询,都能找到最相关的记忆。Hindsight 将这套四路并行检索体系称为TEMPR。
不同性质的查询需要不同的检索手段:
- "Alice works at Google"→ 需要精确的名字匹配;
- "Where does Alice work?"→ 需要语义理解;
- "What did Alice do last spring?"→ 需要时间推理;
- "Why did Alice leave?"→ 需要因果关系追踪。
没有任何单一检索方法能同时处理好这四种情况,Hindsight 的解法就是让四个互补策略并行运行、再统一融合。
二、四种搜索策略
2.1 语义搜索(Semantic Search)
做什么:理解词语背后的含义,而不仅仅是词语本身。
最擅长:
- 概念匹配:"Alice's job" → "Alice works as a software engineer";
- 同义转述:"Bob's expertise" → "Bob specializes in machine learning";
- 同义词:"meeting" 匹配 "conference"、"discussion"、"gathering"。
为什么重要:你可以用自然的方式提问,无需精确匹配关键词。
从源码看,语义臂与 BM25 臂合并在一条 SQL 中执行(UNION ALL 按 fact_type 分子查询),各语义臂走 Postgres 的部分 HNSW 索引(idx_mu_emb_world、idx_mu_emb_observation、idx_mu_emb_experience),见 retrieval.py。
2.2 关键词搜索(Keyword Search)
做什么:精确匹配特定术语与专有名词,即使它们拼写独特。
最擅长:
- 专有名词:"Google"、"Alice Chen"、"MIT";
- 技术术语:"PostgreSQL"、"HNSW"、"TensorFlow";
- 唯一标识符:URL、产品名、特定短语。
为什么重要:确保不会漏掉提及特定名词或术语的结果——即使它们在语义上离你的查询很远。
后端:Hindsight 提供五个可插拔的 BM25 后端,通过环境变量HINDSIGHT_API_TEXT_SEARCH_EXTENSION选择:
| 后端 | 实现机制 | 兼容 Citus? |
|---|---|---|
native | PostgreSQLtsvector+ts_rank_cd(TF-IDF,并非真正的 BM25) | 是 |
vchord | vchord_bm25扩展 | 否 |
pg_textsearch | Timescalepg_textsearch扩展 | 否 |
pgroonga | PGroonga(Groonga)全文扩展,TokenBigram多语言分词器 | 否 |
pg_search | ParadeDBpg_search扩展,可通过HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER配置分词器(如jieba、chinese_compatible、ngram) | 是 |
在水平扩展的 Postgres(Citus)集群上需要真正的 BM25 排序时,pg_search是唯一选择,参考仓库中的 pg_search docker-compose 示例。
从源码结构看,该扩展选择逻辑集中在 config.py,其中ENV_TEXT_SEARCH_EXTENSION、ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE、ENV_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER等常量定义了完整的配置面。
2.3 图遍历(Graph Traversal)
做什么:沿实体之间的连接,找到间接关联的信息。
最擅长:
- 间接关系:"What does Alice do?" → Alice → Google → Google 的产品;
- 实体探索:"Bob's colleagues" → Bob → 同事 → 共同项目;
- 多跳推理:"Alice's team's achievements"。
为什么重要:能检索到那些在语义或词汇上并不相似、但通过知识图谱结构上相连的事实。例如,即使 Alice 和她的经理从未在同一段文本中同时出现,图遍历也能通过共同项目或团队关系找到她的经理。
从源码实现看,默认的图检索器是Link Expansion(link_expansion_retrieval.py):先用向量检索选出有界的语义种子(GRAPH_SEED_LIMIT = 20),再通过memory_links表中的三类并行信号扩展——实体共现链接、写入时预计算的 kNN 语义链接、显式因果链(causes/caused_by/enables/prevents)。实体扩展受graph_per_entity_limit(每实体默认 200)的 LATERAL 上限约束,防止高扇出实体使自连接行数爆炸;若查询超出预算还有超时回退机制,会直接丢弃实体扩展臂。
2.4 时间搜索(Temporal Search)
做什么:理解时间表达,并按事件发生时间过滤。
最擅长:
- 历史查询:"What did Alice do in 2023?";
- 时间区间:"What happened last spring?";
- 相对时间:"What did Bob work on last year?";
- 先后关系:"What happened before Alice joined Google?"。
工作原理:结合语义理解与时间过滤,找到特定时间段内的事件。
为什么重要:使精确的历史查询成为可能,而无需丢弃旧信息。
三、结果融合:Reciprocal Rank Fusion(RRF)
四个策略并行跑完后,结果被融合在一起:
- 出现在多个策略中的记忆排名更高(共识效应);
- 排名比分数更重要(对不同打分体系天然鲁棒);
- 最终结果由一个考虑"查询-记忆交互"的神经网络模型(交叉编码器)重排序。
融合为什么重要:一条既语义相似又提及了正确实体的事实,其排名会高于仅仅语义相似的事实。
RRF(Reciprocal Rank Fusion)按如下公式合并各路的带排名结果列表:
score(d) = Σ 1 / (k + rank_i(d)) i其中:
- k = 60(平滑常数——防止榜首项一家独大);
- rank_i(d)= 文档d在策略i中的位置(1 起始);
- 求和遍历d出现的所有策略。
四个策略权重完全相等,没有逐策略的权重乘数——重要性来自排名位置,而非来源。
为什么用 RRF 而不是原始分数合并?每种检索策略产生的分数尺度不同(余弦相似度、BM25 tf-idf、图激活值)。这些分数不可比——BM25 分数 12.5 与余弦相似度 0.85 含义完全不同。RRF 只使用排名位置,因此对任何打分体系都鲁棒,无需校准。
示例:一条记忆在语义路排第 1、在 BM25 路排第 5:
RRF score = 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318仅在语义路排第 1 的记忆:
RRF score = 1/(60+1) = 0.0164前者的排名更高,因为它获得了跨策略的共识。
源码实现位于 fusion.py:reciprocal_rank_fusion(result_lists, k=60)按固定顺序["semantic", "bm25", "graph", "temporal"]遍历各路结果,累加1/(k+rank)并记录每个候选在各路的source_ranks,最终产出按 RRF 分数排序的MergedCandidate列表。该模块还同时实现了cap_per_source(融合前对单路结果截断,防止某一路过度扩张挤占重排器全局候选预算)与interleave_fusion(轮转融合,用于整合去重场景,保证每路头部候选都有席位)。测试用例 test_combined_scoring.py 与 test_fusion_cap.py 覆盖了融合与截断行为。
四、交叉编码器重排序(Cross-Encoder Reranking)
RRF 给出了不错的初始排序,但它基于的是位置,而非对"查询-文档"的深度理解。交叉编码器把查询与每个候选作为一对输入,产出相关度分数。
预过滤:重排前,候选被按 RRF 分数截断至前300条以控制计算开销,可通过HINDSIGHT_API_RERANKER_MAX_CANDIDATES配置(源码默认值 300,见 config.py)。
为什么在 RRF 之后还要重排?RRF 是基于位置的——它知道一条记忆在各路都排名靠前,但它从没有真正"读过"查询与记忆的组合。交叉编码器会做这件事:将查询与每个候选组成 pair,基于二者的完整交互打分。这能捕捉位置融合漏掉的细节,例如某条记忆因为匹配了一个常见词而在关键词路排第 1、实际与查询意图无关。
分数归一化:交叉编码器输出的是原始 logit(可能为负)。对于本身已落在 [0, 1] 区间内、由校准过的外部 API 重排器(如 Cohere、SiliconFlow、ZeroEntropy、Alibaba、Jina)返回的分数,原样透传以保留绝对置信度;而 [0, 1] 之外的原始 logit 则用 sigmoid 归一化:
CE_normalized = 1 / (1 + e^(-raw_logit))批处理:候选按批次打分——本地重排器每批32对,TEI 每批128对(默认值见 config.py:DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 32、DEFAULT_RERANKER_TEI_BATCH_SIZE = 128)。
:::tip 没有交叉编码器怎么办? 在没有交叉编码器的部署中(例如不带外部重排器的 slim 镜像),系统回退到基于 RRF 的分数:候选按 RRF 排名被赋予铺在 [0.1, 1.0] 区间的合成分数,使后文的复合打分提升(boosts)依然有意义地工作。 :::
这一回退逻辑在源码中体现得很直接:reranking.py 的apply_combined_scoring检测到透传型重排器(passthrough)时,会把cross_encoder_score_normalized按 RRF 排名重新映射到 [0.1, 1.0],避免乘性 boost 沦为纯时间排序。归一化与透传逻辑则在同文件的CrossEncoderReranker.rerank(reranking.py)中实现,测试 test_reranker_score_normalization.py 专门验证了该行为。
一个值得注意的实现细节:rerank()在送模型前会把记忆的上下文与发生日期一并拼进文档文本([Date: June 5, 2022 (2022-06-05)] ...),使模型在打分时具备时间感知能力。
五、复合打分:三重乘性 Boost
归一化后的交叉编码器分数会被三个乘性提升调整,纳入交叉编码器看不到的信号:新鲜度(recency)、时间接近度(temporal proximity)与证据强度(proof count)。
为什么是乘性而不是加性?加性提升(如CE + 0.1 × recency)会不论相关度高低给每个候选相同的绝对加分,一条相关性很差的记忆可能仅因足够新鲜就反超高度相关的记忆。乘性提升让调整量与基础相关度分数成正比——对高相关记忆 +10% 的绝对变化大于对低相关记忆 +10%。这保证次要信号永远无法压倒主要的相关性判断。
公式:
final_score = CE_normalized × recency_boost × temporal_boost × proof_count_boost每个 boost 以 1.0 为中心(中性),由 alpha 限定其摆动幅度:
boost = 1 + α × (signal - 0.5)| Boost | α | 最大调整 | 奖励什么 |
|---|---|---|---|
| Recency(新鲜度) | 0.2 | ±10% | 新记忆优先于旧记忆 |
| Temporal proximity(时间接近度) | 0.2 | ±10% | 靠近查询时间窗的记忆 |
| Proof count(证据数) | 0.1 | ±5% | 有更多证据支撑的 observation |
5.1 新鲜度信号
从记忆发生日起按 365 天线性衰减:
recency = clamp(1.0 - days_ago / 365, 0.1, 1.0)查询时间戳当天的记忆 recency 为 1.0(+10% boost);早于查询时间戳 6 个月的记忆 recency 约 0.5(中性);早于查询时间戳一年的记忆 recency 为 0.1(-8% 惩罚)。若未提供query_timestamp,则使用服务器当前时间。没有日期的记忆取 0.5(中性——不加分也不扣分)。
5.2 时间接近度信号
仅当查询包含时间引用时激活(如 "last spring"、"in 2023")。度量记忆日期与所查询时间窗口中心的距离:
temporal_proximity = 1.0 - min(days_from_center / (window_days / 2), 1.0)位于窗口中心的记忆得 1.0(+10% boost),位于窗口边缘的得 0.0(-10% 惩罚)。对非时间类查询,所有记忆均为 0.5(中性)。
5.3 证据数信号
对 observation 类型记忆,用对数曲线奖励有更多证据支撑的记忆:
proof_norm = clamp(0.5 + ln(proof_count) / 10, 0.0, 1.0)| 证据数 | proof_norm | Boost |
|---|---|---|
| 1 | 0.5 | 中性 |
| 3 | 0.61 | +1.1% |
| 10 | 0.73 | +2.3% |
| 150+ | 1.0 | +5%(上限) |
5.4 复合影响的最大范围
所有 boost 都取极值时:
- 最好情况:×1.10 × 1.10 × 1.05 ≈+27%
- 最坏情况:×0.90 × 0.90 × 0.95 ≈-23%
这些 boost 刻意保守——只微调排名,而不覆盖交叉编码器的相关度判断。
实现位于 reranking.py 的apply_combined_scoring,其中_RECENCY_ALPHA = 0.2、_TEMPORAL_ALPHA = 0.2、_PROOF_COUNT_ALPHA = 0.1与文档一一对应;新鲜度衰减函数compute_recency_decay还额外提供exponential(90 天半衰期)与none两种可选曲线。
六、Token 预算管理与 Chunks
Hindsight 是面向 AI Agent 而非人类设计的。传统搜索系统返回"top-k"条结果,但 Agent 不按结果条数思考——它按 token 思考。Agent 的上下文窗口以 token 计量,Hindsight 也正是以 token 计量返回结果。
工作原理:
- 先选排名最高的记忆;
- 直到 token 预算耗尽为止;
- 你指定上下文预算,Hindsight 用最相关的记忆把它填满。
你控制的参数:
max_tokens:返回多少记忆内容(默认 4096 tokens);budget:搜索深度档位(low、mid、high);types:按 world、experience、observation 或 all 过滤;tags:按可见性标签过滤记忆;tags_match:标签匹配方式(全部选项见 Recall API)。
扩展上下文:Chunks
记忆是蒸馏后的事实——简洁,但有时丢失细节。当你的 Agent 需要更深的上下文时,可以选择性取回原始材料。Chunks返回生成每条记忆所用的原始文本,在蒸馏事实丢失了关键细节时尤其有用:
Memory: "Alice prefers Python over JavaScript" Chunk: "Alice mentioned she prefers Python over JavaScript, mainly because of its data science ecosystem, though she admits JS is better for frontend work and she's been learning TypeScript lately."使用include_chunks=True配合max_chunk_tokens来控制 chunks 的 token 预算。这在需要逐字引用或上下文至关重要的场景(例如"关于那个项目,Alice 到底说了什么?")时非常有用。
从源码看,chunks 的取回独立于max_tokens过滤——max_tokens=0时会返回 0 条事实,但仍可取回 chunks,见 memory_engine.py 的注释与recall主流程(该流程的第 5 步即为 Token Filter:按max_tokens预算自上而下截取结果)。
七、调优 Recall:质量 vs 延迟
不同用例需要在召回质量与响应速度之间做不同的取舍。两个参数控制这一权衡。
7.1 Budget:搜索深度
控制 Hindsight 探索记忆库的彻底程度——影响图遍历深度、候选池大小和交叉编码器重排序:
| Budget | 适用场景 | 权衡 |
|---|---|---|
| low | 快速查询、简单问题 | 快,可能漏掉间接连接 |
| mid | 大多数查询、均衡 | 覆盖面好、速度合理 |
| high | 需要深度探索的复杂查询 | 彻底,但更慢 |
示例:"What did Alice's manager's team work on?" 受益于 high budget——需要遍历多跳(Alice → manager → team → projects)并评估更多候选。
7.2 Max Tokens:上下文窗口大小
控制返回多少记忆内容:
| Max Tokens | 约等于页数 | 适用场景 | 权衡 |
|---|---|---|---|
| 2048 | ~2 页 | 聚焦回答、快速 LLM | 记忆更少,更快 |
| 4096(默认) | ~4 页 | 均衡上下文 | 覆盖好,标准 |
| 8192 | ~8 页 | 全面上下文 | 记忆更多,LLM 更慢 |
示例:"Summarize everything about Alice" 受益于更高的 max_tokens 以纳入更多事实。
7.3 两个独立维度
Budget 和 max_tokens 控制的是 recall 的不同侧面:
| 参数 | 控制什么 | 延迟影响 | 示例 |
|---|---|---|---|
| Budget | 多彻底地探索记忆 | 搜索时间 | High budget 能找到 Alice → manager → team → projects |
| Max Tokens | 返回多少上下文 | LLM 处理时间 | High tokens 向 Agent 返回更多记忆 |
二者相互独立。常见组合:
| Budget | Max Tokens | 用例 |
|---|---|---|
| high | low | 深度搜索,只返回最好的结果 |
| low | high | 快速搜索,返回找到的全部 |
| high | high | 综合性研究查询 |
| low | low | 快速聊天机器人响应 |
7.4 推荐配置
| 用例 | Budget | Max Tokens | 原因 |
|---|---|---|---|
| 聊天机器人回复 | low | 2048 | 快速响应、聚焦上下文 |
| 文档问答 | mid | 4096 | 覆盖面与速度均衡 |
| 研究查询 | high | 8192 | 综合性、多跳推理 |
| 实时搜索 | low | 2048 | 最小化延迟 |
八、Budget 如何映射到管线参数
budget参数(low/mid/high)控制的是搜索深度——每个策略考虑多少个候选。每一档映射为一个贯穿所有管线阶段的recall budget数值:
| Budget | Recall budget(fixed 模式) | 环境变量覆盖 |
|---|---|---|
| low | 100 | HINDSIGHT_API_RECALL_BUDGET_FIXED_LOW |
| mid | 300(默认) | HINDSIGHT_API_RECALL_BUDGET_FIXED_MID |
| high | 1000 | HINDSIGHT_API_RECALL_BUDGET_FIXED_HIGH |
该 recall budget 在管线中的使用方式:
| 管线阶段 | recall budget 的用法 |
|---|---|
| 语义搜索 | 从 HNSW 过度获取 max(recall_budget × 5, 100),再裁剪到 recall_budget |
| BM25 搜索 | SQL 中LIMIT recall_budget |
| 图遍历 | 最多探索 recall_budget 个节点 |
| 时间扩散 | 经由链接激活最多 recall_budget 个节点 |
| 结果考量 | 前 recall_budget × 2 条结果进入 token 过滤 |
重排器预过滤(300 候选)与 budget独立——它是单独的旋钮(HINDSIGHT_API_RERANKER_MAX_CANDIDATES)。
:::info 自适应预算(Adaptive budgeting) 另一种预算模式让 recall budget 随max_tokens伸缩,而不是使用固定值:
recall_budget = clamp(max_tokens × ratio, min, max)| Budget | Ratio | 环境变量覆盖 |
|---|---|---|
| low | max_tokens 的 2.5% | HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_LOW |
| mid | max_tokens 的 7.5% | HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_MID |
| high | max_tokens 的 25% | HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_HIGH |
结果被钳制在地板值20(HINDSIGHT_API_RECALL_BUDGET_MIN)与天花板2000(HINDSIGHT_API_RECALL_BUDGET_MAX)之间。
通过HINDSIGHT_API_RECALL_BUDGET_FUNCTION=adaptive启用。 :::
源码侧,这些默认值集中在 config.py(DEFAULT_RECALL_BUDGET_FIXED_LOW = 100、DEFAULT_RECALL_BUDGET_FIXED_MID = 300、DEFAULT_RECALL_BUDGET_FIXED_HIGH = 1000、自适应比例 0.025/0.075/0.25、上下限 20/2000),预算解析逻辑在 memory_engine.py 的_resolve_thinking_budget中实现。
九、图评分细节(Graph Scoring Detail)
图遍历(link expansion)对每个候选将三个独立信号相加:
| 信号 | 评分公式 | 取值范围 |
|---|---|---|
| Entity overlap(实体重叠) | tanh(shared_entity_count × 0.5) | [0, ~1.0] |
| Semantic link(语义链接) | 预计算的 kNN 链接权重 | [0.7, 1.0] |
| Causal link(因果链接) | 因果链接权重 | [0, 1.0] |
graph_score = entity_score + semantic_score + causal_score ∈ [0, 3]加性组合奖励收敛性证据——通过多种信号类型与查询相连的记忆,排名高于仅通过单一强信号相连的记忆。
为什么实体分数用 tanh?原始共享实体数量无上界——像 "user" 这样的高扇出实体可能产生 50+ 的计数,淹没另外两个信号。tanh(count × 0.5)自然饱和:前几个共享实体贡献很大(1→0.46,2→0.76,3→0.91),再多则边际收益递减,从而把实体信号压进 [0, 1],与语义和因果信号同尺度。
为什么这里用加性而不是乘性?与复合打分中的 boosts 不同,图信号是独立的证据通道,而非对基础分数的调整。某条记忆可能只通过因果链接相连(无共享实体、无语义相似)——乘性组合会把它直接归零。加性评分让每个信号独立贡献,跨策略的排名交给外层 RRF 融合处理。
实体信号示例:与查询共享 1 个实体的记忆得分 tanh(0.5) ≈ 0.46;共享 2 个实体得分 tanh(1.0) ≈ 0.76;3 个及以上饱和在 0.91 附近。
该评分逻辑可在 link_expansion_retrieval.py 中逐行验证:math.tanh(row["score"] * 0.5)计算实体信号,三臂分数在 Python 合并步骤中相加得到 [0, 3] 区间的图分数。
十、一个完整例子:多策略如何协作
考虑查询:"What did Alice say about Python last spring?"
- 语义找到关于 Alice 编程观点的事实;
- 关键词确保 "Python" 确实被提及;
- 图谱连接 Alice → 编程语言 → 相关实体;
- 时间过滤到 "last spring" 时间段。
四者的融合恰好给出你要找的东西——尽管任何单一策略都不足以做到。
十一、小结与延伸阅读
Hindsight 的召回管线可以概括为一条清晰的链路:四路并行检索(语义 / 关键词 / 图 / 时间)→ RRF 融合(k=60、等权)→ 交叉编码器重排序(预过滤 300 候选、sigmoid 归一化)→ 三重乘性 boost(新鲜度 ±10%、时间接近度 ±10%、证据数 ±5%)→ 按 final_score 自顶向下填充 max_tokens 预算。所有关键旋钮(recall budget、重排候选上限、预算函数模式、BM25 后端)都暴露为环境变量,便于按部署形态调优。
延伸阅读(本文基于 retrieval.md 展开):
- Retain —— 记忆如何带着丰富上下文被存储;
- Reflect —— disposition 如何影响推理;
- Recall API —— 代码示例、参数与标签过滤的完整参考。
【免费下载链接】hindsightHindsight: Agent Memory That Learns项目地址: https://gitcode.com/GitHub_Trending/hindsight2/hindsight
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考