跳到主要内容

检索引擎:文本、语义与双通道查询

30 秒导读: RAG 里"取回(retrieve)"这一步在 LLMWare 里由一个 Query 类统包。你给它一个问题字符串,它可以走文本全文索引(在集合数据库里找字面命中)、走语义向量(在向量库里找意思相近的 block),或两条一起走再合并。三种模式产出的都是同一种结构——一列 query-result 字典(下称 qr),供 05 章拿去打包成证据、喂给模型。

本章讲的是 llmware/retrieval.py 里的 Query 类。它的上游是 01 章(知识库容器 + 集合 DB / 向量库双存储)和 03 章(嵌入层),本章不重复它们的实现,只讲"怎么在它们之上做取回"。


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

一句话定义: Query 是"取回引擎"——把一个 Library(知识库)当数据源,对着它执行查询,返回一列命中的文本块。

解决什么问题: 你已经把一堆 PDF、网页解析成了 block(文本块)并存进库(01/02 章),有的还算好了向量(03 章)。现在你想问"奥地利的首都是哪",系统得从成千上万个 block 里挑出最相关的几个。这就是取回。

为什么不是一种查询就够: 找相关块有两种根本不同的思路,各有盲区:

思路怎么找强在哪弱在哪
文本查询在集合 DB 的全文索引里找字面命中的词精确、快、可解释、认识专有名词/数字换个说法就找不到("车" vs "汽车")
语义查询把问题变成向量,在向量库里找意思相近的块抓得住同义改写、上下文需要预先算好嵌入;可能漏掉精确关键词

它能做什么(功能清单):

  • 纯文本查询,还能叠加文档过滤、内容类型(表/图/文本)过滤、页码/作者过滤。
  • 纯语义查询,带距离阈值裁剪,支持二级文档过滤。
  • 双通道(hybrid):文本 + 语义各跑一遍,按"两边都命中"重新排序合并。
  • 一堆结果后处理:上下文窗口扩展、时间戳过滤、参考文献(bibliography)生成。

用起来什么样: 官方文档字符串里的最小例子(retrieval.py:88-95):

from llmware.library import Library
from llmware.retrieval import Query

library = Library().create_new_library('lib_semantic_query')
library.add_website(url='https://en.wikipedia.org/wiki/Austria', get_links=False)
library.install_new_embedding(embedding_model_name="industry-bert-sec", vector_db="milvus")

query = Query(library=library) # 一个库 = 一个查询源
results = query.semantic_query(query='the capital of austria is', result_count=3)
# results 是一列 dict,每个 dict 就是一个命中块 + 元数据 + 打分

一句话直觉:Query 想成"图书馆检索台"。文本查询是"按书名/关键词在卡片目录里翻",语义查询是"跟管理员描述你想要什么、他凭理解给你抱几本过来",双通道是"两种都做,把两边都推荐的排最前"。


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

一张图先看清三条取回路径怎么汇到同一个出口。 从左读到右,注意三条路最后都挤进同一个"归一化"漏斗:

你的问题字符串

┌──────────────┼──────────────┐
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
│ 文本查询 │ │ 语义查询 │ │ 双通道 │
│text_query│ │semantic_ │ │dual_pass │
│ │ │ query │ │ _query │
└────┬────┘ └─────┬─────┘ └────┬─────┘
│ │ │ (内部各调
│ │ │ 一次文本 +
走集合DB全文 先嵌入问题→ │ 一次语义)
索引查 向量库search_ │
│ index找近邻→ │
│ 按 doc_ID+block_ID │
│ 回连到真实block │
│ │ │
┌────▼──────────────▼──────┐ │
│ 归一化漏斗 │◄───────┘
│ _cursor_to_qr / _..._ │ 双通道拿两边已归一的
│ with_secondary_filter │ qr 做 n×n 比对合并
│ → 统一的 qr 字典列表 │
└───────────┬───────────────┘

┌──────▼───────┐
│ 后处理(可选) │ 窗口扩展 / 时间过滤 /
│ │ 语义重排 / 参考文献
└──────┬───────┘

交给 05 章:证据打包 + 生成

部件一句话职责:

部件干什么位置
Query.__init__绑定库、探测已有嵌入、决定默认 search_mode、备好输出键retrieval.py:116
text_query 家族把查询交给集合 DB 的全文索引retrieval.py:421
semantic_query 家族嵌入问题→查向量库→回连 blockretrieval.py:706
dual_pass_query文本+语义各跑一遍,交叉比对合并retrieval.py:865
_cursor_to_qr归一化漏斗:把任意来源的原始块打成统一 qrretrieval.py:647
_cursor_to_qr_with_secondary_filter同上,但边打包边套二级过滤retrieval.py:574
后处理方法组窗口扩展 / 时间过滤 / 重排 / 参考文献retrieval.py:1457/1563/1699/1721

主线走一遍(高层): 输入一个问题 → 按模式进入文本/语义/双通道之一 → 三条路都归到 _cursor_to_qr 打成统一的 qr 列表 → 可选地再做窗口扩展/过滤/重排 → 输出 qr,交给生成阶段。

为什么"归一化漏斗"是全章的枢纽: 文本查询拿到的是 DB 游标里的原始行,语义查询拿到的是"[block, 距离]"对——两者字段不一。但它们都被送进 _cursor_to_qr,统一补齐 matches/page_num/score/distance 等键、裁到 result_count、只保留 query_result_return_keys 里的字段。所以不管走哪条路,下游看到的 qr 结构完全一致。这就是 05 章能"无所谓你怎么取回"的原因。


3. 打底:一个 Query 是怎么初始化的

这节讲: 为什么 Query(library=library) 一行,就"自动知道"该走文本还是语义。

3.1 构造时的模式探测

__init__(retrieval.py:116)做的关键动作,是探测这个库上有没有可用的嵌入,据此定 self.search_mode:

  • self.library.get_embedding_status() 拿到该库的嵌入记录列表(retrieval.py:176)。
  • 如果传了 embedding_model_name,就在记录里找匹配的模型(可再叠加 vector_db 精确配对),命中且 embedding_status == "yes"search_mode = "semantic"(retrieval.py:180-201)。
  • 没传模型名但库里有嵌入记录 → 默认取最近一条嵌入记录作为语义源(retrieval.py:204-223)。
  • 什么嵌入都没有 → 兜底 search_mode = "text"(retrieval.py:233-234)。

一旦匹配到语义源,构造函数会顺手 load_embedding_model() 把嵌入模型加载好(retrieval.py:230-231)。

一个坑值得记: 你可以用 query_mode= 参数强制覆盖探测结果(retrieval.py:256-257)——不管库里有没有嵌入,query_mode="text" 就强制走文本。

3.2 输出键:qr 里到底有哪些字段

构造函数还定义了三档"输出键清单"(retrieval.py:161-173),决定每个 qr 字典带哪些字段:

档位变量内容
完整query_result_standard_keys_id, text, doc_ID, block_ID, page_num, content_type, author_or_speaker, ..., score, similarity, distance, matches(20 个键)
精简query_result_short_keystext, file_source, page_num, score, distance, matches
最小必需query_result_min_required_keystext, file_source, page_num

默认用完整档(retrieval.py:173)。你可以用 set_output_keys(retrieval.py:298)自定义——但它会强制补回最小必需的三个键(retrieval.py:308-312),保证下游生成阶段一定拿得到正文、来源、页码。get_output_keys(retrieval.py:292)只是读回当前清单。


4. 文本侧:走集合 DB 的全文索引

这节讲: 文本查询怎么把问题交给数据库、原始游标怎么变成统一 qr。

4.1 最朴素的一条:text_query

text_query(retrieval.py:421)几乎是"直通":

# retrieval.py:426-437(节选,展示三步骨架)
if exact_mode:
query = self.exact_query_prep(query) # 加引号做精确匹配
cursor = CollectionRetrieval(self.library_name,
account_name=self.account_name).basic_query(query) # 交给 DB 全文索引
results_dict = self._cursor_to_qr(query, cursor, result_count=result_count, ...)

关键在 CollectionRetrieval(...).basic_query(query)——这一步把查询直接压给集合数据库的文本索引(resources.py:121-123,注释原文:"Simple text query passed to the text index")。也就是说,文本查询的"找"发生在 DB 层,Query 只负责把 DB 吐出来的游标(cursor)加工成 qr。

