万字长文Query改写与多路召回实战|从HyDE到RRF融合,召回率提升22%的完整方案

一、Query 改写:让机器"读懂"用户的言外之意

Query 改写的本质,是在用户意图和检索系统之间搭一座桥。用户的原始 query 往往是口语化、不完整的,而检索系统需要的是结构化、语义明确的"搜索语言"。

1.1 多查询改写(Multi-Query Rewriting)

最直观的思路:一个 query 不够,那就生成多个。

LangChain 的 MultiQueryRetriever 就是这个思路的官方实现。核心逻辑很简单------用 LLM 把用户的原始问题改写成 N 个不同表述的查询,然后分别检索,最后合并结果。

python 复制代码
import os
from typing import List
from langchain_core.documents import Document
from langchain_openai import ChatOpenAI
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS

# 设置OpenAI API(从环境变量读取,请先设置 OPENAI_API_KEY 和 OPENAI_API_BASE)
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "you-deepseek-api-key")
os.environ["OPENAI_API_BASE"] = os.getenv("OPENAI_API_BASE", "https://api.deepseek.com")

# 示例文档数据
docs = [
    Document(page_content="RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。", metadata={"doc_id": "1"}),
    Document(page_content="幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。", metadata={"doc_id": "2"}),
    Document(page_content="BM25是一种基于词频和文档频率的稀疏检索算法,擅长精确关键词匹配。", metadata={"doc_id": "3"}),
    Document(page_content="向量检索使用Embedding模型将文本转换为高维向量,通过向量相似度进行语义匹配。", metadata={"doc_id": "4"}),
    Document(page_content="HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。", metadata={"doc_id": "5"}),
]

# 创建向量存储
embeddings = HuggingFaceEmbeddings(model_name=r"E:\LLM Project\Local Knowledge Base Q&A System\models\Xorbits\bge-large-zh-v1.5")
vector_store = FAISS.from_documents(docs, embeddings)
base_retriever = vector_store.as_retriever(search_kwargs={"k": 3})

print(f"已创建向量存储,包含 {len(docs)} 个文档")
python 复制代码
# 多查询改写示例
from langchain_classic.retrievers.multi_query import MultiQueryRetriever, DEFAULT_QUERY_PROMPT

llm = ChatOpenAI(temperature=0, model="deepseek-chat", api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ["OPENAI_API_BASE"])

# 创建多查询检索器
retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=llm,
    parser_key="lines"
)

# 用户问一个问题,系统自动生成多个改写版本并行检索
query = "什么是RAG系统的多路召回?"
print(f"原始查询: {query}")

# Step 1: 查看 LLM 用的是什么 Prompt 以及生成了哪些改写查询
prompt_text = DEFAULT_QUERY_PROMPT.format(question=query)
response = llm.invoke(prompt_text)
rewritten_queries = [q.strip() for q in response.content.strip().split("\n") if q.strip()]
print(f"\nLLM 生成的 {len(rewritten_queries)} 个改写查询:")
for i, q in enumerate(rewritten_queries, 1):
    print(f"  {i}. {q}")

# Step 2: 用 MultiQueryRetriever 检索(内部会自动调用 LLM 改写 + 去重合并)
docs = retriever.invoke(query)
print(f"\n检索到 {len(docs)} 个不重复文档:")
for i, doc in enumerate(docs, 1):
    print(f"  {i}. [doc_id={doc.metadata['doc_id']}] {doc.page_content}")
    
# 输出:
# 原始查询: 什么是RAG系统的多路召回?
# 
# LLM 生成的 3 个改写查询:
#   1. 1. 在RAG系统中,多路召回具体指的是什么技术或策略?
#   2. 2. 请解释RAG架构中如何通过多种检索路径或来源实现信息召回。
#   3. 3. 多路召回在RAG系统中的作用和实现方式是什么?
# 
# 检索到 3 个不重复文档:
#   1. [doc_id=1] RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。
#   2. [doc_id=2] 幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。
#   3. [doc_id=5] HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。

但这里有个坑,我踩过不止一次:

如果你让 LLM 生成 10 个查询,看起来覆盖面更广了,但噪声也成倍增加。2024 年的 DMQR-RAG 论文做了系统研究,结论是:查询数量不是越多越好,自适应改写才是最优解

他们的实验表明,对于简单 query,3-5 个改写就够了;复杂的多跳查询,可能需要 7-10 个。关键是让模型自己判断------这就是 DMQR-RAG 提出的"自适应改写数量"机制。

1.2 HyDE:用"伪文档"做查询扩展

如果说 Multi-Query 是"换种说法问",那 HyDE(Hypothetical Document Embedding)就是"我先猜答案长什么样,再用答案去搜"。

这个思路来自 2022 年 CMU + 滑铁卢大学的论文 Precise Zero-Shot Dense Retrieval without Relevance Labels。核心步骤就三步:

  1. 生成伪文档:用 LLM 根据 query 生成一个"假想的理想答案"
  2. 嵌入伪文档:把生成的伪文档做成向量
  3. 用伪文档向量去检索:因为伪文档包含了 query 的语义扩展,召回效果往往更好
