跳到主要内容

回忆与检索:dense 余弦 + BM25 混合重排

30 秒导读: 前面几章负责"把对话炼成记忆并存下来"。这一章讲下一次调用时怎么找回相关记忆、并塞回 prompt——从一句用户问话出发,先用向量相似(稠密召回)捞出一大批候选,再用关键词打分(BM25)和向量分加权融合重排,取最相关的几条,拼成一段 <memori_context> 注入到模型的 system 里。全程只读,不写库、不生成嵌入。

本章覆盖 Memori 的读路径。写路径(拦截捕获、记忆生成、嵌入落库)见 01-capture / 02-augmentation / 04-rust-core;存储驱动里那些具体 SQL/方言查询见 05-storage。这里只讲"给定 query 和 entity,怎么把最相关的记忆排出来、并注入 prompt"。


1. 这是什么(零基础也能懂)

一句话定义: 召回(recall)就是——用户又来提问了,系统在真正调用大模型之前,先去记忆库里翻出"跟这次提问相关的旧记忆",偷偷塞进提示词,让模型"记得"你以前说过的事。

解决什么问题 / 给谁用: 大模型本身是无状态的,这轮对话结束它就"失忆"。你上周说"我对花生过敏",这周问它"帮我推荐个菜谱",它默认根本不知道。召回就是那个"翻笔记"的动作:在模型开口前,把"花生过敏"这条旧记忆找出来贴到 system 提示里,于是模型的回答就自动避开花生了。

核心难点是"相关"两个字。 记忆库里可能有几百上千条事实,不能全塞给模型(太贵、也会稀释注意力)。所以要排序:哪些记忆跟"这次这句话"最相关?Memori 的答案是两种相关度一起看:

相关度白话擅长弱点
稠密 / 语义(dense)"意思像不像"——把句子变成向量算夹角抓同义、换个说法也能命中("购车" ↔ "买了辆车")精确关键词不敏感,罕见专有名词容易被平均掉
词法 / 关键词(lexical, BM25)"字面词撞不撞"——数词频、罕见词加权抓精确 token、专有名词、短查询换个说法就抓瞎,不懂语义

一句话直觉: 稠密召回像"凭印象找个大概",BM25 像"用 Ctrl+F 找准词"。Memori 把两者加权相加,并且发现"查询越短,越该信关键词"——所以短查询会自动调高 BM25 的权重。

用起来什么样: 使用者其实什么都不用做。你只管照常调用被 Memori 包裹过的 LLM(见 01-capture),召回是自动发生的。它注入进去的东西长这样(真实格式,来自 recall_injection.py:194-199):

<memori_context>
Only use the relevant context if it is relevant to the user's query. Relevant context about the user:
- User is allergic to peanuts. Stated at 2026-05-01
- User prefers spicy food. Stated at 2026-06-12
</memori_context>

这段被拼到 system 提示末尾,模型下一句回答就"记得"你了。


2. 顶层全景(它大概怎么转)

Memori 的召回有两套等价实现并存:一套 Rust 核心(默认高性能路径,core/src/),一套纯 Python 兜底(memori/search/,当 Rust 扩展不可用或走 cloud 时用)。两套算法逻辑刻意保持一致——同样的 BM25、同样的融合公式、同样的候选池规则。本章两条都讲。

怎么读这张图

从上到下是一次召回的时间顺序;左边是 Python 编排层(拿 query、注入 prompt),右边是真正干检索活的核心(Rust 或 Python)。命中即停的短路点用 标出。

用户提问 kwargs


[extract_user_query] 从 messages/input/contents 里挖出最后一句 user 文本
│ 空? ⊘→ 原样返回,不注入

[inject_recalled_facts] 编排:解析 entity_id → 选 cloud / rust / python 路径

├─(BYODB + Rust)→ rust_core.retrieve_facts ──┐
├─(BYODB 无 Rust)→ Recall.search_facts ───────┤
└─(cloud)────────→ POST cloud/recall ─────────┤

