数据截至 (上游 commit db80b6a7c591)
混合检索:意图规划 + 三视图召回 + 反思
30 秒导读: 这一章讲 SimpleMem 文本记忆的"读"侧引擎(Stage 3)。给它一句自然语言问题,它不会只做一次向量搜索,而是:先让 LLM 把问题拆成几个子查询、再同时跑三条互补的召回路(语义/词法/符号),把结果并起来去重,最后再让 LLM 检查"这些够不够回答问题",不够就针对缺口追加检索。
本章聚焦文本这一支的检索引擎。记忆是怎么"写"进去(三阶段语义无损压缩)见 01-text-pipeline.md;总体架构见 index.md。
1. 这是什么(零基础也能懂)
-
一句话定义:
HybridRetriever是一个"会做计划、会多路并查、会自我检查"的记忆检索器——输入一句话问题,输出一组相关的记忆条目(MemoryEntry)。 -
解决什么问题: 假设你的记忆库里存了几百条压缩后的对话事实。用户问"上周 Alice 在上海跟谁聊了产品 XYZ?"。如果只做向量相似度搜索,常会漏——因为:
- "上周""上海"这种时间/地点约束,向量匹配抓不准(它只懂"语义像不像",不懂"是不是在这个时间窗内");
- "XYZ"这种专有名词/关键词,如果向量把它泛化成"某产品",就检不到那条精确记录;
- 复杂问题往往需要好几条记忆拼起来才能答,一次查询召不全。
-
它怎么破: 用三种互补的召回方式同时查,再把结果合并。三者各补一个短板:
召回视图 擅长 底层手段 语义 R_sem 找"意思相近"的记忆(换了说法也能命中) 向量余弦相似度 词法 R_lex 找"字面命中关键词/专名"的记忆 BM25 全文检索 符号 R_sym 按人物/时间/地点/实体精确过滤 元数据 SQL 过滤 -
一句话直觉: 把它想成一个尽责的资料员——先想清楚"要查几件事"(规划),再兵分三路去不同的档案柜找(语义柜按意思、词法柜按关键词、符号柜按标签过滤),找回来汇总去重,最后自己检查一遍"够不够,缺的再去补一趟"(反思)。
2. 顶层全景(它大概怎么转)
一次 retrieve(query) 的主线(enable_planning=True 时走 _retrieve_with_planning):
用户问题 q
│
▼
┌─────────────────────── 阶段1:检索规划(Planning) ───────────────────────┐
│ _analyze_information_requirements(q) ← LLM:这问题要哪几类信息? │
│ _generate_targeted_queries(q, plan) ← LLM:拆成最小必要子查询(≤4条) │
└──────────────────────────────────────────────────────────────────────┘
│ 子查询列表 [q1, q2, ...]
▼
┌─────────────────────── 阶段2:三视图并行召回 ───────────────────────────┐
│ 语义 R_sem : 每个子查询各跑一次向量搜索(可并行) │
│ 词法 R_lex : _analyze_query(q) 抽关键词 → BM25 全文检索 │
│ 符号 R_sym : 从 q 抽人物/时间/地点/实体 → 元数据过滤 │
└──────────────────────────────────────────────────────────────────────┘
│ 全部结果拼在一起 all_results
▼
_merge_and_deduplicate_entries ← C_q = R_sem ∪ R_lex ∪ R_sym(按 entry_id 去重)
│
▼
┌─────────────────────── 阶段3:反思式补检索(可选) ─────────────────────┐
│ _retrieve_with_intelligent_reflection: │
│ 循环 ≤ MAX_REFLECTION_ROUNDS 轮: │
│ LLM 判断结果是否"完整" → 不完整就生成缺口查询 → 再召回 → 合并 │
└──────────────────────────────────────────────────────────────────────┘
│
▼
最终 MemoryEntry 列表
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
HybridRetriever | 检索总控:规划→三视图→合并→反思 | simplemem/core/hybrid_retriever.py:20 |
VectorStore | 底层存储与三种物理检索(向量/FTS/元数据过滤) | simplemem/core/database/vector_store.py:19 |
MemoryEntry | 记忆单元数据结构,自带三层索引所需字段 | simplemem/core/models/memory_entry.py:13 |
settings / config_default.py | TOP_K、并行/规划/反思开关的默认值 | simplemem/core/settings.py:12、config_default.py:71 |
主线走一遍(高层): 问题进来 → LLM 拆子查询 → 三路并行召回 → 并集去重 → LLM 反思补检索 → 交给下游 AnswerGenerator 生成答案。
入口就一个: retrieve() 里先看 enable_planning;开着就走完整规划管线,关了就退化成一次纯语义搜索(_semantic_search)——见 hybrid_retriever.py:69-73。
3. 核心原理(逐个机制,由浅入深)
3.1 检索规划:把一个问题拆成"要查的几件事"
-
要解决的小问题: 一个复杂问题("上周 Alice 和 Bob 讨论产品 XYZ 的结论是什么?")往往对应记忆库里分散的好几条事实。一次查询召不全,得先想清楚"要查哪几件事"。
-
思路: 不直接拿原问题去搜,而是先让 LLM 做两步分析——先问"这问题需要哪些类型的信息(
required_info)、检索深度多少",再据此生成最小必要的一组子查询(而不是海量子查询,强调"efficiency - fewer, more targeted queries are better")。 -
原理演示(示意,非源码):
# 演示"规划=先分析需求,再据此拆子查询"
plan = llm_analyze_requirements(query) # → {question_type, key_entities, required_info, minimal_queries_needed}
subqueries = llm_generate_queries(query, plan) # → ["原问题", "产品XYZ的讨论结论", ...]
subqueries = subqueries[:4] # 重点看:上限 4 条,强制"最小必要"
-
真实实现: 两步分别是
_analyze_information_requirements(hybrid_retriever.py:650,LLM 产出required_info/minimal_queries_needed,temperature=0.2)和_generate_targeted_queries(hybrid_retriever.py:719)。后者有两个硬约束:必须包含原问题(hybrid_retriever.py:780-781,原问题不在列表就插到最前),以及最多 4 条(hybrid_retriever.py:784queries = queries[:4])。 -
坑/细节: LLM 分析失败时两个方法都有兜底——
_analyze_information_requirements失败退化成一条general需求(hybrid_retriever.py:711-717),_generate_targeted_queries失败直接return [original_query](:792)。所以规划出错不会让检索崩,只是退回"就查原问题"。
注意别混淆:
_generate_targeted_queries(规划用,受required_info引导、限 4 条)和_generate_search_queries(hybrid_retriever.py:345,生成 3-5 条的通用版)是两个方法。主规划路径走的是前者。
3.2 三视图召回:语义 / 词法 / 符号并起来
这是本章的心脏。三条召回路互补,结果取并集(不是交集),宁可多召回、后面再去重,也不漏。数学写法:C_q = R_sem ∪ R_lex ∪ R_sym(见文件顶注 hybrid_retriever.py:7)。
三路怎么触发,在 _retrieve_with_planning 里按顺序拼:语义结果来自子查询(hybrid_retriever.py:94-101),然后追加词法(:107-109)和符号(:112-114)。
语义 R_sem —— 向量余弦
- 它干嘛: 找"意思相近"的记忆,换了说法也能命中。公式
R_sem = Top-n(cos(E(q_sem), E(m_i)))。 - 实现:
_semantic_search(hybrid_retriever.py:241)只是把子查询转发给vector_store.semantic_search(query, top_k=self.semantic_top_k)。底层把 query 编码成向量后在 LanceDB 上做向量搜索(vector_store.py:159-160)。top_k默认SEMANTIC_TOP_K=25(三路里给得最宽)。
词法 R_lex —— BM25 关键词
- 它干嘛: 抓字面命中的专名/关键词(向量容易把 "XYZ" 泛化掉,这一路把它救回来)。公式
R_lex = Top-n(BM25(q_lex, m_i))。 - 两步走: 先
_analyze_query(hybrid_retriever.py:176)让 LLM 从问题里抽出keywords(以及人物/时间/地点/实体,一次抽全供两路复用),再_keyword_search(:248)把关键词交给vector_store.keyword_search。抽不到关键词就退化用原问题当关键词(:257-260)。 - 底层:
keyword_search(vector_store.py:167)把关键词" ".join成一个字符串丢给self.table.search(query)——当 FTS 索引存在时,LanceDB 会自动把字符串输入识别为全文检索(vector_store.py:176-178)。top_k默认KEYWORD_TOP_K=5。
符号 R_sym —— 元数据精确过滤
- 它干嘛: 按人物/时间/地点/实体做精确过滤——这是语义/词法都做不到的"结构化约束"。公式
R_sym = Top-n({m_i | Meta(m_i) ⊨ q_sym})("记忆的元数据满足查询约束")。 - 实现:
_structured_search(hybrid_retriever.py:264)从_analyze_query抽出的persons/location/entities/time_expression里取条件。没有任何结构化条件就直接返回空(:280-281)——符号路对不含约束的问题不产出噪声。 - 时间的特殊处理: 时间表达式先经
_parse_time_range解析成区间(见 3.3),再作为条件传下去。 - 底层过滤:
structured_search(vector_store.py:114-138)把条件透传给存储后端;后端把条件拼成 SQL 式where子句(array_has_any(persons, ...)、location LIKE '%...%'、timestamp >= ... AND <= ...,拼接见vector_store_backend.py:237-253),用prefilter=True先过滤再取top_k(vector_store_backend.py:253)。top_k默认STRUCTURED_TOP_K=5。
3.3 时间区间解析:把"上周"变成一个 ISO 时间窗
-
要解决的小问题: 用户说"上周""11 月 15 日"这种自然语言时间,得变成
timestamp >= X AND timestamp <= Y才能拿去过滤。 -
实现:
_parse_time_range(hybrid_retriever.py:292)用第三方库dateparser把时间短语解析成一个datetime,配settings={'PREFER_DATES_FROM': 'past'}(偏向理解成过去的时间,:302-305)。 -
两条规则:
- 默认按"当天"给区间: 命中日期后,起点设成当天 00:00:00、终点设成 23:59:59(
hybrid_retriever.py:309-310)。 - "周"扩窗: 如果表达式里含
"week"或中文"周",把区间前后各扩 7 天(:313-315start_time - timedelta(days=7)/end_time + timedelta(days=7))——这是它对"上周"这类模糊范围的粗略近似。
- 默认按"当天"给区间: 命中日期后,起点设成当天 00:00:00、终点设成 23:59:59(
-
坑: 解析失败或没命中返回
None,符号路就少一个条件、不会报错(:321-324)。这个"当天/周扩 7 天"是启发式近似,不是精确日历周,理解成"大致落在那个时间附近"即可。
3.4 合并去重:三路结果并成一份
-
要解决的小问题: 三路(其中语义还是多个子查询)会召回大量重复条目,得合成一份不重的列表。
-
实现:
_merge_and_deduplicate_entries(hybrid_retriever.py:409)——极简:遍历所有条目,用一个seen_ids集合按entry_id去重,保留首次出现的顺序(:413-421)。这就是并集C_q = R_sem ∪ R_lex ∪ R_sym的落地。 -
原理演示(示意,非源码):
seen, merged = set(), []
for entry in all_results: # all_results = 语义 + 词法 + 符号,已按此顺序拼好
if entry.entry_id not in seen: # 重点看:去重键是 entry_id
seen.add(entry.entry_id)
merged.append(entry) # 先到先留,保序
- 一个易错点(值得留意): 文件里还有另一个
_merge_and_deduplicate(hybrid_retriever.py:326),它接收的是Dict[str, List]并按优先级structured > semantic > keyword排序合并。但主管线用的是_merge_and_deduplicate_entries(接扁平 list、不排优先级)。也就是说,主路径的合并只保序去重,并不给符号/语义结果更高优先级——那个带优先级的版本在主规划路径上未被调用。
3.5 反思式补检索:自己检查"够不够,缺什么再补一趟"
-
要解决的小问题: 合并后的结果未必够回答问题。与其直接交给下游,不如先让 LLM 检查一遍。
-
思路: 拿当前结果去问 LLM"这些信息完整吗、覆盖率多少、缺哪几类";不完整就针对缺口生成新查询、再召回、再合并,循环若干轮直到"完整"或到达上限。
-
主路径用的是"智能反思"版
_retrieve_with_intelligent_reflection(hybrid_retriever.py:794,由_retrieve_with_planning在:125调用)。它带着规划阶段的information_plan一起判断,循环体:for round in range(max_reflection_rounds): # 上限 MAX_REFLECTION_ROUNDS,默认 2status = _analyze_information_completeness(...) # LLM → "complete" / "incomplete" / "no_results"if status == "complete": break # 够了,收工if status == "incomplete":q_new = _generate_missing_info_queries(...) # LLM 生成 1-3 条缺口查询new = 并行/串行再语义召回(q_new)current = _merge_and_deduplicate_entries(current + new)else: # no_resultsbreak -
完整性判断:
_analyze_information_completeness(hybrid_retriever.py:841)让 LLM 对照required_info输出assessment(complete/incomplete)和coverage_percentage,temperature=0.1(求稳定)。判不出来时默认 "incomplete"(偏保守,:903-905),宁可多补一轮。 -
缺口查询:
_generate_missing_info_queries(hybrid_retriever.py:907)据"当前有什么 + 还缺什么"生成 1-3 条针对性查询;这些补查询只走语义召回(:827/_additional_search_worker:643),不再重跑词法/符号。 -
多轮上限: 由
max_reflection_rounds(默认MAX_REFLECTION_ROUNDS=2)封顶,防止无限追加。enable_reflection可全局关,也可在retrieve(query, enable_reflection=False)里按单次查询覆盖(hybrid_retriever.py:58,122)——注释说这对"不该反思的对抗性问题"有用。
诚实说明(代码里有两套反思): 除智能版外,文件里还有一套更早的
_retrieve_with_reflection(hybrid_retriever.py:129),用_check_answer_adequacy(:423,判 "sufficient"/"insufficient")和_generate_additional_queries(:486)。结构几乎一样,但不带information_plan、判据是"够不够答"而非"对照需求完整度"。在当前主规划路径上,_retrieve_with_reflection与_check_answer_adequacy并未被调用——真正跑的是智能版。两套并存看起来是迭代残留;本章把两套都记下来,但请以智能版为准。
4. 深入实现:三层索引在 LanceDB 上怎么落地
三视图召回不是三张表,而是同一张 LanceDB 表上的三种查询方式。表结构在 _init_table(vector_store.py:53)一次建好,一行 = 一条记忆:
| 列 | 服务哪一视图 | 类型 |
|---|---|---|
vector | 语义 R_sem(dense 向量列) | list<float32>,维度 = embedding_model.dimension |
lossless_restatement | 词法 R_lex(建 FTS 索引的那一列) | string |
persons / entities | 符号 R_sym(数组元数据) | list<string> |
location / timestamp / topic | 符号 R_sym(标量元数据) | string |
keywords | 存关键词(词法检索实际打在 restatement 上) | list<string> |
entry_id | 去重主键 | string |
对应 MemoryEntry 的字段(models/memory_entry.py:19-53),这些字段就是 01 章压缩管线产出的、带三层索引信息的记忆单元。
三种物理检索的落地:
-
语义 = 向量搜索。
semantic_search(vector_store.py:150)把 query 用encode_single(query, is_query=True)编码,self.table.search(query_vector.tolist()).limit(top_k)(:159-160)。空表直接返回空(:156)。 -
词法 = Tantivy 全文索引。 FTS 索引在第一次插入数据后才建(后端
insert里add完调_init_fts_index,vector_store_backend.py:185-186),打在lossless_restatement列上。本地存储用 Tantivy(use_tantivy=True,tokenizer_name="en_stem"英文词干,vector_store_backend.py:161-166);云存储(gs/s3/az 前缀)Tantivy 用不了,改用 LanceDB 原生 FTS(use_tantivy=False,:153-158)。检索时传字符串给table.search(...),LanceDB 见到有 FTS 索引就自动当全文查询处理(vector_store_backend.py:214-218)。 -
符号 = SQL 式 where 过滤。 后端
structured_search(vector_store_backend.py:224-262)把条件拼成 DataFusion/SQL 谓词:数组列用array_has_any(persons, make_array('Alice','Bob')),地点用LIKE '%...%'(单引号做了转义:243),时间用timestamp >= '...' AND timestamp <= '...',多条件AND连接后.where(where_clause, prefilter=True)——prefilter 意味着先过滤再取候选,而非取回后再滤(:253)。
性能相关: add_entries 是批量插入并编码(vector_store.py:55-81);optimize()(vector_store.py:146)在批量写后压紧索引以加速查询。检索侧的并行由 HybridRetriever 用 ThreadPoolExecutor 承担(见下),VectorStore 本身是同步的。
并行检索: 多个子查询用线程池并行跑语义搜索——_execute_parallel_searches(hybrid_retriever.py:559),worker 是 _semantic_search_worker。开关 ENABLE_PARALLEL_RETRIEVAL(默认开)、线程数 MAX_RETRIEVAL_WORKERS。注意 settings.py 里默认 MAX_RETRIEVAL_WORKERS=8,而构造函数形参默认写的是 3(hybrid_retriever.py:43)——实际生效的是 settings 的 8,因为构造时用 or config 兜底(:56)。并行块整体 try/except,失败会回退串行(:585-594)。
5. 巧妙之处(可借鉴的技术)
-
并集而非交集,分工明确。 三路各补一短板(语义补"换说法"、词法补"专名"、符号补"结构化约束"),取并集保召回,去重靠
entry_id。妙在符号路对无约束问题直接返回空(hybrid_retriever.py:280-281、vector_store.py:201-202),不制造噪声。 -
一次 LLM 抽取,喂两路。
_analyze_query(:176)一把抽出 keywords + persons + time + location + entities,词法路取前者、符号路取后者,省掉重复调用。 -
规划强约束"最小必要"。 子查询硬性
[:4](:784)、反思缺口查询限 1-3 条,并且每次都强制包含原问题——在"召回充分"和"token/延迟"之间刻意压小扇出。 -
反思带覆盖率、判不出偏保守。 完整性评估要 LLM 报
coverage_percentage,失败默认incomplete(:903-905)——宁可多补一轮也不早停。 -
本地/云自适应 FTS。 Tantivy 只在本地文件系统可用,代码按
db_path前缀自动切换到原生 FTS(vector_store.py:42,80-96),让同一套代码跑本地和对象存储。
6. 边界与局限
-
三路各自 Top-K 截断,无跨路重排。 合并只做"去重保序",没有统一打分/RRF 融合——一条既被语义又被词法命中的记忆,不会因此被排到更前(主路径用的是不排优先级的
_merge_and_deduplicate_entries,那个带优先级的_merge_and_deduplicate在主路径未被调用,见 3.4)。 -
时间解析是启发式。
_parse_time_range对"周"一律前后各扩 7 天(hybrid_retriever.py:313-315),不是精确日历周;非英文/复杂时间短语依赖dateparser的能力,失败即静默返回None。 -
重度依赖 LLM 且多次往返。 规划(2 次)+ 查询分析(1 次)+ 每轮反思(评估 + 生成)都要调 LLM,延迟和成本较高;失败虽有兜底,但兜底会削弱检索质量。
-
词法索引的时机。 FTS 索引第一次插入数据后才建(
vector_store.py:146-148);空库或索引未就绪时词法路可能召不到,keyword_search有 try/except 兜底返回空(:181-183)。 -
两套反思代码并存。 智能版实际生效,旧版
_retrieve_with_reflection/_check_answer_adequacy留在文件里未接入主路径,属维护包袱。
7. 横向对比
- 与本项目自身其它检索支的关系:文本这一支之外,SimpleMem 还有 EvolveMem 的自进化检索(见 03-evolvemem.md)和 Omni/Cross 的多模态与跨会话检索(见 04-omni-and-cross.md)。本章讲的三视图召回是它们共同的文本检索底座。
- 与"写"侧的衔接:三视图能查什么,取决于压缩管线往
MemoryEntry里塞了哪些字段(向量、关键词、人物/时间/地点/实体)——见 01-text-pipeline.md。
8. 代码地图(导航索引)
| 主题 | 文件 | 符号 |
|---|---|---|
| 检索总入口(规划开关) | simplemem/core/hybrid_retriever.py | HybridRetriever.retrieve |
| 完整规划管线 | simplemem/core/hybrid_retriever.py | _retrieve_with_planning |
| 需求分析(要哪几类信息) | simplemem/core/hybrid_retriever.py | _analyze_information_requirements |
| 生成最小子查询(限 4) | simplemem/core/hybrid_retriever.py | _generate_targeted_queries |
| 查询分析(抽 kw/人物/时间…) | simplemem/core/hybrid_retriever.py | _analyze_query |
| 语义召回 R_sem | simplemem/core/hybrid_retriever.py | _semantic_search |
| 词法召回 R_lex | simplemem/core/hybrid_retriever.py | _keyword_search |
| 符号召回 R_sym | simplemem/core/hybrid_retriever.py | _structured_search |
| 时间区间解析 | simplemem/core/hybrid_retriever.py | _parse_time_range |
| 合并去重(主路径) | simplemem/core/hybrid_retriever.py | _merge_and_deduplicate_entries |
| 带优先级合并(主路径未用) | simplemem/core/hybrid_retriever.py | _merge_and_deduplicate |
| 智能反思(主路径) | simplemem/core/hybrid_retriever.py | _retrieve_with_intelligent_reflection |
| 旧版反思(未接入) | simplemem/core/hybrid_retriever.py | _retrieve_with_reflection / _check_answer_adequacy |
| 完整性评估 / 缺口查询 | simplemem/core/hybrid_retriever.py | _analyze_information_completeness / _generate_missing_info_queries |
| 并行召回 | simplemem/core/hybrid_retriever.py | _execute_parallel_searches |
| 表结构 + FTS 初始化 | simplemem/core/database/vector_store.py | _init_table / _init_fts_index |
| 向量搜索(dense) | simplemem/core/database/vector_store.py | semantic_search |
| 全文搜索(Tantivy FTS) | simplemem/core/database/vector_store.py | keyword_search |
| 元数据过滤(SQL where) | simplemem/core/database/vector_store.py | structured_search |
| 记忆单元数据结构 | simplemem/core/models/memory_entry.py | MemoryEntry |
| TOP_K / 开关默认值 | simplemem/core/settings.py | _DEFAULTS(SEMANTIC_TOP_K 等) |
| 参数说明副本 | simplemem/core/config_default.py | SEMANTIC_TOP_K / ENABLE_REFLECTION … |