python 复制代码
# HyDE 实现
class HyDEEncoder:
    def __init__(self, llm, encoder):
        self.llm = llm
        self.encoder = encoder
    
    def generate_pseudo_doc(self, query: str) -> str:
        """生成伪文档"""
        prompt = f"""请根据以下问题,生成一段可能包含答案的文档片段(100-200字):

问题:{query}

文档片段:"""
        
        response = self.llm.invoke(prompt)
        return response.content
    
    def encode(self, query: str) -> List[float]:
        """生成伪文档并返回扩展后的向量"""
        pseudo_doc = self.generate_pseudo_doc(query)
        print(f"生成的伪文档:\n{pseudo_doc}\n")
        
        # 嵌入伪文档
        embedding = self.encoder.embed_query(pseudo_doc)
        return embedding

# 使用示例
llm = ChatOpenAI(temperature=0.7, model="deepseek-chat", api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ["OPENAI_API_BASE"])
hyde_encoder = HyDEEncoder(llm, embeddings)

query = "RAG系统如何解决幻觉问题?"
print(f"查询: {query}\n")

hyde_embedding = hyde_encoder.encode(query)
results = vector_store.similarity_search_by_vector(hyde_embedding, k=5)

print(f"\n检索到 {len(results)} 个文档:")
for i, doc in enumerate(results, 1):
    print(f"  {i}. [doc_id={doc.metadata['doc_id']}] {doc.page_content}")
    
# 输出:
# 查询: RAG系统如何解决幻觉问题?
# 
# 生成的伪文档:
# RAG(检索增强生成)系统通过引入外部知识库来缓解大模型的"幻觉"问题。其核心机制是在生成回答前,先从向量数据库或文档库中检索与用户查询语义相关的真实信息片段,并将这些片段作为上下文输入给生成模型。例如,当用户询问"2024年诺贝尔化学奖得主"时,RAG会优先检索出当年的官方获奖公告,而非依赖模型内部可能过时或错误的参数记忆。此外,RAG还能通过多轮检索验证、引用来源标记以及置信度阈值过滤,进一步降低模型编造事实的风险。这种方法将生成过程从"凭记忆编造"转变为"基于事实的总结",有效提升了回答的准确性与可追溯性。
# 
# 
# 检索到 5 个文档:
#   1. [doc_id=1] RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。
#   2. [doc_id=2] 幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。
#   3. [doc_id=4] 向量检索使用Embedding模型将文本转换为高维向量,通过向量相似度进行语义匹配。
#   4. [doc_id=3] BM25是一种基于词频和文档频率的稀疏检索算法,擅长精确关键词匹配。
#   5. [doc_id=5] HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。

HyDE 的优势在于,它生成的伪文档天然包含了领域术语和上下文信息,这些是原始 query 可能缺失的。比如用户问"怎么防幻觉",伪文档里可能会出现"事实核查"、"引用溯源"、"检索增强"等专业表达,从而匹配到更精准的文档。

但 HyDE 也有局限 :如果 LLM 生成的伪文档本身就有幻觉(跑偏了),那检索结果只会更差。所以实际落地时,建议配合置信度过滤多伪文档投票机制。

1.3 查询分解:把复杂问题拆成简单问题

有些 query 天生就是"多跳"的,比如:"比较2023年和2024年特斯拉在中国的销量变化,并分析原因"。

这种 query 直接做向量检索,召回的文档往往是零散的,很难覆盖所有维度。查询分解(Query Decomposition)的思路是:把复杂问题拆成多个子问题,分别检索后再综合

python 复制代码
# 查询分解实现
import re
from langchain_core.prompts import PromptTemplate

# 确保 llm 已初始化(兼容单元格独立运行)
if 'llm' not in dir():
    llm = ChatOpenAI(temperature=0, model="deepseek-chat", api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ["OPENAI_API_BASE"])

decompose_prompt = PromptTemplate(
    input_variables=["question"],
    template="""将以下复杂问题分解为2-4个简单的子问题。直接列出子问题,每行一个(不要加编号或"子问题"前缀,只写问题本身):

问题:{question}

子问题:"""
)

def decompose_query(llm, complex_query: str) -> List[str]:
    """分解复杂查询,支持多种LLM输出格式的健壮解析"""
    response = llm.invoke(decompose_prompt.format(question=complex_query))
    raw_text = response.content.strip()

    # 按行拆分
    lines = raw_text.split("\n")

    sub_questions = []
    for line in lines:
        line = line.strip()
        if not line:
            continue
        # 移除常见的编号前缀(如 "1.", "1、", "1)", "- ", "• ")
        cleaned = re.sub(r'^[\d]+[.、)]\s*', '', line)
        # 移除 "子问题" / "子问题N:" 前缀
        cleaned = re.sub(r'^子问题[\d]*[::]\s*', '', cleaned)
        cleaned = cleaned.strip()
        # 过滤掉纯标题行(只包含"子问题"关键词且过短的行)
        if cleaned and len(cleaned) > 3:
            sub_questions.append(cleaned)

    # 回退:如果按行解析失败,尝试按句子/编号整体拆分
    if not sub_questions:
        # 按中文编号拆分:\d+[.、)] 或 子问题\d
        parts = re.split(r'(?:^|\s)(?:\d+[.、)]\s*|子问题\d+[::]\s*)', raw_text)
        sub_questions = [p.strip() for p in parts if p.strip() and len(p.strip()) > 3]

    return sub_questions

# 示例
complex_query = "比较2023年和2024年特斯拉在中国的销量变化,并分析原因"
print(f"复杂查询: {complex_query}\n")

sub_questions = decompose_query(llm, complex_query)
print("分解后的子问题:")
if sub_questions:
    for i, sq in enumerate(sub_questions, 1):
        print(f"{i}. {sq}")
else:
    print("  (未能分解出子问题,请检查LLM连接或尝试其他查询)")


