跳到主要内容

检索策略层:basic / RAG-Fusion / HyDE 与两级 RRF

30 秒导读: 底层三路检索解决了「一次查询怎么在数据库里搜」;这一章往上加一层——「查询侧」策略:要不要先把用户的问题改写成几个变体、或先让 LLM 编一段假答案,再拿它们各自去搜一遍,最后把多份排名融合成一份。这层的精华是两级 RRF:一级在 SQL 里融合语义+全文,一级在这里融合跨子查询的多次检索。


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

  • 一句话定义: 检索策略层是站在底层搜索之上的查询改写与结果融合层——它决定「用几条查询、怎么改写、搜完怎么合并」,而不关心「一条查询在数据库里具体怎么跑」。

  • 它解决什么问题: 用户问一句话,常常问法不好。同一个意思换个说法,能搜到完全不同(但同样相关)的文档。单条查询容易漏。于是有两个经典思路:

    • 多改写几次(RAG-Fusion):让 LLM 把问题换成几个近义问法,各搜一遍,再合并。
    • 先编个假答案(HyDE):让 LLM 先「假装」写出答案文档,再拿这段假文档去找真正相似的真文档——因为「答案」和「答案」在向量空间里往往比「问题」和「答案」更近。
  • 三种策略一览:

    策略 (search_strategy)一句话用几条查询
    vanilla / basic直接拿原查询搜一遍1 条(原查询)
    rag_fusionLLM 生成近义子查询,各搜再融合num_sub_queries
    hydeLLM 生成假设文档,各自嵌入并行搜再融合num_sub_queries
  • 一句话直觉: 把「搜索」想成问路。basic 是只问一个人;rag_fusion 是用几种不同问法问同一个人再综合;hyde 是先自己脑补一个「理想答案长啥样」,拿着这张画像去人群里找最像的。

本节不出现底层 SQL——那是第 01 章的事。


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

入口是 search(),它按 search_strategy 分派到三个私有方法之一。三者都返回同一种结果容器 AggregateSearchResult(chunk + graph,详见第 03 章)。

怎么读这张图: 从上往下是「一次 search() 调用」的分派;rag_fusion/hyde 都会多次下钻到底层搜索,再经第二级 RRF 汇合。

search(query, settings) retrieval_service.py:259
按 settings.search_strategy 分派

┌─────────────────┼──────────────────┐
▼ ▼ ▼
"vanilla"/其它 "rag_fusion" "hyde"
_basic_search _rag_fusion_search _hyde_search
:281 :326 :554
│ │ │
│ ① LLM 生成近义子查询 ① LLM 生成假设文档
│ _generate_similar_queries _run_hyde_generation
│ :405 :899
│ │ │
│ ② 每条子查询 = 一次 ② 每篇假文档嵌入后
│ _basic_search 并行 fanout 搜索
│ │ _fanout_chunk_and_graph_search:616
│ ▼ ▼
│ ┌──────────────── 第二级 RRF ───────────────┐
│ │ _reciprocal_rank_fusion_chunks :450 │
│ │ _reciprocal_rank_fusion_graphs :509 │ (k=60)
│ └──────────────────┬──────────────────────┘
│ │
│ ③ 按【原查询】做最终 arerank 重排
▼ ▼
┌──────────────────────────────────────────────┐
│ 每条底层搜索都走 _vector_search_logic (:651) │
│ 在 fulltext / semantic / hybrid 间选路 + 重排 │ ← 这里面才是第 01 章的 SQL
└──────────────────────────────────────────────┘


AggregateSearchResult

部件职责一句话:

部件干什么位置
searchsearch_strategy 分派到三策略之一retrieval_service.py:259
_basic_search嵌入→chunk 搜索→graph 搜索→聚合retrieval_service.py:281
_rag_fusion_search生成子查询→各搜→RRF→按原查询重排retrieval_service.py:326
_hyde_search生成假设文档→并行 fanout→融合→重排retrieval_service.py:554
_vector_search_logic在 fulltext/semantic/hybrid 间选路并 arerankretrieval_service.py:651
_reciprocal_rank_fusion_chunks跨子查询融合 chunk 排名(k=60)retrieval_service.py:450

3. 分派入口:一个 search(),三条路

search() 只做一件事——search_strategy,转小写,分派:

# retrieval_service.py:259 search()
strategy = search_settings.search_strategy.lower()
if strategy == "hyde":
return await self._hyde_search(query, search_settings)
elif strategy == "rag_fusion":
return await self._rag_fusion_search(query, search_settings)
else: # 'vanilla'、'basic',或任何其它值
return await self._basic_search(query, search_settings)

