跳到主要内容

混合检索:意图规划 + 三视图召回 + 反思

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:18
MemoryEntry记忆单元数据结构,自带三层索引所需字段simplemem/core/models/memory_entry.py:13
settings / config_default.pyTOP_K、并行/规划/反思开关的默认值simplemem/core/settings.py:12config_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:784 queries = 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:185)把条件拼成 SQL 式 where 子句(array_has_any(persons, ...)location LIKE '%...%'timestamp >= ... AND <= ...),用 prefilter=True 先过滤再取 top_k(vector_store.py:204-228)。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-315 start_time - timedelta(days=7) / end_time + timedelta(days=7))——这是它对"上周"这类模糊范围的粗略近似。
  • 坑: 解析失败或没命中返回 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,默认 2
    status = _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_results
    break
  • 完整性判断: _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 索引在第一次插入数据后才建(add_entries_init_fts_index,vector_store.py:146-148),打在 lossless_restatement 列上。本地存储用 Tantivy(use_tantivy=True,tokenizer_name="en_stem" 英文词干,:89-95);云存储(gs/s3/az 前缀)Tantivy 用不了,改用 LanceDB 原生 FTS(use_tantivy=False,:80-87)。检索时传字符串给 table.search(...),LanceDB 见到有 FTS 索引就自动当全文查询处理(:176-178)。

  • 符号 = SQL 式 where 过滤。 structured_search(vector_store.py:185)把条件拼成 DataFusion/SQL 谓词:数组列用 array_has_any(persons, make_array('Alice','Bob')),地点用 LIKE '%...%'(单引号做了转义 :211),时间用 timestamp >= '...' AND timestamp <= '...',多条件 AND 连接后 .where(where_clause, prefilter=True)——prefilter 意味着先过滤再取候选,而非取回后再滤(:222-228)。

性能相关: add_entries 是批量插入并编码(vector_store.py:121-144);optimize()(:240)在批量写后压紧索引以加速查询。检索侧的并行由 HybridRetrieverThreadPoolExecutor 承担(见下),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-281vector_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.pyHybridRetriever.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_semsimplemem/core/hybrid_retriever.py_semantic_search
词法召回 R_lexsimplemem/core/hybrid_retriever.py_keyword_search
符号召回 R_symsimplemem/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.pysemantic_search
全文搜索(Tantivy FTS)simplemem/core/database/vector_store.pykeyword_search
元数据过滤(SQL where)simplemem/core/database/vector_store.pystructured_search
记忆单元数据结构simplemem/core/models/memory_entry.pyMemoryEntry
TOP_K / 开关默认值simplemem/core/settings.py_DEFAULTS(SEMANTIC_TOP_K 等)
参数说明副本simplemem/core/config_default.pySEMANTIC_TOP_K / ENABLE_REFLECTION