# 输出:
# 复杂查询: 比较2023年和2024年特斯拉在中国的销量变化,并分析原因
# 
# 分解后的子问题:
# 1. 2023年特斯拉在中国的总销量是多少?
# 2. 2024年特斯拉在中国的总销量是多少?
# 3. 2023年到2024年特斯拉在中国销量变化的具体数值和百分比是多少?
# 4. 影响特斯拉在中国销量变化的主要因素有哪些?

2026 年的一篇论文The Impact of Query Decomposition and Cross-Encoder Reranking in Multi-Hop Retrieval-Augmented Generation 验证了这个策略的有效性。他们的结论是:查询分解 + 重排序的"广撒网、精过滤"策略,在多跳问答场景下显著优于单查询检索

1.4 Query2Doc:另一种伪文档思路

Query2Doc 和 HyDE 类似,也是生成伪文档来扩展查询。区别在于 Query2Doc 更强调用少量示例(few-shot)来引导生成,让伪文档更贴近真实文档的分布。

实际项目中,我发现 HyDE 和 Query2Doc 的效果很大程度上取决于生成模型的质量。如果用的是 GPT-4 级别的模型,生成的伪文档质量很高;如果是 7B 级别的本地模型,建议先用少量领域数据微调一下,不然生成的伪文档可能"一本正经地胡说八道"。


二、多路召回:不要把鸡蛋放在一个篮子里

Query 改写解决的是"query 不够丰富 "的问题,多路召回解决的是"单路检索有盲区"的问题。

2.1 为什么单路检索不够?

向量检索和稀疏检索(BM25)各有优劣:

检索方式 擅长什么 不擅长什么 典型场景
向量检索(Dense) 语义相似、同义词、概念匹配 精确匹配、罕见术语、ID/代码 "RAG原理"匹配"检索增强生成"
稀疏检索(BM25) 精确匹配、关键词命中、术语 语义泛化、同义词 "BERT"精确匹配含"BERT"的文档
关键词匹配 快速过滤、布尔逻辑 语义理解 必须包含/排除某些词
python 复制代码
# BM25 检索器实现
from langchain_community.retrievers import BM25Retriever
from rank_bm25 import BM25Okapi

# 创建BM25检索器
bm25_retriever = BM25Retriever.from_documents(docs, k=3)

print(f"BM25检索器已创建,包含 {len(docs)} 个文档")

# 测试BM25检索
test_query = "幻觉问题"
bm25_results = bm25_retriever.invoke(test_query)
print(f"\nBM25检索 '{test_query}' 的结果:")
for i, doc in enumerate(bm25_results, 1):
    print(f"{i}. {doc.page_content[:80]}...")

# 输出:
# BM25检索器已创建,包含 3 个文档
#
# BM25检索 '幻觉问题' 的结果:
# 1. HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。...
# 2. 幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。...
# 3. RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。...
python 复制代码
# 向量检索测试
test_query = "如何减少模型编造内容"
vector_results = base_retriever.invoke(test_query)
print(f"向量检索 '{test_query}' 的结果:")
for i, doc in enumerate(vector_results, 1):
    print(f"{i}. {doc.page_content[:80]}...")

# 输出:
# 向量检索 '如何减少模型编造内容' 的结果:
# 1. 幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。...
# 2. HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。...
# 3. RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。...

2026 年的基准测试论文From BM25 to Corrective RAG: Benchmarking Retrieval Strategies for Text-and-Table Documents 对比了10 余种检索组合,结论是:混合检索(BM25 + 向量)在绝大多数场景下都是最优基线

2.2 并行召回 vs 串行召回

多路召回有两种架构:

并行召回(推荐):

css 复制代码
用户Query → [向量检索] → 结果A
         → [BM25检索] → 结果B
         → [关键词过滤] → 结果C
         → RRF融合 → 重排序 → 最终结果

三路并行,互不阻塞,最后用融合算法合并。优点是速度快,适合在线服务。

串行召回(级联过滤):

css 复制代码
用户Query → [粗排:BM25快速过滤] → Top 100
         → [精排:向量检索] → Top 20
         → [重排序:Cross-Encoder] → Top 5

先粗后精,逐步缩小候选集。优点是计算量小,适合资源受限的场景。

实际项目中,我推荐并行召回 + 融合 + 重排序的架构,因为各路召回的互补性最强,融合后的效果通常优于任何单路。

2.3 向量数据库的原生混合检索

好消息是,主流向量数据库都已经内置了混合检索能力,不需要自己从零实现:

Qdrant

python 复制代码
from qdrant_client import QdrantClient

client = QdrantClient("localhost", port=6333)

# 混合检索:dense向量 + sparse向量(BM25)
client.search(
    collection_name="my_collection",
    query_vector=("dense", [0.1, 0.2, ...]),  # 向量部分
    query_sparse_vector=("sparse", {0: 1.0, 5: 0.8}),  # 稀疏部分
    limit=10
)

Milvus

python 复制代码
from pymilvus import Collection

collection = Collection("hybrid_collection")

# 定义ANN搜索 + 全文搜索的混合查询
search_params = {
    "metric_type": "L2",
    "params": {"nprobe": 128}
}

# 多向量搜索(支持多路融合)
results = collection.hybrid_search(
    reqs=[
        AnnSearchRequest(data=[[0.1, 0.2, ...]], anns_field="dense_vector", param=search_params, limit=100),
        AnnSearchRequest(data=[[...]], anns_field="sparse_vector", param={}, limit=100)
    ],
    rerank=RRFRanker(k=60),  # RRF融合
    limit=10
)