上面这段是真实分派逻辑。注意 else兜底:不认识的策略名一律走 _basic_search

一个小坑: SearchSettings.search_strategy 字段默认值是 "vanilla",它的 description 举例写的是 'query_fusion'(search.py:498),但真正被识别的字符串是 "rag_fusion"——写 "query_fusion" 会因不匹配而悄悄落到 _basic_search。以代码分派为准。

控制多查询数量的字段是 num_sub_queries,默认 5,只对 hyde / rag_fusion 生效(search.py:520)。


4. 核心策略逐个讲

4.1 _basic_search:一条查询的标准流程

这是最朴素、也是另外两条策略的积木。四步:

  1. 若开了语义或混合搜索,先把 query 嵌入成向量(一次,后面复用);
  2. chunk 搜索(走 _vector_search_logic);
  3. graph 搜索(走 _graph_search_logic,见第 03 章);
  4. 打包成 AggregateSearchResult
# retrieval_service.py:281 _basic_search() —— 只摘第 1、2 步
if search_settings.use_semantic_search or search_settings.use_hybrid_search:
query_vector = await self.providers.completion_embedding.async_get_embedding(text=query)
if search_settings.chunk_settings.enabled:
chunk_results = await self._vector_search_logic(
query_text=query,
search_settings=search_settings,
precomputed_vector=query_vector, # 把刚算好的向量传下去,避免重复嵌入
)

关键设计:向量只嵌入一次,通过 precomputed_vector 一路往下传——rag_fusion 里每条子查询、hyde 里每篇假文档,都靠这个参数避免重复调用嵌入模型。

4.2 _vector_search_logic:在三路之间选路

无论哪条策略,chunk 检索最终都汇到这个方法。它的职责是选路 + 重排,真正的 SQL 由第 01 章hybrid_search / full_text_search / semantic_search 执行。选路规则:

# retrieval_service.py:651 _vector_search_logic() —— 选路分支
if (search_settings.use_fulltext_search and search_settings.use_semantic_search) \
or search_settings.use_hybrid_search:
raw_results = await ...chunks_handler.hybrid_search(...) # 全文+语义 → 混合
elif search_settings.use_fulltext_search:
raw_results = await ...chunks_handler.full_text_search(...) # 只全文
elif search_settings.use_semantic_search:
raw_results = await ...chunks_handler.semantic_search(...) # 只语义
else:
raise ValueError("At least one of use_fulltext_search or use_semantic_search must be True")

选路口诀:「全文且语义」或「显式 hybrid」→ 混合;否则谁开走谁;都没开就报错。

选路之后,还有两步收尾:

  • 重排:对原始结果调 completion_embedding.arerank(query=query_text, ..., limit=...),按 limit 截断(:716)。
  • 回填:给每条结果的 metadata["associated_query"] 写上是哪条 query 搜出来的(:726)——这在 fusion 里很有用,能追溯一条 chunk 是哪个子查询命中的。

注意这里的 arerank单条查询内的重排;别和后面 fusion 里跨子查询的 RRF 混淆——那是两回事、两个层级。

4.3 _rag_fusion_search:多改写 + 融合

要解决的小问题: 用户一种问法可能漏掉相关文档。思路: 让 LLM 换几种问法,各搜一遍,再融合。

四步(retrieval_service.py:326):

  1. 凑齐子查询集合:sub_queries = [query](原查询始终保留,不丢用户本意),再让 LLM 生成 num_sub_queries - 1 条近义改写补进去。
  2. 逐条搜:对每条子查询调 _basic_search,把各自的 chunk 列表、graph 列表分别收集成「排名列表的列表」。
  3. 第二级 RRF 融合:_reciprocal_rank_fusion_chunks / _reciprocal_rank_fusion_graphs 把多份排名合成一份。
  4. 按原查询最终重排:融合后再用 arerank(query=query, ...)原始用户查询语义重排,截到 limit
# retrieval_service.py:326 _rag_fusion_search() —— 摘子查询与融合骨架
sub_queries = [query]
if search_settings.num_sub_queries > 1:
extra = await self._generate_similar_queries(
query=query, num_sub_queries=search_settings.num_sub_queries - 1)
sub_queries.extend(extra)
for sq in sub_queries: # 逐条子查询走完整 basic 搜索
aggr = await self._basic_search(sq, search_settings)
chunk_results_list.append(aggr.chunk_search_results)
graph_results_list.append(aggr.graph_search_results)
fused_chunk_results = self._reciprocal_rank_fusion_chunks(chunk_results_list) # ← 第二级 RRF

