数据截至 (上游 commit 85532420387c)
图谱式明文记忆(下):检索召回、重排与推理
30 秒导读: 上一章讲了记忆怎么"写进"图谱(04)。这一章讲反向的一半: 一条用户 query 进来,系统怎么把散落在图里的相关记忆找回来、排好序、去掉重复,最后交给对话模型。 主角是一个叫
Searcher的类,它把检索拆成"解析 → 多路并行召回 → 多源融合 → 重排 → 排序裁剪"五步流水线。
本章只讲读取一侧。写入(抽取、组织、去重、冲突消解)见 03 和 04;
记忆的形态与容器见 01;上层内核 MOSCore 见 02。
1. 这是什么(零基础也能懂)
一句话定义: 检索管线 = 给定一句话,从记忆图谱里"捞出"最相关的一批记忆条目,排好序返回。
它解决什么问题。 记忆库越写越大,几千上万条记忆散落在图谱的节点里。对话时不可能把全部记忆塞进 上下文窗口——太贵、也会淹没重点。所以每轮对话前要做一次精准的召回:只取"和这次问题相关"的十几条。
一个直觉类比。 把它想成图书馆找书:
- 你只说一句"我上次说的那个项目截止日期"(query,模糊)。
- 图书馆员先听懂你要什么(任务解析:关键词=项目、截止日期)。
- 然后兵分几路同时找:按卡片目录找(图谱结构)、按"意思相近"找(向量)、按字面词找(BM25/全文)。
- 各路把候选堆到一起,去掉重复,按相关度排好序,只把最上面几本递给你(重排 + 裁剪)。
用起来什么样。 上层调用极简——MOSCore.search("我周五的会议改到几点了?"),内部就跑完整条管线:
# 示意,非源码:一次检索的对外观感
results = mos.search(
query="我周五的会议改到几点了?",
top_k=10,
mode="fine", # fine=慢而准(用大模型解析),fast=快而糙
internet_search=False, # 是否允许联网补充
)
# results 是一批 TextualMemoryItem,已按相关度排好序
为什么要"多路"而不是只用向量? 因为单一召回各有盲区:向量擅长"意思相近"但对精确的专有名词 (人名、编号)不敏感;字面匹配(BM25/全文)擅长专名但抓不住语义;图谱结构召回擅长"同一主题/标签 的邻居"但依赖抽取时打好的标签。并行跑、再融合,才能既召得全又召得准。
2. 顶层全景(它大概怎么转)
怎么读下面这张图: 从上到下是一次 search() 的时间顺序;中间那层"六路召回"是并行的(同时发起),
其余步骤串行。命中即汇入同一个候选池。
用户 query
│
▼
┌─────────────────────────────────────────────┐
│ ① 任务解析 _parse_task / TaskGoalParser │ query → 关键词 keys / 标签 tags /
│ (fast=分词; fine=大模型拆解) │ 改写 query / 是否联网 / 向量 embedding
└─────────────────────────────────────────────┘
│ parsed_goal + query_embedding
▼
┌─────────────────────────────────────────────┐
│ ② 多路并行召回 _retrieve_paths (线程池) │
│ ├ A 工作记忆 _retrieve_from_working_memory │
│ ├ B 长期+用户 _retrieve_from_long_term... │ 每一路内部又并行跑:
│ ├ C 联网 _retrieve_from_internet │ 图谱召回 / 向量召回 /
│ ├ (可选)关键词 _retrieve_from_keyword │ BM25召回 / 全文召回
│ ├ (可选)工具 _retrieve_from_tool_memory │ (GraphMemoryRetriever.retrieve)
│ ├ (可选)技能 _retrieve_from_skill_memory │
│ └ (可选)偏好 _retrieve_from_preference... │
└─────────────────────────────────────────────┘
│ 每一路各自 _maybe_rerank 后汇总成一个大 list
▼
┌─────────────────────────────────────────────┐
│ ③ 后处理 post_retrieve │
│ ├ 去重 _deduplicate_results (按记忆文本) │
│ └ 排序裁剪 _sort_and_trim (按分数, 分类型 top_k)│
└─────────────────────────────────────────────┘
│
▼
最终 list[TextualMemoryItem](已排好序)
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Searcher | 检索总指挥,编排整条管线 | retrieve/searcher.py:46 |
TaskGoalParser | 把 query 解析成关键词/标签/改写句/是否联网 | retrieve/task_goal_parser.py:18 |
GraphMemoryRetriever | 单一 scope 内的四路底层召回 | retrieve/recall.py:61 |
BaseReranker(BGE/cosine) | 对候选打相关度分、排序 | reranker/base.py:12 |
MemoryReasoner | 用大模型二次筛选/合成(可选深链路) | retrieve/reasoner.py:11 |
AdvancedSearcher | Searcher 子类,多阶段"深检索" | retrieve/advanced_searcher.py:25 |
| 联网检索器 | 把网页搜索结果转成记忆条目 | retrieve/xinyusearch.py 等 |
一个容易混淆的点:实际用的搜索器是子类。 tree.py 里是 from ... import AdvancedSearcher as Searcher
(tree.py:25),所以生产实例是 AdvancedSearcher;但普通 .search() 走的仍是父类 Searcher.search
的流水线,AdvancedSearcher 只是额外提供了 deep_search(见 §6.2)。
主线走一遍( 高层,不进代码): MOSCore.search 遍历用户可访问的每个 Cube,对每个 Cube 的
text_mem.search() 发起检索(mem_os/core.py:618)→ TreeTextMemory.search new 一个搜索器并调其
.search(tree.py:214)→ 进入本章的五步流水线 → 各 Cube 结果汇总回 MOSCore。
3. 核心原理(逐个机制,由浅入深)
3.1 任务解析:把模糊的 query 变成可检索的目标
要解决的小问题。 用户说的话是自然语言,而底层召回需要结构化的抓手:该用哪些关键词做字面匹配? 哪些标签做图谱过滤?要不要联网?原句要不要改写得更适合检索?
思路。 用 TaskGoalParser 产出一个 ParsedTaskGoal。它有两档:
fast模式——不调大模型,几乎"原样透传"。快,适合高并发。fine模式——调一次大模型,把 query 拆成结构化的keys/tags/memories,还判断internet_search并给出rephrased_query(改写句)。慢但准。
真实实现。 入口 parse 按 mode 分流(task_goal_parser.py:30):
# task_goal_parser.py:46 —— parse() 的分流
if mode == "fast":
return self._parse_fast(task_description, context=context, **kwargs)
elif mode == "fine":
return self._parse_fine(task_description, context, conversation, **kwargs)
fast 模式默认连分词都不做,keys/tags 直接留空、rephrased_query 就是原句(_parse_fast,
task_goal_parser.py:55);只有开了 use_fast_graph 才用 tokenizer.tokenize_mixed 分词填充。
fine 模式把 query + 上下文 + 历史对话套进 TASK_PARSE_PROMPT 交给大模型,再把 JSON 解析成
ParsedTaskGoal(_parse_fine / _parse_response,task_goal_parser.py:83 / :107)。
关键细节:解析结果如何回流到检索。 Searcher._parse_task(searcher.py:283)拿到 parsed_goal 后:
用 rephrased_query 覆盖原 query;若 parsed_goal.memories 非空,就把 query 和这些 memories 一起
embed,得到一批 query_embedding(不是一个向量,是多个,后续向量召回会各走一遍)。
# searcher.py:353 —— 改写句覆盖 + 多向量嵌入
query = parsed_goal.rephrased_query or query
if parsed_goal.memories:
embed_texts = list(dict.fromkeys([query, *parsed_goal.memories]))
query_embedding = self.embedder.embed(embed_texts) # 返回 list[list[float]]
3.2 单 scope 内的四路召回:GraphMemoryRetriever
要解决的小问题。 给定一个记忆范围(如 LongTermMemory)和解析好的目标,怎么从图数据库里
把候选节点捞出 来?
思路:一个 scope 内也不止一种召回。 GraphMemoryRetriever.retrieve(recall.py:81)在同一个 scope 里
并行跑最多四种召回,最后按节点 id 合并去重:
GraphMemoryRetriever.retrieve(scope=LongTermMemory)
├─ _graph_recall 结构召回:按 keys 精确匹配 / tags 重叠≥2
├─ _vector_recall 向量召回:query_embedding 逐个做相似度检索
├─ _bm25_recall (若配了 bm25) 字面 BM25 打分
└─ _fulltext_recall (若 use_fast_graph) 图库全文索引
│
▼ combined = {item.id: item} ← 按 id 去重合并
list[TextualMemoryItem]
特例:工作记忆走捷径。 WorkingMemory scope 不做上面四路,直接 get_all_memory_items 全量取回、
截断 top_k(recall.py:122-131)——因为工作记忆本来就小、时效强,全取即可。
四路各自在干嘛:
| 召回路 | 匹配依据 | 关键行 |
|---|---|---|
_graph_recall | key ∈ parsed_goal.keys,或 tags 重叠 ≥ 2 | recall.py:242 |
_vector_recall | 每个 query 向量做 search_by_embedding,按分数并集去重 | recall.py:370 |
_bm25_recall | 先按 scope 元数据取候选节点,再 BM25Okapi 打分 | recall.py:534 |
_fulltext_recall | 图库原生全文索引 search_by_fulltext | recall.py:572 |
图谱召回的巧妙约束——"标签至少重叠 2 个"。 只要有一个标签相同就召回,噪声太大;所以要求 tags 交集 ≥ 2 才保留,把"沾边"过滤成"确有共性":
# recall.py:265 —— 结构召回的保留判据
if parsed_goal.keys and node_key in parsed_goal.keys:
keep = True # key 精确命中
elif parsed_goal.tags:
overlap = len(set(node_tags) & set(parsed_goal.tags))
if overlap >= 2: # 标签至少重叠 2 个才算相关
keep = True
向量召回的"双路"设计。 _vector_recall 内部还分 path A(无优先级)和 path B(带 search_priority
偏好过滤)并发跑,再把两路命中按"同 id 取高分"合并(recall.py:419-467)——让"优先级偏好"能加成
但不至于漏掉普通命中。
3.3 多路召回的编排:_retrieve_paths
要解决的小问题。 §3.2 是"一个 scope 内"的召回。但一次检索要覆盖多种记忆类型 + 多种来源 (工作/长期/用户/工具/技能/偏好/联网),这些怎么组织?
思路:用一个线程池同时发起 A~F 六条路径。 Searcher._retrieve_paths(searcher.py:349)按开关把
若 干条路径提交进 ContextThreadPoolExecutor,全部并行,最后 t.result() 汇总:
| 路径 | 方法 | 触发条件 |
|---|---|---|
| A 工作记忆 | _retrieve_from_working_memory | 总是(scope 匹配) |
| B 长期+用户 | _retrieve_from_long_term_and_user | 总是 |
| C 联网 | _retrieve_from_internet | 有 retriever 且 parsed_goal.internet_search |
| 关键词 | _retrieve_from_keyword | use_fulltext 开 |
| D 工具 | _retrieve_from_tool_memory | search_tool_memory |
| E 技能 | _retrieve_from_skill_memory | include_skill_memory |
| F 偏好 | _retrieve_from_preference_memory | include_preference_memory |
共同套路。 每条路径都是"调 GraphMemoryRetriever.retrieve 拿候选 → 立刻 _maybe_rerank"。
路径 B 的长期记忆和用户记忆还各自并发(searcher.py:761),并对结果做两道清洗:
_deduplicate_rawfile_results(删掉被 RawFile 指向的 summary 节点,searcher.py:1282)和
_filter_intermediate_content(滤掉含 "File URL:/File ID:/Filename:" 的中间产物,searcher.py:1330)。
每一路都可能带 CoT 增强(见 §3.5)。 路径 B/D/E/F 若开了 vec_cot,会先把 query 拆成子问题、
各自嵌入,和原始 query 向量拼在一起再送去向量召回,扩大召回面。
3.4 关键词召回:_retrieve_from_keyword 的加权抽词
要解决的小问题。 全文/关键词检索需要"从一句话里挑出最该用来匹配的几个词",挑多了引噪声, 挑少了漏关键。
思路:按语言 + 长度动态决定抽几个词,并给英文词打分排序。核心是
_extract_weighted_keyword_terms(searcher.py:603):
- 先
detect_lang,再_keyword_extract_top_k按 query 长短决定抽 1~3 个词(searcher.py:565)。 - 中文用
jieba.analyse.extract_tags(带词性白名单KEYWORD_ALLOW_POS)。 - 英文用
_rank_english_keyword_terms:按"出现次数×3 + 长度权重 + 含数字加成"打分(searcher.py:590)。 - 全程用停用词表
StopwordManager过滤、去重。
安全细节:防注入。 抽出的词在拼进 to_tsquery 前会被引号包裹并转义单引号,避免用户输入里的
操作符被当成查询语法(searcher.py:659)。
3.5 CoT 查询增强:把复杂问题拆成子问题
要解决的小问题。 "我上个月在杭州出差时定的那家酒店叫什么、离会场多远?"——一句里其实塞了两三个 子问题,单向量召回抓不全。
思路。 _cot_query(searcher.py:1379)用大模型判断 query 是否"复杂";复杂就拆成 sub_questions
(最多 split_num=3 个),对每个子问题各嵌入一遍,和原向量一起送召回。
# searcher.py:1412 —— CoT 拆解的核心判断
assert "is_complex" in response_json
if not response_json["is_complex"]:
return [query] # 简单问题,不拆
else:
return response_json["sub_questions"][:split_num] # 复杂,拆成子问题
接线处。 在路径 B 里(searcher.py:753):vec_cot 开时先 _cot_query 得到子问题、embed 成
cot_embeddings,再 extend(query_embedding) 把原向量并进去,一起喂 graph_retriever.retrieve。
拆解失败会 except 兜底成 [query],不影响主流程。