Elasticsearch

json 复制代码
{
  "query": {
    "hybrid": {
      "queries": [
        { "match": { "content": "RAG多路召回" } },
        { "knn": { "field": "vector", "query_vector": [0.1, 0.2], "k": 100 } }
      ]
    }
  }
}

三、RRF 融合:多路结果的"民主投票"

多路召回来了多组结果,怎么合并?直接取并集?那排名信息就浪费了。加权求和?权重怎么定?

RRF(Reciprocal Rank Fusion,倒数排名融合)是一个 elegant 的解决方案,不需要训练,不需要调权重,公式简单到令人发指:

scss 复制代码
RRF_score(d) = Σ 1 / (k + rank_i(d))

其中:

  • rank_i(d):文档 d 在第 i 路召回中的排名
  • k:常数,通常取 60(经验值,防止低排名文档分数过高)
python 复制代码
import numpy as np

def rrf_fusion(results_lists: List[List[Document]], k: int = 60) -> List[Document]:
    """
    多路召回结果的RRF融合
    results_lists: [[doc1, doc2, ...], [doc1, doc3, ...], ...]
    k: RRF常数,通常取60
    """
    scores = {}
    doc_map = {}

    for results in results_lists:
        for rank, doc in enumerate(results, start=1):
            doc_id = doc.metadata.get("doc_id", hash(doc.page_content))
            doc_map[doc_id] = doc

            if doc_id not in scores:
                scores[doc_id] = 0
            scores[doc_id] += 1.0 / (k + rank)

    # 按RRF分数排序
    sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return [doc_map[doc_id] for doc_id, _ in sorted_docs]

# ========== 演示:构建差异化语料 ==========
# 为了使RRF融合效果更明显,使用包含不同领域的10篇文档
# 让向量检索和BM25检索产生部分重叠、部分互补的结果
demo_docs = [
    Document(page_content="RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。", metadata={"doc_id": "1"}),
    Document(page_content="幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。", metadata={"doc_id": "2"}),
    Document(page_content="BM25是一种基于词频和文档频率的稀疏检索算法,擅长精确关键词匹配。", metadata={"doc_id": "3"}),
    Document(page_content="向量检索使用Embedding模型将文本转换为高维向量,通过向量相似度进行语义匹配。", metadata={"doc_id": "4"}),
    Document(page_content="HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。", metadata={"doc_id": "5"}),
    Document(page_content="2024年诺贝尔物理学奖授予了John Hopfield和Geoffrey Hinton,表彰他们在人工神经网络的基础性发现。", metadata={"doc_id": "6"}),
    Document(page_content="Transformer架构自2017年提出以来,彻底改变了搜索与问答领域,BERT、GPT系列模型均基于此架构。", metadata={"doc_id": "7"}),
    Document(page_content="Python是数据科学和机器学习领域最流行的编程语言,拥有NumPy、Pandas、PyTorch等丰富生态。", metadata={"doc_id": "8"}),
    Document(page_content="语义搜索与传统关键词搜索的最大区别在于理解用户意图,而非仅仅匹配表面文字。", metadata={"doc_id": "9"}),
    Document(page_content="多路召回结合了向量检索和稀疏检索的优势,通过融合算法实现1+1>2的检索效果。", metadata={"doc_id": "10"}),
]

# 为演示创建独立的检索器
demo_embeddings = HuggingFaceEmbeddings(model_name=r"E:\LLM Project\Local Knowledge Base Q&A System\models\Xorbits\bge-large-zh-v1.5")
demo_vector_store = FAISS.from_documents(demo_docs, demo_embeddings)
demo_vector_retriever = demo_vector_store.as_retriever(search_kwargs={"k": 5})
demo_bm25_retriever = BM25Retriever.from_documents(demo_docs, k=5)

print(f"已构建演示语料库,共 {len(demo_docs)} 篇文档\n")

# ========== 测试RRF融合 ==========
# 选一个能让两路检索产生差异化的查询:"语义搜索"与"检索算法"各有所长
query = "什么是检索增强生成中的语义搜索"
vector_results = demo_vector_retriever.invoke(query)
bm25_results = demo_bm25_retriever.invoke(query)

# ---------- 分别展示两路检索的原始结果 ----------
print(f"{'='*60}")
print(f"查询: {query}")
print(f"{'='*60}")

print(f"\n📌 向量检索 Top-{len(vector_results)}(语义匹配能力强,擅长同义词/概念):")
for i, doc in enumerate(vector_results, 1):
    print(f"  {i}. [doc_id={doc.metadata['doc_id']}] {doc.page_content}")

print(f"\n📌 BM25检索 Top-{len(bm25_results)}(精确关键词匹配,擅长术语命中):")
for i, doc in enumerate(bm25_results, 1):
    print(f"  {i}. [doc_id={doc.metadata['doc_id']}] {doc.page_content}")

# ---------- RRF融合 ----------
fused_results = rrf_fusion([vector_results, bm25_results], k=60)

# 标记每篇文档的来源
vector_ids = {doc.metadata['doc_id'] for doc in vector_results}
bm25_ids = {doc.metadata['doc_id'] for doc in bm25_results}
only_vector = vector_ids - bm25_ids
only_bm25 = bm25_ids - vector_ids
both = vector_ids & bm25_ids