子查询怎么生成——_generate_similar_queries(:405):用 config.app.fast_llm(便宜快模型)、max_tokens=128temperature=0.8 让模型「每行一条改写」,再按换行切、取前 num_sub_queries 条:

# retrieval_service.py:405 _generate_similar_queries() —— 解析部分
lines = [line.strip() for line in raw_text.split("\n") if line.strip()]
return lines[:num_sub_queries]

4.4 _hyde_search:先编假答案,再找真文档

要解决的小问题: 「问题」的向量和「答案」的向量在嵌入空间里往往不够近思路(HyDE = Hypothetical Document Embeddings,假设文档嵌入): 先让 LLM 编几段「假装是答案」的文档,拿这些假文档的向量去搜,更容易命中真正的答案文档。

流程与 rag_fusion 形似,但两处关键不同——用假文档而非改写,且并行 fanout:

# retrieval_service.py:554 _hyde_search() —— 生成 + 并行 fanout
hyde_docs = await self._run_hyde_generation(
query=query, num_sub_queries=search_settings.num_sub_queries)
tasks = []
for hypothetical_text in hyde_docs:
tasks.append(asyncio.create_task(
self._fanout_chunk_and_graph_search(
user_text=query, # 原查询:用于文本侧 & 重排
alt_text=hypothetical_text, # 假文档:用于语义/混合的向量
search_settings=search_settings,
)))
results_list = await asyncio.gather(*tasks) # 并行等待全部

_fanout_chunk_and_graph_search(:616)是每篇假文档的搜索单元,它体现了 HyDE 的核心分工:

  • 假文档 alt_text 算嵌入向量 → 喂给语义/混合搜索(precomputed_vector=vec);
  • 原查询 user_text 作为 query_text → 用于全文匹配和后续重排。

即:「假文档」负责在向量空间里定位,「真问题」负责校准最终相关性。

假文档怎么生成——_run_hyde_generation(:899):从 prompt 库取名为 "hyde" 的模板(带 {message}{num_outputs} 占位),用 fast_llmmax_tokens=512temperature=0.7 生成,再按空行 \n\n 切成多篇:

# retrieval_service.py:899 _run_hyde_generation() —— 切分
raw_text = response.choices[0].message.content
return [chunk.strip() for chunk in (raw_text or "").split("\n\n") if chunk.strip()]

融合之后,HyDE 的最终重排 limit 放得更宽:limit * num_sub_queries(:598)——因为多篇假文档各自召回,候选量本就更大。

rag_fusion vs hyde 对比:

维度_rag_fusion_search_hyde_search
LLM 生成什么近义子查询(短)假设文档(长)
拿什么去嵌入每条子查询本身每篇假文档
原查询的角色也作为一条子查询参与检索不参与检索,只用于文本侧+最终重排
执行方式顺序 for 逐条 _basic_searchasyncio.gather 并行 fanout
生成方法_generate_similar_queries :405_run_hyde_generation :899
最终重排 limitsearch_settings.limitlimit * num_sub_queries

5. 精华:两级 RRF(Reciprocal Rank Fusion,倒数排名融合)

这是本章最值得带走的一点。R2R 里 RRF 出现在两个不同层级,很容易混淆,这里讲清。

RRF 是什么(一句话): 一种只看「排第几」、不看原始分数量纲的融合法。某条结果的融合分 = 它在每份排名里 1 / (k + 名次)累加。名次越靠前贡献越大;k(常取 60)是平滑常数,削弱头名的绝对统治、让中段也有话语权。

为什么用 RRF 而非直接加分数: 语义相似度、全文 ts_rank、不同子查询的分数量纲天差地别,直接相加没意义;而「名次」是可比的。

两级 RRF 的分工

┌─ 第一级 RRF:SQL 层内、单条查询内 ────────────────────────┐
│ 融合对象:同一条查询的【语义排名】 vs 【全文排名】 │
│ 地点:hybrid_search 的 SQL(第 01 章),带可配权重 │
│ 产物:这一条查询的一份混合排名 │
└──────────────────────────────────────────────────────────┘
│ 每条子查询/假文档各得一份排名