┌──────────── 检索管道(读路径核心)────────────┐
│ 1. fetch_embeddings(entity, dense_limit) │ 拉这个 entity 的一批嵌入行
│ 2. 稠密余弦 find_similar_embeddings │ 算 cos,取候选池 top-K
│ 候选池大小 = dynamic_candidate_limit │ = min(行数, max(limit*10, 50))
│ 3. fetch_facts_by_ids(命中的 id) │ 按 id 取回事实正文
│ 4. search_facts:余弦分 ⊕ BM25 融合重排 │ 取最终 top-N(limit)
└───────────────────────────────────────────────┘


[_score_for_recall_threshold] 过滤:rank_score < 阈值的丢掉
│ 全被滤掉? ⊘→ 不注入

[format_recalled_fact_lines] 拼成 "- 事实. Stated at 时间" 列表

按 provider 注入:anthropic/bedrock→system,google→system_instruction,其余→messages[0]

带记忆的 kwargs → 真正调用 LLM

部件一句话职责

部件干什么在哪
extract_user_query从五花八门的调用参数里挖出"用户这次问了啥"memori/llm/helpers/query_extraction.py:44
inject_recalled_facts召回总编排 + 把结果注入 promptmemori/llm/pipelines/recall_injection.py:107
EngineOrchestrator::retrieve / recallRust 顶层入口:校验 + embed query + 跑管道core/src/lib.rs:161 / :196
run_retrievalRust 检索管道主体(嵌入→稠密→取正文→融合)core/src/retrieval/pipeline.rs:10
find_similar_embeddings稠密余弦召回,取候选池core/src/search/similarity.rs:35
search_facts余弦分 + BM25 融合重排,出最终 top-Ncore/src/search/api.rs:11
lexical_scores / dense_lexical_weightsBM25 打分 + 按查询长度选融合权重core/src/search/lexical.rs:55 / :143
Recall.search_factsPython 侧召回入口(cloud + BYODB)memori/memory/recall.py:355
search_entity_facts_corePython 侧检索管道(与 Rust 对应)memori/search/_core.py:185

主线走一遍(高层): 一句 query 进来 → 挖出文本 → 把 query 变成向量 → 用向量在这个 entity 的嵌入池里找语义最近的一批(候选池,故意开大)→ 按命中的 id 取回事实正文 → 对候选池做余弦+BM25 融合重排,砍到最终几条 → 阈值过滤 → 拼字符串 → 按不同 LLM 厂商的参数格式注入 system。


3. 核心原理(逐个机制,由浅入深)

3.1 两段式检索:先粗召回,再精重排

它要解决的小问题: 融合排序(尤其 BM25)要对每个候选算词频、比长度,不便宜。如果对整个 entity 的上千条记忆都做,太慢。

思路: 分两段。第一段用便宜的稠密余弦粗筛出一个"大候选池"(比最终要的多得多);第二段只在这个池子里做精细融合重排。这样贵的计算只作用在几十条上,不是全库。

候选池开多大? 这是个关键旋钮,由 dynamic_candidate_limit 决定(core/src/retrieval/pipeline.rs:162):

fn dynamic_candidate_limit(limit: usize, rows_loaded: usize) -> usize {
let scaled = limit.saturating_mul(10); // 想要的 10 倍
let floor = scaled.max(50); // 但至少 50
let bounded_by_rows = rows_loaded.min(floor); // 别超过实际拉到的行数
limit.max(bounded_by_rows) // 也别小于最终要的 limit
}

用白话说:候选池 = min(实际嵌入行数, max(最终要的条数 × 10, 50)),再兜底不小于 limit。想要 5 条最终结果?那就先粗召回 50 条候选,再从这 50 条里精排出 5 条。给融合重排留足"翻盘"的空间——某条记忆语义分中游、但关键词精确命中,就有机会靠 BM25 挤进前列。

Python 侧是完全等价的 _candidate_limit(memori/search/_core.py:85),但多一个分支:没有 query_text 时直接用 limit(纯语义、不重排,就不需要大池子)。