print(f"\n{'='*60}")
print(f"RRF 融合结果统计:")
print(f"  向量独有: {len(only_vector)} 篇  |  BM25独有: {len(only_bm25)} 篇  |  两路共有: {len(both)} 篇")
print(f"  向量检索: {len(vector_results)} →   BM25检索: {len(bm25_results)} →   RRF融合: {len(fused_results)} (去重+重排)")
print(f"{'='*60}")

print(f"\n📌 RRF融合后 Top-{len(fused_results)}(排名融合 + 去重):")
for i, doc in enumerate(fused_results, 1):
    did = doc.metadata['doc_id']
    # 标记来源
    if did in only_vector:
        source_tag = "🔵 仅向量"
    elif did in only_bm25:
        source_tag = "🟠 仅BM25"
    else:
        source_tag = "🟢 两路共有"
    print(f"  {i}. [doc_id={did}] {source_tag}\n     {doc.page_content}")

print(f"\n💡 RRF 的核心价值:两路检索结果存在差异时,融合后能同时保留")
print(f"   向量擅长的语义匹配结果 和 BM25擅长的关键词精确匹配结果,互为补充。")


# 输出:
# 已构建演示语料库,共 10 篇文档
# 
# ============================================================
# 查询: 什么是检索增强生成中的语义搜索
# ============================================================
# 
# 📌 向量检索 Top-5(语义匹配能力强,擅长同义词/概念):
#   1. [doc_id=1] RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。
#   2. [doc_id=9] 语义搜索与传统关键词搜索的最大区别在于理解用户意图,而非仅仅匹配表面文字。
#   3. [doc_id=4] 向量检索使用Embedding模型将文本转换为高维向量,通过向量相似度进行语义匹配。
#   4. [doc_id=3] BM25是一种基于词频和文档频率的稀疏检索算法,擅长精确关键词匹配。
#   5. [doc_id=5] HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。
# 
# 📌 BM25检索 Top-5(精确关键词匹配,擅长术语命中):
#   1. [doc_id=10] 多路召回结合了向量检索和稀疏检索的优势,通过融合算法实现1+1>2的检索效果。
#   2. [doc_id=9] 语义搜索与传统关键词搜索的最大区别在于理解用户意图,而非仅仅匹配表面文字。
#   3. [doc_id=8] Python是数据科学和机器学习领域最流行的编程语言,拥有NumPy、Pandas、PyTorch等丰富生态。
#   4. [doc_id=7] Transformer架构自2017年提出以来,彻底改变了搜索与问答领域,BERT、GPT系列模型均基于此架构。
#   5. [doc_id=6] 2024年诺贝尔物理学奖授予了John Hopfield和Geoffrey Hinton,表彰他们在人工神经网络的基础性发现。
# 
# ============================================================
# RRF 融合结果统计:
#   向量独有: 4 篇  |  BM25独有: 4 篇  |  两路共有: 1 篇
#   向量检索: 5 →   BM25检索: 5 →   RRF融合: 9 (去重+重排)
# ============================================================
# 
# 📌 RRF融合后 Top-9(排名融合 + 去重):
#   1. [doc_id=9] 🟢 两路共有
#      语义搜索与传统关键词搜索的最大区别在于理解用户意图,而非仅仅匹配表面文字。
#   2. [doc_id=1] 🔵 仅向量
#      RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。
#   3. [doc_id=10] 🟠 仅BM25
#      多路召回结合了向量检索和稀疏检索的优势,通过融合算法实现1+1>2的检索效果。
#   4. [doc_id=4] 🔵 仅向量
#      向量检索使用Embedding模型将文本转换为高维向量,通过向量相似度进行语义匹配。
#   5. [doc_id=8] 🟠 仅BM25
#      Python是数据科学和机器学习领域最流行的编程语言,拥有NumPy、Pandas、PyTorch等丰富生态。
#   6. [doc_id=3] 🔵 仅向量
#      BM25是一种基于词频和文档频率的稀疏检索算法,擅长精确关键词匹配。
#   7. [doc_id=7] 🟠 仅BM25
#      Transformer架构自2017年提出以来,彻底改变了搜索与问答领域,BERT、GPT系列模型均基于此架构。
#   8. [doc_id=5] 🔵 仅向量
#      HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。
#   9. [doc_id=6] 🟠 仅BM25
#      2024年诺贝尔物理学奖授予了John Hopfield和Geoffrey Hinton,表彰他们在人工神经网络的基础性发现。
# 
# 💡 RRF 的核心价值:两路检索结果存在差异时,融合后能同时保留
#    向量擅长的语义匹配结果 和 BM25擅长的关键词精确匹配结果,互为补充。

RRF 的妙处在于:

  1. 无参数:不需要训练,不需要调权重
  2. 对排名敏感,对分数不敏感:只关心"排第几",不关心"具体多少分"
  3. 天然处理结果缺失:如果某路没召回某文档,那路就不贡献分数,不影响其他路

但要注意:RRF 假设各路召回的"排名质量"大致相当。如果某路召回质量特别差(比如随机排序),它会拉低整体效果。所以融合前最好先确保每路召回都是"合格线以上"。


四、重排序:最后一公里的精度提升

多路召回 + RRF 融合之后,结果已经比单路好很多了。但如果要追求极致精度,还需要一道"重排序"(Reranking)工序。

4.1 为什么需要重排序?

召回阶段追求的是"不漏"(高召回率),所以可以用轻量级的近似算法(ANN、BM25)。但召回出来的 Top-K,排名不一定准。

重排序阶段追求的是""(高精确率),用更重的模型(Cross-Encoder)对召回结果做精细排序,把真正相关的文档排到前面。

