问法不同,结果截然不同
向量检索有一个容易被忽视的脆弱性:同一个问题换个说法,检索结果可能完全不同。
"BGE 模型在中文上效果怎么样" 和 "中文 Embedding 推荐哪个" 语义高度相似,但它们的 embedding 向量在高维空间中的位置可能相差不少,导致检索到的文档集合差异很大。
这是 Bi-Encoder 的结构性问题:embedding 是在 query 和 doc 互不知情的情况下各自计算的,对措辞的微妙变化非常敏感。
上一篇我们优化了文档侧 ------更好的分块策略让文档更容易被找到。本篇从问题侧入手:在把问题送进向量库之前,先对问题本身做一些处理,让它的召回效果更稳定、更全面。
三种策略:
- Multi-Query:生成多个问法,多角度检索后合并
- HyDE:先生成假设答案,用答案去检索
- Query Decomposition:把复杂问题拆成多个子问题
Multi-Query:多角度问法扩大召回面
核心思路
一个问题对应向量空间中的一个点,这个点可能恰好和某些相关文档距离较远。如果能从多个方向逼近,就能覆盖更大的区域。
css
原始问题 → LLM 改写 → [问法1, 问法2, 问法3]
↓
分别检索,合并去重
↓
取前 TOP-K 返回
代码实现
python
from langchain_classic.retrievers import MultiQueryRetriever
MULTI_QUERY_PROMPT = ChatPromptTemplate.from_messages([
("system", "你是一个专业的问题改写助手。"),
("human",
"请将以下问题改写为 3 个不同的表达方式,从不同角度提问,"
"以便在向量数据库中检索到更多相关内容。\n"
"每行输出一个问题,不要编号,不要解释。\n\n"
"原始问题:{question}"),
])
# 方式一:使用 LangChain 内置封装
retriever = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
llm=llm,
)
# 方式二:手动实现,可以控制 Prompt 和合并逻辑
multi_query_chain = MULTI_QUERY_PROMPT | llm | StrOutputParser()
variants_text = multi_query_chain.invoke({"question": question})
variants = [q.strip() for q in variants_text.strip().split("\n") if q.strip()]
all_docs = base_retriever.invoke(question) # 原始问题
for variant in variants:
all_docs.extend(base_retriever.invoke(variant))
return dedup_docs(all_docs)[:TOP_K]
适用场景: 问法多样、用户可能用不同术语描述同一概念的场景。代价是每次查询多调用 3 次检索(LLM 改写 + 3 次向量检索)。
HyDE:用假设答案去检索
核心思路
HyDE(Hypothetical Document Embeddings,假设文档嵌入)是 2022 年提出的方法,它的出发点是:
问题的 embedding 和答案的 embedding 天生处于不同的语义空间。
向量库里存的是文档(答案空间),但检索时用的是 query(问题空间),两者的 embedding 分布本来就不完全重叠。
HyDE 的解法:让 LLM 先生成一段假设性的答案,用这个假设答案的 embedding 去检索------假设答案和真实答案处于同一个语义空间,比问题更接近文档。
markdown
query → LLM 生成假设答案(~100字)→ 假设答案 embedding
↓
向量检索(找最近的文档)
↓
返回真实文档给 LLM
假设答案不需要准确,只需要语义上接近真实答案,让 embedding 落在正确的区域就够了。
代码实现
python
HYDE_PROMPT = ChatPromptTemplate.from_messages([
("system", "你是一个技术知识助手。"),
("human",
"请为以下问题写一段假设性的回答,约 100 字。"
"这段回答将用于向量检索,不需要完全准确,"
"只需要在语义上与真实答案接近。\n\n"
"问题:{question}"),
])
hyde_chain = HYDE_PROMPT | llm | StrOutputParser()
hypothetical_answer = hyde_chain.invoke({"question": question})
# 用假设答案的 embedding 检索
hyp_embedding = embeddings.embed_query(hypothetical_answer)
results = vectorstore.similarity_search_by_vector(hyp_embedding, k=TOP_K)
适用场景: 问题措辞和文档措辞差异较大的场景,比如用户用口语提问、文档是技术语言写的。代价是额外一次 LLM 调用 + 一次 embedding 计算。
Query Decomposition:拆解复杂问题
核心思路
有些问题天然是多跳的:
"RAG 系统在中文场景下,应该选什么 Embedding 模型和向量数据库?"
这个问题包含两个独立的子问题:
- 中文场景推荐哪个 Embedding 模型?
- 企业级应用推荐哪个向量数据库?
用一次检索试图同时覆盖两个问题,往往两个都覆盖得不完整。
Query Decomposition 的思路:先拆,再各自检索,最后把所有检索结果合并给 LLM 一起回答。
css
复杂问题 → LLM 拆解 → [子问题1, 子问题2, 子问题3]
↓
分别检索,合并去重
↓
所有子问题的文档汇总给 LLM
代码实现
python
DECOMPOSE_PROMPT = ChatPromptTemplate.from_messages([
("system", "你是一个问题分析助手。"),
("human",
"请将以下复杂问题拆分为 2-3 个简单的子问题,"
"每个子问题可以独立检索。\n"
"每行输出一个子问题,不要编号,不要解释。\n\n"
"原始问题:{question}"),
])
decompose_chain = DECOMPOSE_PROMPT | llm | StrOutputParser()
sub_questions_text = decompose_chain.invoke({"question": question})
sub_questions = [q.strip() for q in sub_questions_text.strip().split("\n") if q.strip()]
all_docs = []
for sub_q in sub_questions:
all_docs.extend(base_retriever.invoke(sub_q))
return dedup_docs(all_docs)[:TOP_K]
适用场景: 涉及多个概念或跨主题的复杂问题。代价是多次 LLM 调用(拆解 + N 次检索)。
实验结果
diff
==================================================================================
RAGAS 指标对比(四种查询优化策略)
==================================================================================
指标 Naive Multi-Query HyDE Decomposed
────────────────────────────────────────────────────────────────
context_recall 0.625 0.625 0.750 0.875 ◀
context_precision 0.583 0.583 0.726 ◀ 0.590
faithfulness 0.833 0.883 0.946 ◀ 0.911
answer_relevancy 0.406 0.412 0.377 0.474 ◀
==================================================================================
数字解读:
Multi-Query(context_recall = 0.625,与 Naive 持平)
出乎意料------在这个知识库上,改写问法并没有带来召回提升。原因在于:知识库只有 8 篇文档,向量检索本身就很准,改写出来的 3 个变体和原始问题检索到的是同一批文档,合并去重后没有增量。Multi-Query 的价值在大型知识库上会更加明显,那时候不同问法能触达向量空间的不同区域。
HyDE(context_recall = 0.750,+0.125;context_precision = 0.726,+0.143)
两个指标都有改善,且 faithfulness 也跳到了 0.946------是四种策略里最高的。假设答案的 embedding 确实落在了更接近文档的语义空间,不仅找到了更多相关文档,而且排序质量也更好。
Query Decomposition(context_recall = 0.875,+0.250)
召回率提升最显著。拆解问题后,每个子问题单独检索,覆盖了原始问题一次检索无法触及的文档。最终合并的文档集合更全面,faithfulness 也相应提升到 0.911。
三种策略的本质差异
| Multi-Query | HyDE | Query Decomposition | |
|---|---|---|---|
| 解决的问题 | 措辞敏感、问法单一 | 问答语义空间不匹配 | 多跳/多概念问题 |
| 改造对象 | 问题措辞(多角度) | 问题形态(问→答) | 问题结构(整→部分) |
| 额外 LLM 调用 | 1次(改写) | 1次(生成假设答案) | 1次(拆解) |
| 额外检索次数 | 3次 | 0次 | 2-3次 |
| 本实验最优指标 | --- | context_precision、faithfulness | context_recall |
| 最适合的场景 | 大知识库、口语化查询 | 专业文档、问答风格差异大 | 多概念、需要综合多篇文档 |
核心区别在于变换维度:
- Multi-Query 在"如何问"上做文章------同一个意思,多种说法
- HyDE 在"用什么检索"上做文章------用答案代替问题
- Query Decomposition 在"问什么"上做文章------把一个大问题拆成几个小问题
可以叠加使用
这三种策略并不互斥,可以根据场景组合:
python
# 示例:HyDE + Multi-Query 叠加
# 1. 生成假设答案 embedding
hyp_embedding = embeddings.embed_query(hyde_answer)
# 2. 同时对改写后的 3 个问题检索
all_docs = vectorstore.similarity_search_by_vector(hyp_embedding, k=4)
for variant in multi_query_variants:
all_docs.extend(base_retriever.invoke(variant))
return dedup_docs(all_docs)[:TOP_K]
叠加后召回面更大,但 API 调用次数也随之增加。生产环境里建议根据查询类型自适应选择策略,而不是无差别叠加。
完整代码
代码已开源:
核心文件:
query_optimization.py--- 四种查询优化策略的完整对比实验
运行方式:
bash
git clone https://github.com/chendongqi/llm-in-action
cd 13-query-optimization
cp .env.example .env
pip install -r requirements.txt
python query_optimization.py
小结
本文对比了三种查询优化策略的效果:
- Multi-Query:对措辞多样性有帮助,在大知识库上价值更突出;本实验因知识库较小效果不明显
- HyDE:用假设答案替代问题做检索,跨越了问答语义空间的鸿沟,context_precision 和 faithfulness 均有明显提升
- Query Decomposition:拆解复杂问题,多路检索后合并,context_recall 提升最大(+0.250)
这三种优化都在问题进入向量库之前完成,不需要改动分块策略、不需要调整 Embedding 模型,是 RAG 系统里成本最低的一类优化。