管道全貌(run_retrieval,core/src/retrieval/pipeline.rs:10)按顺序做这几步:

调用干什么
1bridge.fetch_embeddings(entity, dense_limit) (:32)拉这个 entity 的嵌入行(读存储,详见 05)
2find_similar_embeddings(..., candidate_limit) (:51)稠密余弦,取候选池
3bridge.fetch_facts_by_ids(ids) (:57)按命中 id 回表取事实正文
4search_facts(candidates, limit, query_text) (:80)融合重排,出最终 top-N

注意第 1、3 步的存储读取由 StorageBridge 完成,本章不展开——那是 05-storage 的地盘。

3.2 稠密召回:余弦相似度

它要解决的小问题: "这两句话意思像不像?"要用一个数字表示。

思路: 把每句话嵌入成一个向量(方向代表语义),两个向量的夹角余弦就是相似度:方向一致 = 1,垂直无关 = 0,相反 = -1。跟向量长度无关,只看方向。

原理演示(示意,非源码):

# 示意,非源码:余弦 = 点积 / (各自模长的乘积)
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b)) # 点积:方向越一致越大
na = sum(x * x for x in a) ** 0.5 # a 的模长
nb = sum(y * y for y in b) ** 0.5 # b 的模长
return dot / (na * nb) # 归一化到 [-1, 1]

真实实现: cosine_similarity(core/src/search/similarity.rs:9)就是这个公式,外加两条防御:长度不等返回 0(维度不匹配直接判无关),任一为零向量返回 0(避免除零)。上层 find_similar_embeddings(:35)对候选池每条算一次余弦,然后用部分排序取 top-K(见 3.4)。

Python 侧走的不是手写循环,而是 FAISS。 memori/search/_faiss.py:93find_similar_embeddings 把嵌入堆成矩阵、faiss.normalize_L2 归一化后,用 IndexFlatIP(内积索引,_faiss.py:78)做搜索——L2 归一化后的内积就等于余弦,所以数学上和 Rust 版一致,只是用了向量化库加速。维度不匹配时同样直接返回空(_faiss.py:68)。

3.3 词法召回:BM25

它要解决的小问题: 语义相似会"抹平"精确关键词。你问 "Rust ownership",语义上一堆编程记忆都沾边,但真正提到 "Rust" 这个罕见精确词的那条,该被顶上来。

思路: BM25 是信息检索的经典打分法,核心两条直觉——

  • 词频(TF)有饱和: 一个词在文档里出现越多分越高,但收益递减(出现 10 次不等于 5 次的两倍),由参数 k1 控制。
  • 稀有词(IDF)更值钱: 一个词在越少文档里出现,命中它越说明"专门相关",权重越高。
  • 长文档打折(b): 长文档天然更容易撞词,按长度归一化,避免长记忆霸榜。

先分词。 tokenize(core/src/search/lexical.rs:40)做三件事:转小写、按非字母数字切分、去停用词。停用词表 STOPWORDS(:9)是一张排好序的常量表,故意排序是为了用 binary_search 做 O(log N) 过滤(:45,注释在 :8 明说了"必须保持有序")。

再打分。 lexical_scores(core/src/search/lexical.rs:55)对候选池算标准 BM25,参数是教科书默认值 k1 = 1.2b = 0.75(:92-93),IDF 用带平滑的公式(:95-98)。最后一步很关键——把所有分归一化到 [0, 1](除以最高分,:133-136),这样才能和同样在 [0,1] 区间(近似)的余弦分放到一个尺度上相加。如果没有任何候选命中查询词,直接返回全零(:129),融合时等于"只看语义"。

Python 版 lexical_scores_for_ids(memori/search/_lexical.py:74)是逐行对应的实现:同一张停用词集合 _STOPWORDS(:24)、同样 k1=1.2 / b=0.75(:100-101)、同样归一化到最高分。差别只在分词——Python 用正则 [a-z0-9]+(:23),Rust 用字符切分,结果等价。

3.4 混合融合:两个分数怎么合成一个