4.2 BGE-Reranker:工业级重排序方案

BAAI 的 BGE-Reranker 系列是目前中文场景下最实用的重排序模型。和 Bi-Encoder(双塔模型,提前把文档和 query 编码成向量)不同,Reranker 是 Cross-Encoder(交叉编码器),把 query 和文档一起输入模型,输出相关性分数。

python 复制代码
# BGE-Reranker 加载(本地优先 + 指数退避重试)
import os, time, socket
from FlagEmbedding import FlagReranker

LOCAL = r'E:\LLM Project\Local Knowledge Base Q&A System\models\Xorbits\bge-reranker-large'
MODEL = 'BAAI/bge-reranker-base'
os.environ.setdefault('HF_HUB_DOWNLOAD_TIMEOUT', '30')
socket.setdefaulttimeout(30)

def _load_reranker(path, retries=5, fp16=True):
    NET = (TimeoutError, ConnectionError, ConnectionRefusedError,
           ConnectionAbortedError, ConnectionResetError, BrokenPipeError)
    for i in range(retries + 1):
        try:
            reranker = FlagReranker(path, use_fp16=fp16)
            if i: print(f'[重试成功] 第{i}次重试后加载成功')
            return reranker
        except NET as e:
            if i == retries: raise
            wait = 2 ** i
            print(f'[重试 {i+1}/{retries}] {type(e).__name__} --- 等待 {wait}s')
            time.sleep(wait)
        except OSError as e:
            if getattr(e, 'winerror', 0) in (10060, 10061, 10053, 10054):
                if i == retries: raise
                wait = 2 ** i
                print(f'[重试 {i+1}/{retries}] WinError {e.winerror} --- 等待 {wait}s')
                time.sleep(wait)
            else:
                raise

path = LOCAL if os.path.isdir(LOCAL) else MODEL
if path == LOCAL:
    print(f'[本地模型] ✅ {LOCAL}')
else:
    print(f'[远程下载] ⏳ {MODEL}')
reranker = _load_reranker(path)
print('\n✅ BGE-Reranker模型已加载')
python 复制代码
# prepare_for_model 兼容补丁(transformers >= 4.46 移除了该方法)
import types
from transformers import BatchEncoding

def _patch_prepare_for_model(tokenizer):
    if hasattr(tokenizer, 'prepare_for_model'):
        return
    def _prepare_for_model(this, ids, pair_ids=None, max_length=None,
                           add_special_tokens=True, padding=False,
                           truncation='only_second', stride=0,
                           return_tensors=None, return_token_type_ids=None,
                           return_attention_mask=None, return_overflowing_tokens=False,
                           return_special_tokens_mask=False):
        text_a = this.decode(ids, skip_special_tokens=True, clean_up_tokenization_spaces=False)
        text_b = this.decode(pair_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False) if pair_ids is not None else None
        encoded = this(text_a, text_b,
                      truncation=truncation or True,
                      max_length=max_length or this.model_max_length,
                      padding=padding, add_special_tokens=add_special_tokens)
        result = {'input_ids': encoded['input_ids']}
        if return_attention_mask:
            result['attention_mask'] = [1] * len(result['input_ids'])
        if return_token_type_ids:
            result['token_type_ids'] = [0] * len(result['input_ids'])
        if return_special_tokens_mask:
            result['special_tokens_mask'] = [0] * len(result['input_ids'])
        if return_overflowing_tokens:
            result['overflowing_tokens'] = []
        return BatchEncoding(result)
    tokenizer.prepare_for_model = types.MethodType(_prepare_for_model, tokenizer)
    print('[补丁] prepare_for_model 已注入到 tokenizer')


def rerank_results(reranker, query, candidates, top_k=3):
    """使用 BGE-Reranker 做精排"""
    if not candidates:
        return []
    _patch_prepare_for_model(reranker.tokenizer)
    pairs = [[query, doc.page_content] for doc in candidates]
    scores = reranker.compute_score(pairs)
    reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in reranked[:top_k]]


# 测试重排序
query = "RAG系统如何解决幻觉问题?"
reranked_results = rerank_results(reranker, query, fused_results[:5], top_k=3)
print(f"查询: {query}\n\n重排序后的Top-3结果:")
for i, doc in enumerate(reranked_results, 1):
    print(f"{i}. {doc.page_content[:80]}...")
    
    
# 输出:
# 查询: RAG系统如何解决幻觉问题?
# 
# 重排序后的Top-3结果:
# 1. RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。...
# 2. 多路召回结合了向量检索和稀疏检索的优势,通过融合算法实现1+1>2的检索效果。...
# 3. 向量检索使用Embedding模型将文本转换为高维向量,通过向量相似度进行语义匹配。...

Cross-Encoder 的优势是交互充分 ------query 和文档的每个 token 都能互相 attention,所以精度远高于 Bi-Encoder。代价是计算量大,不能提前缓存,只能对召回后的少量候选(通常 50-200 个)做重排。

4.3 量化的收益

那篇掘金文章 RRF混合检索+BGE重排序:召回率从0.67到0.82的实战 给了非常具体的数字:

方案 Recall@10 相对提升
纯向量检索 0.67 基准
向量 + BM25 混合 0.74 +10.4%
混合 + RRF 融合 0.78 +16.4%
混合 + RRF + BGE 重排 0.82 +22.4%