exact_mode=True 时会先过 exact_query_prep(retrieval.py:1550):用双引号把整个查询包起来,交给 DB 做短语精确匹配;注意它即使你没加引号也会强制包引号(retrieval.py:1557-1559)。

4.2 归一化漏斗:_cursor_to_qr

这是文本侧(也是全章)的核心工序(retrieval.py:647)。它遍历 DB 游标里每一行 raw_qr,做统一加工:

  • 算字符级命中位置: matches = self.locate_query_match(query, raw_qr["text"]),把命中挂到 matches 键(retrieval.py:659-660)。
  • 字段改名/补齐: page_num 取自解析期存的 master_index(retrieval.py:661);_id 转成字符串;score/similarity/distance 缺了就补 0.0(retrieval.py:665-672)——文本查询没有向量距离,所以这里 distance 恒为 0。
  • 裁字段: 只保留 query_result_return_keys 里的键,并额外盖上 query/account_name/library_name(retrieval.py:676-685)。
  • 收集去重的 doc 列表 并在 counter >= result_count 时停(除非 exhaust_full_cursor=True)(retrieval.py:689-697)。

最终产出统一结构:{"query", "results", "doc_ID", "file_source"}(retrieval.py:699),并按 save_history 决定是否登记到查询状态(retrieval.py:701-702)。

locate_query_match 是怎么标命中的(retrieval.py:1510): 它用 CorpTokenizer 把查询切成词,然后在正文里逐字符滑窗比对每个词,命中就记 [起始下标, 词]。这份 matches 是给下游做高亮/证据定位用的原始坐标。是个朴素的 O(正文长 × 词数)扫描,但正文块通常不长。

4.3 带过滤的文本查询

其余文本方法都是"先在 DB 侧下过滤条件,再走归一化",分两类:

A. 文档级过滤 —— text_query_with_document_filter(retrieval.py:441):doc_filter 里认 doc_IDfile_source 作为键(retrieval.py:453-459),调 DB 的 text_search_with_key_value_range 把查询限定在某几篇文档里。认不出键时不报错,而是安全兜底跑无过滤的 basic_query(retrieval.py:461-470)——宁可多给结果,不空手而归。

B. 内容/属性过滤 —— 走 text_query_with_custom_filter(retrieval.py:540): 这是一个通用底座,text_query_by_content_type(retrieval.py:480)、image_querytable_querytext_query_by_author_or_speaker 都是它的薄封装,只是塞不同的 filter_dict:

便捷方法传的 filter_dict
text_query_by_content_type(q, ct){"content_type": ct}
image_query(q){"content_type": "image"}
table_query(q){"content_type": "table"}
text_query_by_author_or_speaker(q, a){"author_or_speaker": a}

text_query_with_custom_filter 先拿 filter_dict 的键对照 library.default_keys 做校验(非法键直接报错返回 -1,retrieval.py:559-563),再调 DB 的 text_search_with_key_value_dict_filter,最后走 带二级过滤的归一化漏斗 _cursor_to_qr_with_secondary_filter(retrieval.py:574)。

4.4 为什么要"二级过滤"

_cursor_to_qr_with_secondary_filter(retrieval.py:574)和 _cursor_to_qr 几乎一模一样,只多一段:在打包前再用 filter_dict 在应用层核对一遍每行是否满足(支持 value 是列表的 in 匹配),不满足就跳过(retrieval.py:602-611)。

这是双保险: DB 侧已经过滤过一次,应用层再兜一次,防止不同后端(Mongo/Postgres/SQLite)对复合过滤的语义差异漏网。同一个方法后面还会被语义侧复用(见 4.5 的镜像用法)。


5. 语义侧:嵌入问题 → 查向量库 → 回连 block

这节讲: 语义查询和文本查询最大的不同——"找"发生在向量库,但结果得拿 id 回到集合 DB 里的真实 block

5.1 semantic_query 的四步

semantic_query(retrieval.py:706)骨架:

# retrieval.py:713-751(节选四步)
self.load_embedding_model()
self.query_embedding = self.embedding_model.embedding(query) # ① 问题→向量
semantic_block_results = self.embeddings.search_index( # ② 向量库找近邻
self.query_embedding, embedding_db=self.embedding_db,
model=self.embedding_model, sample_count=result_count)
for i, blocks in enumerate(semantic_block_results): # ③ 按距离阈值裁
if blocks[1] < embedding_distance_threshold:
block_data = blocks[0]
block_data["distance"] = blocks[1]; block_data["semantic"] = "semantic"
block_data["score"] = 0.0
qr_raw.append(block_data)
results_dict = self._cursor_to_qr(query, qr_raw, result_count=result_count) # ④ 归一化

四步拆开看:

  1. 嵌入问题: 调嵌入模型把 query 变成向量(retrieval.py:717);没有模型直接抛 ModelNotFoundException
  2. 查向量库: 交给嵌入层的 embeddings.search_index(...)(retrieval.py:723)。它返回的每个元素是 [block_data, distance] 对——block_data 已经是回连好的真实 block(向量库存了 id,search_index 负责按 id 取回 block,见 03 章 embeddings.py:129)。
  3. 距离阈值裁剪: 只保留 distance < embedding_distance_threshold 的(retrieval.py:738-744)。默认阈值 self.semantic_distance_threshold = 1000(retrieval.py:156)——故意设得极高,等于默认不裁,注释写着 "basic shut off at such a high level"。想真正裁剪要自己传小阈值。命中的块顺手打上 distance(真实距离)、semantic="semantic" 标记、score=0.0
  4. 归一化: 同样喂进 _cursor_to_qr(retrieval.py:751)——于是语义结果和文本结果长得一模一样。

注意 score 与 distance 的分工: 语义结果里 score=0.0distance=真实向量距离;文本结果里两者都是 0.0。这让下游能区分"这条是语义来的还是文本来的",也给双通道合并留了钩子。

5.2 语义侧的过滤:两种做法

做法一 · 结果级 apply_custom_filter(retrieval.py:755): semantic_query 若传了 custom_filter,会在归一化前用它把结果再筛一遍——all(result.get(key)==value ...) 全等匹配(retrieval.py:760-762)。简单直接。

做法二 · 复用文本侧的二级过滤 —— semantic_query_with_document_filter(retrieval.py:765): 想按文档/页限定语义搜索时用它。它同样"嵌入→search_index→按距离裁",但默认 result_count=100 拉一大批(retrieval.py:766,注释:先多捞一些以抵消过滤损耗),然后把原始块交给 _cursor_to_qr_with_secondary_filter(retrieval.py:810)——和文本侧共用同一个二级过滤漏斗。这是"归一化"设计的红利:一个过滤器,文本语义两边都能用。

5.3 以块搜块:similar_blocks_embedding

similar_blocks_embedding(retrieval.py:817)是语义查询的变体:输入不是问题字符串,而是一个已有的 block,拿它的 block["text"] 去嵌入(retrieval.py:827),找语义相近的其它块。用于"找相似段落""去重""扩展阅读"。它的默认 embedding_distance_threshold=10(不是 1000),所以默认就会做实际裁剪。归一化时传的 query 是空串 ""(retrieval.py:858),因此 locate_query_match 直接返回空 matches(retrieval.py:1517-1518)。


6. 双通道:两条路一起走再合并

这节讲: hybrid 检索怎么用"两边都命中"这个信号重排结果。

6.1 dual_pass_query 的合并逻辑

dual_pass_query(retrieval.py:865)是本章工程含量最高的一支。它不重新发明取回,而是把 4 章和 5 章的成果拿来做交叉验证:

先各跑一遍(retrieval.py:886-896):

  • 文本:有 custom_filtertext_query_with_custom_filter,否则走 text_query
  • 语义:走 semantic_query(可带同一个 custom_filter)。

再按 _id 做 n×n 比对(retrieval.py:911-927):primary(默认 "text")那一列为主,逐条去另一列里找相同 _id,把结果分成三堆并打 match_status 标签:

分堆含义match_status
confirming_list两边都命中(最可信)matched
primary_only只有主列命中primary_only
secondary_only只有副列命中secondary_only

再拼装最终列表(retrieval.py:929-937):

merged = 全部 confirming + primary_only 前 5 条 + secondary_only 前 5 条