它要解决的小问题: 现在每条候选有两个分(余弦 cos、BM25 lex),怎么合成最终排序分?

思路: 线性加权相加。rank_score = w_cos × cos + w_lex × lex。真实实现就一行(core/src/search/api.rs:35):

rank_score: (w_cos * cos_score) + (w_lex * lex_score),

权重不是固定的——它看查询长度。 这是 Memori 一个巧妙点(dense_lexical_weights,core/src/search/lexical.rs:143):

pub fn dense_lexical_weights(q_token_count: usize) -> (f32, f32) {
let (base_lex, short_lex) = get_lex_weights();
let w_lex = if q_token_count <= 2 { short_lex } else { base_lex };
(1.0 - w_lex, w_lex) // w_cos + w_lex 恒等于 1
}
  • 查询 ≤ 2 个 token(去停用词后)→ 用更高的词法权重(默认 0.30)。短查询往往是"关键词查找"(比如就打了 "peanut allergy"),这时精确撞词比语义更靠谱。
  • 长查询 → 用基础词法权重(默认 0.15),更信语义。

权重可以用环境变量调,但会被夹住范围。 get_lex_weights(core/src/search/lexical.rs:21)读两个环境变量并 clamp(0.05, 0.40):

环境变量默认作用夹取范围
MEMORI_RECALL_LEX_WEIGHT0.15普通(长)查询的词法权重[0.05, 0.40]
MEMORI_RECALL_LEX_WEIGHT_SHORT0.30短查询(≤2 词)的词法权重[0.05, 0.40]

夹取保证了融合永远是"两者都参与"——词法权重不会低到忽略关键词(≥0.05),也不会高到盖过语义(≤0.40)。注意 Rust 版用 OnceLock 缓存(:16),环境变量只在首次读取时生效,运行期改了不重读;Python 版 dense_lexical_weights(memori/search/_lexical.py:127)则每次调用都读一遍 os.environ,同样 clamp 到 [0.05, 0.40](:147)。

没有 query_text 时退化为纯语义。 search_factselse 分支(core/src/search/api.rs:41)直接把余弦分当作 rank_score,不做 BM25。

3.5 部分排序:只排出前 N,不全排

它要解决的小问题: 候选池可能有 50 条,但最终只要 5 条。对 50 条做完整排序是浪费——O(N log N) 排一堆你根本不看的尾部。

思路:select_nth_unstable_by(快速选择,平均 O(N))先把"第 limit 名"就位、把前 limit 个甩到左边,truncate 砍掉尾巴,对剩下这一小撮做完整排序。search_facts(core/src/search/api.rs:55-60)和 find_similar_embeddings(similarity.rs:52-57)都是这个套路:

if results.len() > limit {
results.select_nth_unstable_by(limit, |a, b| b.rank_score.total_cmp(&a.rank_score));
results.truncate(limit); // 先 O(N) 砍到 limit 个
}
results.sort_unstable_by(|a, b| b.rank_score.total_cmp(&a.rank_score)); // 再排这一小撮

total_cmp 而非 < 是为了正确处理浮点(包括 NaN 不会引发 panic)。


4. Python 编排层:从召回到注入 prompt

前面讲的是"排序核心"。这一节讲外层编排——怎么触发召回、怎么把排好的事实真正塞进不同厂商的 API 参数里。入口是 inject_recalled_facts(memori/llm/pipelines/recall_injection.py:107)。

4.1 三条召回路径

inject_recalled_facts 先挖 query(extract_user_query),然后按配置分流:

配置路径落到哪
cloud=TrueRecall(...).search_facts(query)POST cloud/recall云端 API,本地不算分
BYODB + 有 Rust 扩展rust_core.retrieve_facts(query, entity_id, limit, dense_limit)上面第 3 节的 Rust 管道
BYODB + 无 RustRecall(...).search_facts(query, entity_id)Python 管道 search_entity_facts_core