这个提升曲线很说明问题:每增加一道工序,都有明确收益。但边际收益在递减------从 0.78 到 0.82 的提升,需要引入一个额外的重排序模型,计算成本翻倍。实际项目中要权衡精度需求和延迟预算。


五、完整实战:搭建一个 Query 改写 + 多路召回的 RAG 系统

下面给一个可以直接跑起来的完整代码,整合前面讲的所有技术点:

python 复制代码
class AdvancedRAG:
    """完整的Query改写 + 多路召回 + RRF融合 + 重排序系统"""

    def __init__(self, documents: List[Document], use_llm: bool = True):
        self.documents = documents
        self.use_llm = use_llm

        # 1. 向量检索器
        self.embeddings = HuggingFaceEmbeddings(model_name=r"E:\LLM Project\Local Knowledge Base Q&A System\models\Xorbits\bge-large-zh-v1.5")
        self.vector_store = FAISS.from_documents(documents, self.embeddings)
        self.vector_retriever = self.vector_store.as_retriever(search_kwargs={"k": 5})

        # 2. BM25检索器
        self.bm25_retriever = BM25Retriever.from_documents(documents, k=5)

        # 3. 重排序模型(优先本地路径,带重试回退)
        model_path = r'E:\LLM Project\Local Knowledge Base Q&A System\models\Xorbits\bge-reranker-large'
        if os.path.isdir(model_path):
            self.reranker = FlagReranker(model_path, use_fp16=True)
        elif 'load_reranker_with_retry' in dir():
            self.reranker = load_reranker_with_retry('BAAI/bge-reranker-base', use_fp16=True)
        else:
            self.reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)

        # 4. LLM(用于Query改写和HyDE)
        if use_llm:
            self.llm = ChatOpenAI(temperature=0, model="deepseek-chat", api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ["OPENAI_API_BASE"])
        else:
            self.llm = None

    def query_rewrite(self, query: str, num_variants: int = 3) -> List[str]:
        """使用LLM生成多个查询改写"""
        if not self.llm:
            return [query]

        prompt = f"""请将以下问题改写为{num_variants}个不同表述的查询,每个查询单独一行,保持语义不变但用词不同:

原问题:{query}

改写查询:"""

        response = self.llm.invoke(prompt)
        variants = [line.strip() for line in response.content.split("\n") if line.strip()]
        return [query] + variants[:num_variants]

    def hyde_expand(self, query: str) -> str:
        """HyDE:生成伪文档"""
        if not self.llm:
            return query

        prompt = f"""请根据以下问题,生成一段可能包含答案的文档片段(100-200字):

问题:{query}

文档片段:"""

        response = self.llm.invoke(prompt)
        return response.content

    def multi_path_retrieve(self, query: str) -> List[List[Document]]:
        """多路召回:向量 + BM25"""
        # 向量检索
        vector_results = self.vector_retriever.invoke(query)

        # BM25检索
        bm25_results = self.bm25_retriever.invoke(query)

        return [vector_results, bm25_results]

    def rrf_fuse(self, results_lists: List[List[Document]], k: int = 60) -> List[Document]:
        """RRF融合多路召回结果"""
        scores = {}
        doc_map = {}

        for results in results_lists:
            for rank, doc in enumerate(results, start=1):
                doc_id = doc.metadata.get("doc_id", hash(doc.page_content))
                doc_map[doc_id] = doc

                if doc_id not in scores:
                    scores[doc_id] = 0
                scores[doc_id] += 1.0 / (k + rank)

        sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [doc_map[doc_id] for doc_id, _ in sorted_docs]

    def rerank(self, query: str, candidates: List[Document], top_k: int = 3) -> List[Document]:
        """使用BGE-Reranker做精排(自动修复 prepare_for_model 兼容性)"""
        if not candidates:
            return []

        # 兼容补丁:transformers >= 4.46 移除了 prepare_for_model
        if not hasattr(self.reranker.tokenizer, 'prepare_for_model'):
            self._inject_prepare_for_model()

        pairs = [[query, doc.page_content] for doc in candidates]
        scores = self.reranker.compute_score(pairs)

        reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
        return [doc for doc, score in reranked[:top_k]]

    def _inject_prepare_for_model(self):
        """为 XLMRobertaTokenizer 注入 prepare_for_model 兼容方法"""
        import types
        from transformers import BatchEncoding
        t = self.reranker.tokenizer
        def _fn(this, ids, pair_ids=None, max_length=None,
                add_special_tokens=True, padding=False,
                truncation='only_second', stride=0,
                return_tensors=None, return_token_type_ids=None,
                return_attention_mask=None, return_overflowing_tokens=False,
                return_special_tokens_mask=False):
            ta = this.decode(ids, skip_special_tokens=True, clean_up_tokenization_spaces=False)
            tb = this.decode(pair_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False) if pair_ids is not None else None
            enc = this(ta, tb, truncation=truncation or True,
                      max_length=max_length or this.model_max_length,
                      padding=padding, add_special_tokens=add_special_tokens)
            r = {'input_ids': enc['input_ids']}
            if return_attention_mask: r['attention_mask'] = [1] * len(r['input_ids'])
            if return_token_type_ids: r['token_type_ids'] = [0] * len(r['input_ids'])
            if return_special_tokens_mask: r['special_tokens_mask'] = [0] * len(r['input_ids'])
            if return_overflowing_tokens: r['overflowing_tokens'] = []
            return BatchEncoding(r)
        t.prepare_for_model = types.MethodType(_fn, t)

    def query(self, query: str, use_rewrite: bool = False, use_hyde: bool = False) -> dict:
        """完整查询流程"""
        # Step 1: Query改写(可选)
        if use_rewrite and self.llm:
            queries = self.query_rewrite(query)
            print(f"查询改写结果:{queries}")
        else:
            queries = [query]

        # Step 2: 多路召回
        all_results = []
        for q in queries:
            results = self.multi_path_retrieve(q)
            all_results.extend(results)

        # Step 3: RRF融合
        fused = self.rrf_fuse(all_results)
        print(f"RRF融合后候选数:{len(fused)}")

        # Step 4: 重排序
        final_results = self.rerank(query, fused[:10], top_k=3)

        return {
            "query": query,
            "rewrites": queries if use_rewrite else [],
            "results": final_results,
            "num_candidates": len(fused)
        }

