数据截至 (上游 commit 9ad3e4e49aa3)
混合检索与重排:并行向量+全文,再精排
30 秒导读: 上一章 02-indexing.md 把文档切成 chunk、算好 embedding 存进了向量库和文档库。本章讲取:用户问一句话,kotaemon 怎么从库里捞出最相关的几段。核心手法是混合检索——同时跑"向量语义搜索"和"全文关键词搜索",合并去重,再用 reranker 精排,最后只留 top_k。以及一个容易被忽略但很关键的机制:范围过滤——检索永远只在用户勾选的那些文件里发生。
本章聚焦"为什么这样检索质量更高",不讲把捞到的证据喂给 LLM 生成答案(那是 04-qa-citation.md 的事)。
1. 这是什么(零基础也能懂)
先说要解决的问题
RAG(检索增强生成)的第一步永远是检索:用户问"公司 2023 年营收多少",系统得先从一堆 PDF 里找到讲营收的那几段,才能拿给大模型作答。找得准不准,直接决定答案对不对。检索是 RAG 质量的地基。
那"找得准"难在哪?难在两种"相关"是不同的东西:
| 相关类型 | 例子 | 靠什么找 |
|---|---|---|
| 语义相关(意思像) | 问"营收",文档写"total revenue / 销售额" | 向量搜索(embedding 相似度) |
| 字面相关(词一样) | 问"型号 XR-7",文档里正好有"XR-7" | 全文搜索(关键词匹配) |
- 向量搜索擅长"意思像但用词不同",但对精确的专有名词、编号、代码标识符常常失灵——因为 embedding 会把"XR-7"和"XR-8"压成很接近的向量。
- 全文搜索(关键词/BM25)擅长"词对上就命中",但你换个说法它就抓瞎。
一句话:两种搜索各有盲区,谁都不够。
kotaemon 的答案:混合检索
kotaemon 的做法很直接——两个都跑,然后合起来。这就是"hybrid(混合)"模式:向量搜索捞一批,全文搜索捞一批,去掉重复,交给后面的精排。这样"意思像"和"词一样"的候选都在池子里,召回更全,质量的天花板更高。
一句话直觉: 就像找一本书,你既翻目录(按主题/语义找),又用书末 的索引(按精确的词找)——两条路的结果并起来,漏网的概率最低。
三种检索模式
kotaemon 把检索模式做成一个可选项 retrieval_mode,默认 hybrid:
| 模式 | 干什么 | 适合 |
|---|---|---|
vector | 只做向量语义搜索 | 纯语义问答 |
text | 只做全文关键词搜索 | 精确术语/编号查找 |
hybrid | 两者并行 + 合并(默认) | 通用场景,质量最好 |
用户在 UI 上就能切换这个模式(见 DocumentRetrievalPipeline.get_user_settings 里的 retrieval_mode 下拉,libs/ktem/ktem/index/file/pipelines.py:250-255)。
2. 顶层全景(它大概怎么转)
一次检索从"用户勾了几个文件 + 输入一句话"到"返回精排后的 chunk 列表",经过两层:
- 上层
DocumentRetrievalPipeline(在 ktem)——负责范围:把用户勾选的文件 id 翻译成"允许搜的 chunk id",再决定要不要重排、要不要补同页表格、要不要 LLM 打分。 - 下层
VectorRetrieval(在 kotaemon 内核)——负责真正检索:按retrieval_mode跑向量/全文/混合,合并去重,过 reranker,取 top_k,再做缩略图/表格扩展。
怎么读下面这张图:从上到下是一次检索的时间顺序,左边是"限定范围",中间是"混合检索",右边是"精排收尾"。
用户: 勾选文件 doc_ids=[A,B] + query="2023 营收"
│
▼
┌─────────────────────────────────────────────────────────┐
│ DocumentRetrievalPipeline.run (ktem/pipelines.py:115) │
│ │
│ ① doc_ids ──查 Index 表──▶ chunk_ids (scope=允许搜的块) │
│ ② 组 MetadataFilters(file_id IN doc_ids) + do_extend │
│ ③ 可选: mmr / get_extra_table / llm_scorer │
└──────────────────────────┬───────────────────────────────┘
│ scope=chunk_ids, top_k
▼
┌─────────────────────────────────────────────────────────┐
│ VectorRetrieval.run (kotaemon/vectorindex.py:134) │
│ │
│ retrieval_mode == "hybrid": │
│ ┌─ Thread A: query_vectorstore ─┐ (向量相似度) │
│ │ │ 并行跑 │
│ └─ Thread B: query_docstore ────┘ (全文/BM25) │
│ │ │
│ ▼ 合并 + 去重(去掉两边重复的 id) │
│ rerankers 依次 run ──▶ 精排/过滤 │
│ ▼ │
│ _filter_docs 取 top_k │
│ ▼ │
│ thumbnail / table 扩展 │
└──────────────────────────┬───────────────────────────────┘
▼
list[RetrievedDocument] → 交给 04 章做问答
部件职责一句话:
| 部件 | 干什么 | 在哪 |
|---|---|---|
DocumentRetrievalPipeline | 定范围、配开关、补表格、生成 UI 相关分 | libs/ktem/ktem/index/file/pipelines.py:82 |
VectorRetrieval | 三态检索 + 混合并行 + 合并去重 + 精排 | libs/kotaemon/kotaemon/indices/vectorindex.py:116 |
Index 表 | 记录 文件 → chunk 的映射,用来把范围锁定到选中文件 | pipelines.py:148-153 |
| rerankers | 精排:Cohere / LLM 二分 / LLM Trulens 打分 | libs/kotaemon/kotaemon/indices/rankings/ |
3. 核心机制一:混合检索的并行与合并
这是本章的心脏,在 VectorRetrieval.run 的 hybrid 分支,vectorindex.py:186-238。
3.1 它要解决的小问题
既然要"向量搜索 + 全文搜索都跑",最笨的办法是先跑完一个再跑另一个。但这两次搜索互不依赖——一个查向量库,一个查文档库,完全可以同时进行。串行做等于白等一次网络往返。kotaemon 用两个线程把它们并行起来。
3.2 思路:两个线程,各查各的库
kotaemon 定义两个内部函数,分别用 threading.Thread 跑,start() 后 join() 等两个都完成:
query_vectorstore()——拿 query 的 embedding 去向量库做相似度 搜索,得到一批(id, score),再回文档库取回文本。query_docstore()——直接拿 query 的文本去文档库做全文搜索(Elasticsearch 走 BM25,LanceDB 走 FTS)。
下面这段简化演示(非源码)把骨架抽出来:
# 示意,非源码:hybrid 模式的并行骨架
emb = self.embedding(text)[0].embedding # query 的向量,只算一次
def query_vectorstore(): # 线程 A:语义搜索
_, vs_scores, vs_ids = self.vector_store.query(
embedding=emb, top_k=top_k_first_round, doc_ids=scope)
vs_docs = self.doc_store.get(vs_ids) # 拿 id 回文档库取文本
def query_docstore(): # 线程 B:全文搜索
query = text.text if isinstance(text, Document) else text
ds_docs = self.doc_store.query(query, top_k=top_k_first_round, doc_ids=scope)
a = threading.Thread(target=query_vectorstore)
b = threading.Thread(target=query_docstore)
a.start(); b.start() # 同时开跑
a.join(); b.join() # 两个都完成才继续
重点看: 两条线程都带着 doc_ids=scope 去查——范围过滤在两路都生效(见 §5)。因为查询主要是 I/O 等待(向量库/ES 都在库那头算),Python 的线程即便有 GIL 也能真正省时间。
真实源码:vectorindex.py:193-225 定义两个 query_vectorstore/query_docstore 闭包(用 nonlocal 回写外层变量),然后建线程、start、join。
3.3 合并 + 去重:全文结果里剔掉向量已有的
两路捞回来后要合成一个列表。关键是去重——同一个 chunk 可能既被向量搜索命中、又被全文搜索命中,不能算两次。kotaemon 的去重规则很简单:以向量结果为准,全文结果里凡是 id 已经在向量命中里的,丢掉。
# 示意,非源码:合并去重
result = [
RetrievedDocument(**doc.to_dict(), score=-1.0) # 全文命中,给哨兵分 -1.0
for doc in ds_docs
if doc not in vs_ids # 剔掉向量已经有的
]
result += [
RetrievedDocument(**doc.to_dict(), score=score) # 向量命中,带真实相似度分
for doc, score in zip(vs_docs, vs_scores)
]
真实源码:vectorindex.py:227-235。两个细节值得记:
- 全文命中的分数被写死成
-1.0(score=-1.0)。全文搜索给的是 BM25 分,和向量相似度分不在一个量纲,直接混排没意义,所以 kotaemon 干脆给它一个哨兵分,把"排名"这件事完全交给后面的 reranker。这解释了为什么混合模式几乎总要配 reranker。 - 去重那行
if doc not in vs_ids里,doc是Document对象、vs_ids是 id 字符串列表 —— 这个in判断实际是拿对象和字符串比,恒为"不在"(inferred:代码看起来意图是"按 id 去重",但比较的两边类型不一致,实际很难命中,去重形同没做,靠后面 reranker 兜底)。记住这是"意图上的去重"即可。
3.4 为什么混合质量更好(小结)
把上面串起来,混合检索赢在召回互补:
- 语义近但换了说法的段落 → 向量线程捞到;
- 精确术语/编号 → 全文线程捞到;
- 两批并起来给 reranker,候选池更大,真正相关的段落更不容易在第一步就被漏掉。
检索质量的常见失败是"该找的没找回来"(召回缺失),混合正是冲着这个去的。
4. 核心机制二:reranker 精排 与 _filter_docs 取 top_k
合并出来的候选池是"召回大但排序糙"的(还带着 -1.0 哨兵分)。精排这一步负责把它排好、砍到 top_k。
4.1 rerankers 依次 run
VectorRetrieval 持有一个 reranker 列表 rerankers,检索出候选后逐个套用:
# 示意,非源码
if self.rerankers and text:
for reranker in self.rerankers:
if isinstance(reranker, LLMReranking): # LLM 类先砍到 top_k 再打分,省调用
result = self._filter_docs(result, top_k=top_k)
result = reranker.run(documents=result, query=text)
result = self._filter_docs(result, top_k=top_k) # 最后统一取前 top_k
真实源码:vectorindex.py:240-247。_filter_docs 极简——就是切片 documents[:top_k](vectorindex.py:127-132)。注意 reranker 返回的顺序就是新排名,所以最后 [:top_k] 取的是"精排后最相关的前 k 个"。
对 LLMReranking 这一类的预先截断是个成本优化:LLM 精排每个 chunk 都要一次模型调用,先砍到 top_k 再打分,避免对几十个候选逐一调 LLM。
4.2 三种 reranker 实现
reranker 都实现同一个接口 BaseReranking.run(documents, query) -> list[Document](rankings/base.py:8-13),但精排思路不同:
| 实现 | 文件 | 怎么排 | 特点 |
|---|---|---|---|
CohereReranking | rankings/cohere.py:10 | 调 Cohere rerank API,拿 relevance_score 重排 | 一次 API 调用排全部;无 key 时直接跳过返回原列表 |
LLMReranking | rankings/llm.py:23 | 对每个 chunk 问 LLM"相关吗 YES/NO",过滤掉 NO | 是过滤器不是打分器;并发调用;全被过滤空了就退回前 top_k |
LLMTrulensScoring | rankings/llm_trulens.py:96 | 让 LLM 打 0-10 分,归一化后降序排 | 分数细腻;超长 chunk 先 trim_func 截到 7500 token |
三者可组合。默认配置里 rerankers 常放一个模型 reranker,llm_scorer 另放一个 LLMTrulensScoring 专门给 UI 生成相关分(见 §6)。
LLMReranking 的核心 prompt 很能说明它的"过滤"本质:
Given the following question and context,
return YES if the context is relevant to the question and NO if it isn't.
(rankings/llm.py:12-20,配 langchain 的 BooleanOutputParser 解析 YES/NO。)
LLMTrulensScoring 则要 LLM 输出一个数字,用正则 re_0_10_rating 抠出 0-10 的整数(遇到"8 out of 10"这种取最小值,llm_trulens.py:61-93),再 /10 归一化排序。
4.3 thumbnail / table 扩展(收尾附加)
精排取完 top_k 后,run 还做两类"扩展",让结果更适合展示和作答:
- 缩略图(thumbnail)扩展,
vectorindex.py:250-297:有些 chunk 的 metadata 带thumbnail_doc_id(指向该页的整页截图)。检索命中文本块时,顺带把对应的整页缩略图取回来,并把文本块的内容/分数拷到缩略图 doc 上——这样 UI 能显示原页图像,而 LLM 相关分仍基于文本(注释见vectorindex.py:252-253)。若最后正文全空,就退回原始缩略图(vectorindex.py:299-301)。 - 同页表格扩展,由上层
DocumentRetrievalPipeline在get_extra_table=True时做(见 §6)。
这些扩展不改变"检索质量"的核心逻辑,是为下游展示/问答做的料。
5. 核心机制三: 范围过滤(scope)——只在选中的文件里搜
这是 kotaemon 检索里最实用、也最容易被忽视的一环。UI 上用户勾选了哪几个文件,检索就只能在那几个文件的 chunk 里发生,不会串到别的文档。这既是正确性(别拿无关文档答题),也是权限/隔离。
5.1 从 doc_ids 到 chunk_ids 的三级翻译
范围过滤在 DocumentRetrievalPipeline.run(pipelines.py:115-167)完成,链路是:
用户勾选的文件 id (doc_ids)
│ ① 展平:有的项是 JSON 数组字符串 "[id1,id2]",json.loads 拆开
▼
doc_ids (扁平文件 id 列表)
│ ② 查 Index 表:relation_type=="document" 且 source_id IN doc_ids
▼
chunk_ids ── 作为 scope 传下去 ──▶ VectorRetrieval 两路检索都带上它
- ① 展平:
doc_ids里可能混着"一组 id 打包成 JSON 字符串"的项,pipelines.py:129-139用json.loads拆平。空则直接返回空、跳过检索(pipelines.py:142-144)。 - ② 文件→chunk:查
Index表,取出这些文件对应的所有 chunk 的target_id,得到chunk_ids(pipelines.py:147-153)。这张Index表就是上一章索引时写下的"文件 ↔ chunk"关系(见 02-indexing.md)。 - ③ 下传 scope:
retrieval_kwargs["scope"] = chunk_ids(pipelines.py:157)。到了VectorRetrieval,scope被同时喂给向量库查询(doc_ids=scope)和文档库查询(doc_ids=scope),两路都被锁在这批 chunk 内(vectorindex.py:199-200, 213-216)。
5.2 双保险:MetadataFilters
除了 scope,上层还额外挂了一个 MetadataFilters,按 chunk 的 file_id 元数据过滤(pipelines.py:158-167):
# 示意,非源码
retrieval_kwargs["filters"] = MetadataFilters(
filters=[MetadataFilter(key="file_id", value=doc_ids, operator=FilterOperator.IN)],
condition=FilterCondition.OR,
)
scope(按 chunk id 限定)和 filters(按 metadata 限定)一起作用,给向量库两道范围保险。
5.3 do_extend:先多捞,给精排留余量
范围定好后,上层还开了 do_extend=True(pipelines.py:156)。它的作用在 VectorRetrieval 里(vectorindex.py:149-155):第一轮召回不是只取 top_k,而是 top_k × first_round_top_k_mult(默认 ×10)。
思路:精排要排得准,得先有足够多的候选。第一轮先捞 top_k 的 10 倍,让 reranker 在一个更大的池子里挑,最后才砍回 top_k。先广召回、再精排收窄——这是混合检索质量高的另一半原因。
5.4 其它可选开关
DocumentRetrievalPipeline 上还有几个由 UI 控制的开关:
| 开关 | 作用 | 源码 |
|---|---|---|
mmr | 开启后传 VectorStoreQueryMode.MMR + mmr_threshold=0.5,让结果更多样(减少几段几乎重复的 chunk) | pipelines.py:169-172 |
get_extra_table | 命中含 page_label 的块后,按"同文件同页"再查一轮,把同页的表格补进结果(表格常被切散,补回来才完整) | pipelines.py:180-213 |
llm_scorer | 一个 LLMTrulensScoring,不参与排序,专门给最终结果打 0-10 相关分供 UI 展示(generate_relevant_scores) | pipelines.py:99, 215-223 |
get_extra_table 的逻辑:收集命中块的 (file_name, page_label),构造 $and/$or 元数据查询,再用 vector_retrieval(text="", top_k=50, where=...) 捞同页所有块,去重后追加(pipelines.py:184-213)。这修的是"一张表被切成好几块、检索只命中其中一块"的常见 RAG 坑。
6. 巧妙之处(可带走的技术)
- 并行 I/O 检索:向量库和文档库两次查询互不依赖,用
threading.Thread并行,省掉一次串行等待。查询是 I/O bound,GIL 不碍事。vectorindex.py:218-225。 - 哨兵分 + 交给 reranker:全文命中给
score=-1.0,不试图把 BM25 分和向量分强行归一,而是把排序职责全甩给 reranker。跨量纲的分数别硬混,交给统一的精排器。vectorindex.py:227-231。 - 先广后窄(do_extend ×10):第一轮召回放大 10 倍,精排在大池子里挑再收窄到 top_k。召回和精度分两步走。
vectorindex.py:152-155、pipelines.py:156。 - 范围锁定到 chunk 级:
doc_ids → Index 表 → chunk_ids三级翻译 +MetadataFilters双保险,检索严格困在选中文件内。pipelines.py:147-167。 - LLM reranker 预截断省钱:
LLMReranking类每 chunk 一次调用,精排前先_filter_docs砍到 top_k 再打分。vectorindex.py:242-244。 - 同页表格回补:修"表被切散只命中一块"的坑,按同页把表格补全。
pipelines.py:180-213。
7. 边界与局限(诚实说)
text模式必须有 scope 才出结果:纯全文分支只有if scope:时才查文档库,否则docs=[]返回空(vectorindex.py:178-185)。在 ktem 全流程里 scope 恒有(来自选中文件),但脱离 ktem 直接用VectorRetrieval且不传 scope,text模式会一无所获。- hybrid 去重疑似失效:合并时
if doc not in vs_ids拿Document对象和 id 字符串比较,类型不一致,去重实际很可能不 生效(inferred,见 §3.3)。后果是同一 chunk 可能重复进候选,靠后面 reranker/_filter_docs兜底,不是致命但不优雅。 - doc_store 是硬依赖:没有
doc_store直接抛错(vectorindex.py:157-161)——混合和全文都要靠它做文本检索/取回文本。 - 全文能力取决于后端:
text/hybrid的全文质量取决于 docstore 实现——Elasticsearch 是 BM25(elasticsearch.py:124-141),LanceDB 是 FTS(lancedb.py:62-84);换成纯内存 docstore 时全文检索能力会弱很多。 - reranker 无 key 就静默跳过:
CohereReranking没配 API key 时直接return documents不重排(cohere.py:41-43),此时混合结果的排序几乎只由向量分决定,全文那批(-1.0)会沉底。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 三态检索入口 / top_k / do_extend | libs/kotaemon/kotaemon/indices/vectorindex.py:134 | VectorRetrieval.run |
| hybrid 并行两线程 | libs/kotaemon/kotaemon/indices/vectorindex.py:193-225 | query_vectorstore / query_docstore |
| 合并去重 | libs/kotaemon/kotaemon/indices/vectorindex.py:227-235 | (hybrid 分支 result 拼装) |
| 取 top_k | libs/kotaemon/kotaemon/indices/vectorindex.py:127 | _filter_docs |
| reranker 依次 run | libs/kotaemon/kotaemon/indices/vectorindex.py:240-247 | (rerankers 循环) |
| 缩略图扩展 | libs/kotaemon/kotaemon/indices/vectorindex.py:250-301 | (thumbnail 段) |
| 上层检索管线 / scope | libs/ktem/ktem/index/file/pipelines.py:82 | DocumentRetrievalPipeline |
| doc_ids→chunk_ids | libs/ktem/ktem/index/file/pipelines.py:147-167 | DocumentRetrievalPipeline.run |
| MetadataFilters / mmr | libs/ktem/ktem/index/file/pipelines.py:158-172 | (retrieval_kwargs) |
| 同页表格扩展 | libs/ktem/ktem/index/file/pipelines.py:180-213 | (get_extra_table 段) |
| UI 相关分 | libs/ktem/ktem/index/file/pipelines.py:215-223 | generate_relevant_scores |
| reranker 接口 | libs/kotaemon/kotaemon/indices/rankings/base.py:8 | BaseReranking |
| Cohere 重排 | libs/kotaemon/kotaemon/indices/rankings/cohere.py:10 | CohereReranking |
| LLM YES/NO 过滤 | libs/kotaemon/kotaemon/indices/rankings/llm.py:23 | LLMReranking |
| LLM 0-10 打分 | libs/kotaemon/kotaemon/indices/rankings/llm_trulens.py:96 | LLMTrulensScoring |
上一章: 02-indexing.md(库怎么建、Index 表怎么来) · 下一章: 04-qa-citation.md(把捞到的证据喂给 LLM,带引证作答) · 返回: index.md