Python 的 Recall.search_facts(memori/memory/recall.py:355)负责解析 entity_id、embed query(_embed_query,:213——嵌入生成本身归 04),再调 _search_with_retries(:221)。后者对 search_facts 包了一层重试:遇到数据库 OperationalError 且报文含 "restart transaction",指数退避重试最多 MAX_RETRIES=3 次(:240-247)——应对并发写导致的事务冲突。

Python 检索管道 search_entity_facts_core(memori/search/_core.py:185)和 Rust 的 run_retrieval 一一对应:取嵌入行 → find_similar_embeddings(FAISS)→ _candidate_limit 定池子 → _fetch_content_maps 回表取正文 → _rank_candidates(:116)融合重排。融合公式(_core.py:132-134)和 Rust 完全一致。它还多了个 cloud 分支:candidates 直接以 FactCandidate 传入(已带余弦分),跳过取嵌入这步,重排后再把内部索引 remap 回原始 id(:257-276)。

4.2 相关性阈值过滤

排序出来的事实不是全都注入——低于阈值的被丢掉。判分函数 _score_for_recall_threshold(memori/memory/recall.py:50)的取值优先级:

  1. rank_score → 用它(融合分);
  2. 否则用 similarity(纯余弦);
  3. 纯字符串事实 → 记 1.0(总是保留);
  4. 取不到 → 0.0

过滤发生在 recall_injection.py:170-174:rank_score >= config.recall_relevance_threshold 的才留下。全被滤光就不注入、原样返回(:176-181)——宁可不给记忆,也不给不相关的记忆污染 prompt。

4.3 拼字符串:格式化命中事实

留下的事实由 format_recalled_fact_lines(memori/llm/pipelines/recall_injection.py:26)拼成列表。每条一行 - {content},若有时间戳再补 . Stated at {ts}(:47-49)——给模型时间线索。若事实带 summary,还会另起 ## Summaries 段(format_recalled_summary_lines,:53)。最后包进 <memori_context> 标签,并前置一句"只在相关时才用"的自我约束(:194-199)。

4.4 按 provider 注入的差异(关键细节)

这是最容易被忽略的坑:不同 LLM 厂商的 API 里"system 提示"长得不一样,注入方式必须逐家适配。 inject_recalled_facts:201-224 分了四种:

Provider注入到哪代码
Anthropic / Bedrock追加到顶层 kwargs["system"] 字符串recall_injection.py:201-205
Google (Gemini)inject_google_system_instructionsystem_instruction:206-209
Responses 风格(有 input/instructions、无 messages)追加到 kwargs["instructions"]:210-212
其余(OpenAI 式 messages)拼到 messages[0](若首条是 system),否则插入一条 system 到最前:213-224

Google 那条尤其绕,因为 Gemini 的 system_instruction 可能是字符串、list、dict,或 protobuf 对象,甚至藏在 request._pb 里。inject_google_system_instruction(memori/llm/helpers/google_system_instruction.py:93)对每种形态都写了分支去"追加到第一段文本",而不是粗暴覆盖——保住调用方原有的系统指令。


5. 巧妙之处(可借鉴的技术)

  • 候选池"放大 10 倍再精排"(dynamic_candidate_limit,core/src/retrieval/pipeline.rs:162):粗召回故意多捞,给词法重排留翻盘空间——语义中游但关键词精确命中的记忆,能靠 BM25 挤进最终结果。这是 hybrid search 的精髓。
  • 查询长度自适应权重(dense_lexical_weights,core/src/search/lexical.rs:143):短查询多半是关键词查找,自动调高 BM25 权重;长查询更信语义。用一个 len ≤ 2 的廉价判断吃到大部分收益。
  • BM25 归一化到最高分(lexical.rs:133):让词法分和余弦分落在同一尺度,线性相加才有意义;无命中时退化全零 = "只看语义",优雅降级。
  • 部分排序而非全排(select_nth_unstable_by,api.rs:56):只对最终要的 N 条做完整排序,候选池尾部不浪费算力。
  • Rust / Python 双实现严格对齐:同一套 BM25、同一组参数、同一个融合公式、同一份停用词表——保证不管走不走 Rust 扩展,召回结果一致,便于测试与回退。
  • 注入前的自我约束句("Only use the relevant context if it is relevant…",recall_injection.py:195):即使召回有噪声,也用一句提示让模型自行判断,降低误注入的伤害。