即:"文本和语义都投票的"全部排前面,然后各补最多 5 条独占的。核心直觉——两种截然不同的方法都认为相关的块,几乎一定真相关,所以给最高优先级。

一个前置保障: dual pass 靠 _id/doc_ID 做比对,所以方法开头会强制把这两个键补进输出键清单(retrieval.py:880-884),防止用户用 set_output_keys 把它们剔掉导致比对失效。

一个规模安全阀: n×n 比对是二次复杂度,safety_check=True 时若 result_count > 100 会打印警告并强制夹到 100(retrieval.py:871-878),因为"100×100"以上就吃不消了。

返回的完整 dict 还额外带 text_resultssemantic_results 两个原始列表(retrieval.py:948-952),方便调试对比。

6.2 两个轻量融合工具

除了 dual pass,还有两个"拿语义信号去加工一份已有 qr"的辅助:

  • augment_qr(retrieval.py:959): 拿一份已有 qr,用另一个查询词(query_topic)再跑一次语义(或文本)查询,把最多约 10 条新结果追加到原列表尾部(retrieval.py:973-980)。用于"扩充召回"。
  • apply_semantic_ranking(retrieval.py:984): 不改变集合,只重排顺序。它用 issue_semantic 跑一次语义查询,然后按语义结果的顺序重排原 qr——语义排在前的 _id 提到前面,剩下没匹配上的接在后面(retrieval.py:996-1006)。妙处在于:重排用的查询词可以和原查询不同(注释明确指出),于是能"按另一个维度的相关性"重新洗牌。

7. 结果后处理:窗口、时间、参考文献

这节讲: 拿到 qr 之后,交给生成前常做的三类加工。

7.1 上下文窗口扩展

单个命中块常常太短、缺上下文。expand_text_result_before(retrieval.py:1699)和 expand_text_result_after(retrieval.py:1721)解决这个:从命中块的 block_ID 出发,向前/向后逐块 block_lookup,拼接文本直到攒够 window_size(默认 400 字符)

block_lookup(retrieval.py:1368)按 {doc_ID, block_ID} 精确取一个块(retrieval.py:1374-1376)——这一步又回到了集合 DB:向量库只管找相似,拼上下文靠的是 block 在文档里天然的 block_ID 顺序(02 章埋下的编号)。

一个真实的坑: expand_text_result_before 的 while 循环(retrieval.py:1709-1715)在 before_block 为空时不递减 block_id、也不 break——如果中途某个 block_id 查不到,会卡住;而 expand_text_result_after 明确写了 if not after_block: break(retrieval.py:1733-1734)且每轮 block_id += 1。两个方向的健壮性并不对称。

7.2 时间过滤

filter_by_time_stamp(retrieval.py:1457)按文档入库时间裁 qr:读每条的 added_to_collection 时间戳,先试 Linux 格式解析、失败再试 Windows 格式(retrieval.py:1475-1483),再用 _time_window_filter(retrieval.py:1492)判断是否落在 [first_date, last_date] 区间(两端都可选)。用于"只要最近的资料"。

7.3 参考文献生成

bibliography_builder_from_qr(retrieval.py:1563)把一份 qr 汇成"每篇文档 → 引用了哪些页"的列表。它先去重收集涉及的文档(retrieval.py:1571-1576),再对每篇文档用 Counter 统计各页出现次数、按出现频次(most_common)排序页码(retrieval.py:1586-1590),产出 {文件名: [页码...]} 的列表(retrieval.py:1597)。这是给 05 章"带源引用"用的素材:回答里能标"出自 X 文档第 3、7 页"。


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

  • 一个归一化出口收编三种检索。 无论文本、语义还是双通道,原始命中最终都过 _cursor_to_qr(retrieval.py:647)打成同一份 qr。下游(05 章)因此完全不必关心结果从哪来——取回策略可插拔,而契约不变。这是整章设计的骨。
  • 过滤器一次写、两处用。 _cursor_to_qr_with_secondary_filter(retrieval.py:574)既服务文本侧的 custom filter,也服务语义侧的 semantic_query_with_document_filter(retrieval.py:810)。归一化让"文本和语义共用同一套二级过滤"成为可能。
  • DB 侧 + 应用侧双保险过滤。 复合过滤在数据库先做一遍,应用层再核对一遍(retrieval.py:602-611)——抹平 Mongo/Postgres/SQLite 三种后端过滤语义的差异。
  • 用"跨方法一致性"当排序信号。 dual pass 把"文本和语义都命中"作为最强相关性信号排最前(retrieval.py:929-937),而不是去调某个玄学权重。简单、可解释、鲁棒。
  • 距离阈值默认关掉。 语义阈值默认 1000(retrieval.py:156)等于不裁——把"精度/召回"的权衡显式交还给调用方,而非替用户暗中做决定。
  • 兜底优先于报错。 文档过滤认不出键时不抛异常,而是降级成无过滤查询(retrieval.py:461-470);set_output_keys 会强制补回必需键(retrieval.py:308-312)。取回宁可"多给且能用",不"精确但空手"。