┌─ 第二级 RRF:查询层、跨子查询 ────────────────────────────┐
│ 融合对象:N 条子查询/假文档【各自的那份排名】 │
│ 地点:_reciprocal_rank_fusion_chunks/_graphs(本章),k=60│
│ 产物:一份跨查询的统一排名 → 再按原查询 arerank │
└──────────────────────────────────────────────────────────┘
维度第一级 RRF第二级 RRF
在哪SQL 内 hybrid_search(第 01 章)本章 retrieval_service.py:450 / :509
融合谁语义排名 × 全文排名(两路)N 条子查询各自的结果排名(多路)
触发时机每次混合搜索都发生rag_fusion / hyde
权重语义/全文可配加权无权重,等权累加
常数 kSQL 参数(见第 01 章)硬编码 k=60.0

第二级 RRF 的实现

_reciprocal_rank_fusion_chunks(retrieval_service.py:450)接收「排名列表的列表」,按 chunk id 累加分数:

# retrieval_service.py:450 _reciprocal_rank_fusion_chunks(k=60.0)
for ranking_list in list_of_rankings: # 每条子查询一份排名
for rank, chunk_result in enumerate(ranking_list, start=1): # 名次从 1 起
c_id = chunk_result.id
existing = score_map.get(str(c_id), 0.0)
score_map[str(c_id)] = existing + 1.0 / (k + rank) # ← RRF 核心式
chunk_map.setdefault(str(c_id), chunk_result) # 留一份对象引用备用
fused_items = sorted(score_map.items(), key=lambda x: x[1], reverse=True)
  • 去重靠 chunk_result.id:同一 chunk 被多条子查询命中 → 分数累加,自然置顶(这正是「多路投票」的意义)。
  • 融合完把累加分写回 chunk.score,按分降序,返回。

_reciprocal_rank_fusion_graphs(:509)逻辑一致,只是 graph 结果未必有稳定 id——它先试 g_result.content.id,取不到就退化成 hash(content.json()) 当键:

# retrieval_service.py:509 _reciprocal_rank_fusion_graphs() —— 造 key
if hasattr(g_result.content, "id"):
g_id = str(g_result.content.id)
else:
g_id = f"graph_{hash(g_result.content.json())}" # 无 id 时的兜底键

两个 RRF 的 k 都以默认参数 k: float = 60.0 写死在函数签名里(:451:510),融合调用处未传 k,即恒为 60。

融合之后还有一步:RRF 只按名次融合,不懂原始语义;所以两条策略最后都会再用 arerank(query=<原查询>, ...) 把融合结果按用户真正的问题重排一遍(rag_fusion 在 :382,hyde 在 :594),让最终顺序回到「和原问题最相关」。这是「先广撒网融合、再按初心收口」的收尾。


6. 边界与局限(诚实)

  • 多查询 = 多次 LLM + 多次检索:rag_fusion 顺序跑 N 次 _basic_search,延迟随 num_sub_queries 线性增长;hydeasyncio.gather 并行搜索缓解,但生成假文档那一次 LLM 调用仍是串行前置。
  • 策略名易踩坑:只有精确的 "hyde" / "rag_fusion" 被识别,其它一律兜底到 basic(:271)——包括字段 description 里误导性的 "query_fusion"
  • graph 融合的 key 兜底:无 id 时用 hash(content.json()),理论上不同内容可能碰撞(概率极低,:532)。
  • 依赖生成质量:子查询/假文档由 fast_llm 生成,便宜但可能产出偏离原意的改写,靠最后的原查询 arerank 兜底纠偏。

7. 同组其它章


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

主题文件路径符号名
策略分派入口py/core/main/services/retrieval_service.py:259search
单查询标准流程py/core/main/services/retrieval_service.py:281_basic_search
多改写 + 融合py/core/main/services/retrieval_service.py:326_rag_fusion_search
生成近义子查询py/core/main/services/retrieval_service.py:405_generate_similar_queries
第二级 RRF(chunk)py/core/main/services/retrieval_service.py:450_reciprocal_rank_fusion_chunks
第二级 RRF(graph)py/core/main/services/retrieval_service.py:509_reciprocal_rank_fusion_graphs
HyDE 主流程py/core/main/services/retrieval_service.py:554_hyde_search
单篇假文档 fanoutpy/core/main/services/retrieval_service.py:616_fanout_chunk_and_graph_search
三路选路 + 重排py/core/main/services/retrieval_service.py:651_vector_search_logic
生成假设文档py/core/main/services/retrieval_service.py:899_run_hyde_generation
策略/子查询数字段py/shared/abstractions/search.py:498 :520SearchSettings.search_strategy / num_sub_queries