6. 边界与局限

  • 只讲读路径。 嵌入怎么生成(query 和记忆各自 embed)属于 04-rust-core;记忆怎么被抽取入库属于 02-augmentation;fetch_embeddings / fetch_facts_by_ids 背后的具体存储查询属于 05-storage。
  • 稠密召回是一次全量线性扫描,不是近似最近邻索引(ANN)。Rust 版 find_similar_embeddings 对拉到的每条嵌入都算一次余弦(similarity.rs:44)。规模靠 dense_limit(即 fetch_embeddings 拉多少行)兜底,不是靠 HNSW 之类的索引——entity 记忆极多时,拉取上限决定召回天花板。
  • 环境变量权重在 Rust 侧只读一次(OnceLock,lexical.rs:16):进程启动后改 MEMORI_RECALL_LEX_WEIGHT* 不生效;Python 侧则每次读,两侧行为在这点上不一致
  • 短路点很多,失败即"不注入"而非报错:空 query、空 entity、limit=0、query 嵌入全零、无命中、全被阈值滤掉——任一发生,inject_recalled_facts 都原样返回 kwargs(见 recall_injection.pyreturn kwargs)。好处是永不阻断主调用,代价是"为什么没记忆"要靠 debug 日志排查。
  • 停用词表是英文硬编码(lexical.rs:9),对中文等非空格分词语言,BM25 分词退化为按非字母数字切分,词法召回效果有限。
  • BM25 参数写死 k1=1.2 / b=0.75,不可配;只有融合权重可调。

7. 横向对比

同货架其它记忆系统在"怎么找回记忆"上取舍不同:纯向量库方案(如某些 RAG 栈)只做稠密召回,靠 ANN 索引换规模但丢关键词精度;Memori 的取舍是牺牲 ANN、换来 dense+lexical 混合,在中小记忆规模下用精排质量补召回。若要跨库看"检索与重排"这条原理的不同实现,回到货架总库 doc 的"记忆检索"一节;本项目的写路径与生成侧见 02-augmentation,核心引擎与绑定见 04-rust-core


8. 代码地图(导航索引)

主题文件路径符号
Rust 顶层入口(校验 + embed + 跑管道)core/src/lib.rsEngineOrchestrator::retrieve / EngineOrchestrator::recall
Rust 检索管道主体core/src/retrieval/pipeline.rsrun_retrieval
候选池大小规则core/src/retrieval/pipeline.rsdynamic_candidate_limit
结果格式化(recall 字符串)core/src/retrieval/pipeline.rsformat_recall_output
稠密余弦 + top-Kcore/src/search/similarity.rscosine_similarity / find_similar_embeddings
融合重排入口core/src/search/api.rssearch_facts
分词 / BM25 / 权重core/src/search/lexical.rstokenize / lexical_scores / dense_lexical_weights / get_lex_weights
检索请求模型core/src/retrieval/models.rsRetrievalRequest
Python 召回入口(cloud + BYODB + 重试)memori/memory/recall.pyRecall.search_facts / _search_with_retries / _score_for_recall_threshold
Python 检索管道memori/search/_core.pysearch_entity_facts_core / _candidate_limit / _rank_candidates
Python 统一搜索入口memori/search/_api.pysearch_facts
Python 稠密召回(FAISS)memori/search/_faiss.pyfind_similar_embeddings / _faiss_search
Python BM25 / 权重memori/search/_lexical.pylexical_scores_for_ids / dense_lexical_weights / _tokenize
注入编排 + provider 分流memori/llm/pipelines/recall_injection.pyinject_recalled_facts / format_recalled_fact_lines
Google system_instruction 注入memori/llm/helpers/google_system_instruction.pyinject_google_system_instruction
从调用参数挖 querymemori/llm/helpers/query_extraction.pyextract_user_query