# 创建AdvancedRAG实例
rag = AdvancedRAG(docs, use_llm=False)  # 不使用LLM改写功能
print("AdvancedRAG系统已初始化(不使用LLM改写)")
python 复制代码
# 测试完整流程
query = "RAG怎么解决幻觉问题?"
result = rag.query(query, use_rewrite=False, use_hyde=False)

print(f"\n最终查询: {result['query']}")
print(f"候选文档数: {result['num_candidates']}")
print(f"\nTop-3 结果:")
for i, doc in enumerate(result['results'], 1):
    print(f"{i}. {doc.page_content}")
    
# 输出:
# RRF融合后候选数:3
# 
# 最终查询: RAG怎么解决幻觉问题?
# 候选文档数: 3
# 
# Top-3 结果:
# 1. RAG(检索增强生成)通过引入外部知识库来增强大语言模型的回答能力,有效缓解幻觉问题。
# 2. 幻觉问题是大语言模型的固有缺陷,RAG通过检索真实文档来提供事实依据,减少模型编造内容。
# 3. HyDE(Hypothetical Document Embedding)通过生成伪文档来扩展查询,提高召回率。

六、踩坑实录:那些文档不会告诉你的事

坑 1:Query 改写生成"孪生查询"

有时候 LLM 生成的多个改写版本语义几乎一样,比如:

  • "RAG系统是什么"
  • "什么是RAG系统"
  • "RAG系统的定义"

这种"孪生查询"对召回没有帮助,反而浪费计算。建议加一个语义去重步骤:

python 复制代码
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('BAAI/bge-small-zh')

def deduplicate_queries(queries, threshold=0.95):
    """基于语义相似度去重"""
    embeddings = model.encode(queries)
    unique = [queries[0]]
    
    for i in range(1, len(queries)):
        sims = [np.dot(embeddings[i], e) for e in embeddings[:i]]
        if max(sims) < threshold:
            unique.append(queries[i])
    
    return unique

坑 2:RRF 的 k 值迷信

很多文章说 k=60 是"最佳实践",但其实 k 值应该根据你的结果列表长度来调整。如果每路只召回 Top-10,k=60 会让低排名的文档分数被过度压缩。建议:

  • 召回量小(<20):k=20-30
  • 召回量大(>50):k=60-100

坑 3:重排序的延迟爆炸

BGE-Reranker-large 在 CPU 上跑,50 个候选文档可能需要 2-3 秒。如果 QPS 要求高,可以考虑:

  1. 换轻量级模型:bge-reranker-base 比 large 快 3 倍,精度损失约 2-3%
  2. GPU 部署:用 ONNX Runtime 或 TensorRT 加速
  3. 候选裁剪:RRF 融合后只取 Top-20 做重排,而不是 Top-50

坑 4:HyDE 的"负向漂移"

如果 LLM 对领域知识理解不够,生成的伪文档可能包含错误信息。比如问"量子计算原理",模型生成的伪文档可能混淆"量子比特"和"经典比特"的概念,导致检索跑偏。

解法 :对 HyDE 生成的伪文档做事实性校验,或者限制在领域知识库内做生成(RAG-style HyDE)。


七、延伸阅读与工具清单

论文

工具库


检索系统的优化是一个"叠加增益"的过程。Query 改写、多路召回、RRF 融合、重排序,每一层都有独立价值,组合起来就是 1+1+1+1 > 4 的效果。但也不要过度工程化------先确保单路召回的质量达标,再逐步叠加。毕竟,最复杂的系统,往往是从最简单的基线开始的。

相关推荐
星辰AI打工人1 小时前
Agent-Reach 源码级解析:一个 30-200 行的插件系统凭什么治理 14 个平台
人工智能
IT新视界1 小时前
星环科技ArgoDB:基于一体化架构构建数据全生命周期安全底座
数据库·科技·安全·架构
峥无1 小时前
MySQL DML 操作(CRUD)总结
数据库·mysql
张彦峰ZYF2 小时前
从嵌入、表征到潜空间:理解大模型向量世界的三种视角
人工智能·大模型·向量空间
咕咕AI学堂2 小时前
Python 异步数据库驱动优化:从连接池到 uvloop 的全链路性能调优
人工智能
老H科研技术2 小时前
第 07 篇:OAuth 2.1 与授权架构 —— AS/RS 分离的正确姿势
人工智能·mcp
闵孚龙2 小时前
PyTorch 系列 之 nn.Module:所有模型的骨架
人工智能·pytorch·python
海天一色y2 小时前
深入理解 Function Calling、MCP 与 Skills:AI Agent 的三层能力架构
人工智能·mcp·skills
小星AI2 小时前
FastMCP 2.0 实战:10 分钟给 Claude Code 装上手
人工智能·agent