数据截至 (上游 commit 081bc084a5bc)
检索引擎:文本、语义与双通道查询
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 家族 | 嵌入问题→查向量库→回连 block | retrieval.py:706 |
dual_pass_query | 文本+语义各跑一遍,交叉比对合并 | retrieval.py:865 |
_cursor_to_qr | 归一化漏斗:把任意来源的原始块打成统一 qr | retrieval.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_keys | text, file_source, page_num, score, distance, matches |
| 最小必需 | query_result_min_required_keys | text, 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_ID 或 file_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_query、table_query、text_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。