9. 边界与局限(诚实说)

  • 语义查询依赖预先建好的嵌入。 库上没有嵌入记录时,构造函数直接把模式定成 text(retrieval.py:233-234);query(query_type="semantic") 也会在无模型时静默回退到文本查询(retrieval.py:414-417)——不会报错,但你以为在做语义、其实是文本。
  • 双通道是二次复杂度,硬上限 100。 n×n 比对(retrieval.py:911)使 dual_pass_query 在大 result_count 下退化,安全阀直接夹到 100(retrieval.py:872-878)。它明确不适合大候选集。
  • 合并是"截断式"而非真正加权融合。 dual pass 的独占结果各只取前 5 条(retrieval.py:933-937),没有 RRF、没有归一化打分融合——简单但粗。
  • locate_query_match 是朴素字符扫描(retrieval.py:1525-1546),对超长正文/超多查询词会慢;它算的是字面命中坐标,和语义相关性无关。
  • expand_text_result_before 的循环在缺块时可能不推进(见 7.1 的坑),前后两个方向健壮性不对称。
  • 本章不含"生成"。 Query 只负责取回和结果整形,从不调用 LLM 生成答案。把 qr 变成回答、做带源提示与事后核查,是 05 章的事。

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

主题文件:行符号名
Query 类入口 / 模式探测llmware/retrieval.py:116Query.__init__
加载嵌入模型llmware/retrieval.py:265load_embedding_model
输出键 读/写llmware/retrieval.py:292 / :298get_output_keys / set_output_keys
通用查询入口(text/semantic 分派)llmware/retrieval.py:392query
基础文本查询llmware/retrieval.py:421text_query
文本查询 + 文档过滤llmware/retrieval.py:441text_query_with_document_filter
文本查询 + 内容类型llmware/retrieval.py:480text_query_by_content_type
通用自定义过滤底座llmware/retrieval.py:540text_query_with_custom_filter
归一化漏斗(基础)llmware/retrieval.py:647_cursor_to_qr
归一化漏斗(带二级过滤)llmware/retrieval.py:574_cursor_to_qr_with_secondary_filter
语义查询主入口llmware/retrieval.py:706semantic_query
结果级自定义过滤llmware/retrieval.py:755apply_custom_filter
语义查询 + 文档过滤llmware/retrieval.py:765semantic_query_with_document_filter
以块搜块llmware/retrieval.py:817similar_blocks_embedding
双通道 hybrid 合并llmware/retrieval.py:865dual_pass_query
追加式语义扩充llmware/retrieval.py:959augment_qr
语义重排llmware/retrieval.py:984apply_semantic_ranking
字符级命中定位llmware/retrieval.py:1510locate_query_match
精确查询预处理llmware/retrieval.py:1550exact_query_prep
按 doc+block 取块(窗口/查块基石)llmware/retrieval.py:1368block_lookup
时间戳过滤llmware/retrieval.py:1457filter_by_time_stamp
参考文献生成llmware/retrieval.py:1563bibliography_builder_from_qr
上下文窗口扩展(前/后)llmware/retrieval.py:1699 / :1721expand_text_result_before / expand_text_result_after
DB 侧全文索引查询llmware/resources.py:121CollectionRetrieval.basic_query
向量库近邻检索llmware/embeddings.py:129EmbeddingHandler.search_index

相邻章节: 上游 01 库与存储02 解析与分块03 嵌入与向量库;下游 05 生成与证据;总览见 index