跳到主要内容

混合检索与重排:并行向量+全文,再精排

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.runhybrid 分支,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 回写外层变量),然后建线程、startjoin

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 里,docDocument 对象、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),但精排思路不同:

实现文件怎么排特点
CohereRerankingrankings/cohere.py:10调 Cohere rerank API,拿 relevance_score 重排一次 API 调用排全部;无 key 时直接跳过返回原列表
LLMRerankingrankings/llm.py:23对每个 chunk 问 LLM"相关吗 YES/NO",过滤掉 NO过滤器不是打分器;并发调用;全被过滤空了就退回前 top_k
LLMTrulensScoringrankings/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)。
  • 同页表格扩展,由上层 DocumentRetrievalPipelineget_extra_table=True 时做(见 §6)。

这些扩展不改变"检索质量"的核心逻辑,是为下游展示/问答做的料。


5. 核心机制三:范围过滤(scope)——只在选中的文件里搜

这是 kotaemon 检索里最实用、也最容易被忽视的一环。UI 上用户勾选了哪几个文件,检索就只能在那几个文件的 chunk 里发生,不会串到别的文档。这既是正确性(别拿无关文档答题),也是权限/隔离。

5.1 从 doc_idschunk_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-139json.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-155pipelines.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_idsDocument 对象和 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_extendlibs/kotaemon/kotaemon/indices/vectorindex.py:134VectorRetrieval.run
hybrid 并行两线程libs/kotaemon/kotaemon/indices/vectorindex.py:193-225query_vectorstore / query_docstore
合并去重libs/kotaemon/kotaemon/indices/vectorindex.py:227-235(hybrid 分支 result 拼装)
取 top_klibs/kotaemon/kotaemon/indices/vectorindex.py:127_filter_docs
reranker 依次 runlibs/kotaemon/kotaemon/indices/vectorindex.py:240-247(rerankers 循环)
缩略图扩展libs/kotaemon/kotaemon/indices/vectorindex.py:250-301(thumbnail 段)
上层检索管线 / scopelibs/ktem/ktem/index/file/pipelines.py:82DocumentRetrievalPipeline
doc_ids→chunk_idslibs/ktem/ktem/index/file/pipelines.py:147-167DocumentRetrievalPipeline.run
MetadataFilters / mmrlibs/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-223generate_relevant_scores
reranker 接口libs/kotaemon/kotaemon/indices/rankings/base.py:8BaseReranking
Cohere 重排libs/kotaemon/kotaemon/indices/rankings/cohere.py:10CohereReranking
LLM YES/NO 过滤libs/kotaemon/kotaemon/indices/rankings/llm.py:23LLMReranking
LLM 0-10 打分libs/kotaemon/kotaemon/indices/rankings/llm_trulens.py:96LLMTrulensScoring

上一章: 02-indexing.md(库怎么建、Index 表怎么来) · 下一章: 04-qa-citation.md(把捞到的证据喂给 LLM,带引证作答) · 返回: index.md