RAG

RAG优化技术:混合检索

在检索增强生成流程中,混合检索(Hybrid Search)是提高检索阶段质量的关键高级技术。它确保了大模型在生成答案之前,能够获得语义最相关、关键词最精确的上下文信息。 传统检索方式 传统的单一检索方式存在以下局限: 稠密检索/向…

TL;DR

在检索增强生成流程中,混合检索(Hybrid Search)是提高检索阶段质量的关键高级技术。它确保了大模型在生成答案之前,能够获得语义最相关、关键词最精确的上下文信息。 传统检索方式 传统的单一检索方式存在以下局限: 稠密检索/向…

在检索增强生成流程中,混合检索(Hybrid Search)是提高检索阶段质量的关键高级技术。它确保了大模型在生成答案之前,能够获得语义最相关、关键词最精确的上下文信息。

传统检索方式

传统的单一检索方式存在以下局限: 稠密检索/向量检索(如基于向量的语义相似度搜索): - 能捕捉语义信息,但可能在精确关键词匹配上表现不佳。 - 对训练数据、包括文档的质量、切片和嵌入质量高度依赖,容易受嵌入偏差影响。 稀疏检索/关键字搜索(如 BM25): - 依赖文档切分出来的关键词进行匹配。 - 无法理解语义相似但用词不同的查询与文档(例如“汽车” vs “轿车”)。 | 检索方式 | 优势 | 劣势 | | --- | --- | --- | | 关键字搜索 | 精确匹配 | 语义理解差 | | 语义搜索 | 意图理解 | 关键字敏感度低 |

混合检索(Hybrid Search)是一种复合的检索策略,它结合了至少两种不同类型的搜索算法,以利用它们各自的优势,克服单一检索模式的固有缺陷。

为什么需要混合检索?

混合检索就是通过融合两者的优势,在保持语义理解能力的同时保留关键词匹配的精确性,从而提高检索结果的召回率(Recall)和排序质量(Ranking Quality),最终提升 RAG 系统的整体性能(如回答准确性、事实一致性)。 混合检索将两者结合,使系统既能深刻理解用户意图(语义优势),又能确保检索结果不偏离核心关键词(关键字优势),从而显著提升 RAG 的召回质量,能解决以下问题: 1. 语义匹配但细节错误 2. 向量检索可能返回语义相关但关键信息(如时间、型号、编号)错误的内容。 3. 混合检索通过关键词精确匹配修正偏差。 4. 字面匹配但语义无关 5. 关键词检索无法理解同义词或模糊表达(如“重置密码” vs “找回密码”)。 6. 向量检索可捕捉语义关联,弥补此缺陷。 7. 低频词或专业术语召回困难 8. 向量模型对罕见词表示弱,而关键词检索(如 BM25)对此更敏感。 9. 降低大模型“幻觉”风险 10. 更精准的检索结果 → 更可靠的知识输入 → 减少模型编造答案。

混合检索的常见做法

混合检索的常见做法就是并行多路召回 + 结果融合。 也就是说把同一个查询同时送入:关键词检索模块(如 Elasticsearch / BM25)、向量检索模块(如 Milvus / FAISS / PgVector),然后再对对两路结果进行融合排序。 融合排序有以下常见算法: - 加权求和(Score Weighting)最终得分 = α × 向量得分 + (1−α) × 关键词得分(需归一化得分,α 通常通过实验调优) - 重排序,包括RRF、Cross-Encoder等,下一个章节介绍。

混合检索的实现

Langchain&LlamaIndex

提到成熟的智能体框架就不得不提到 Langchain,Langchain对于混合检索有着非常好的支持,开箱即用。在 LangChain 中,混合检索和重排序通常通过组合不同的组件来实现。 对于混合检索,LangChain 提供了 EnsembleRetriever。EnsembleRetriever 并不直接执行检索,而是包装多个子检索器(如 BM25Retriever、VectorStoreRetriever 等),在收到查询时: 1. 同时调用所有子检索器; 2. 获取每个检索器返回的文档及其“分数”(或排名); 3. 使用一种融合策略(如 Reciprocal Rank Fusion, RRF)合并结果; 4. 返回最终排序后的 Top-K 文档。

//稀疏检索器(BM25)

bm25_retriever = BM25Retriever.from_documents(chunks)

//稠密检索器(FAISS + Embeddings)
embedding_model = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
vectorstore = FAISS.from_documents(chunks, embedding_model)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 2})


//构建 EnsembleRetriev
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, dense_retriever],
    weights=[0.5, 0.5]  # 注意:LangChain 的 EnsembleRetriever 实际上默认使用 RRF,weights 参数在某些版本中可能被忽略
)

实际case,用llamaindex写一个hybrid_retriever.py文件:

import os
import re
from llama_index.core import VectorStoreIndex, Document
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.retrievers.fusion_retriever import QueryFusionRetriever
from llama_index.embeddings.dashscope import DashScopeEmbedding
from llama_index.llms.dashscope import DashScope
from llama_index.core import Settings
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.schema import NodeWithScore
from llama_index.core.schema import QueryBundle
from rank_bm25 import BM25Okapi
import jieba

## 设置 DashScope API Key
DASHSCOPE_API_KEY = "sk-xxx" # 改成你自己的

## ===== 构造文档 - 科技公司产品和服务描述 =====
texts = [
    "花为CloudEngine 16800数据中心交换机采用自主研发的AI芯片,支持智能运维和预测性维护,适用于大型企业和云服务提供商。该产品在2023年获得全球数据中心网络设备市场份额第一。",
    "HuaWei Cloud提供包括弹性计算、存储、数据库平台在内的全栈云服务,特别在AI和大数据领域有深厚积累。华为云在亚太地区的市场份额持续增长。",
    "阿里巴巴的云计算平台阿里云是国内市场份额最大的云服务提供商,其核心产品包括ECS弹性计算、OSS对象存储和RDS关系数据库。阿里云在电商和大数据处理方面有独特优势。",
    "阿里巴巴达摩院在人工智能、量子计算和芯片设计等多个前沿领域进行基础研究,其开发的通义千问大语言模型在中文理解方面表现优异。",
    "腾讯云在游戏云、社交娱乐云服务方面具有领先地位,为《王者荣耀》《和平精英》等大型游戏提供稳定的云服务支持。腾讯云音视频解决方案在行业内广泛使用。",
    "腾讯的微信支付和支付宝是中国两大移动支付平台,其中微信支付依托微信社交生态,在个人转账和小额支付场景中占据优势。",
    "字节跳动的推荐算法是其核心竞争力,抖音和今日头条的内容推荐系统能够实现高度个性化,用户粘性极高。",
    "字节跳动火山引擎提供AI中台和数据中台解决方案,帮助企业构建智能化运营体系,其A/B测试平台在互联网行业广泛使用。",
    "百度的自动驾驶平台Apollo是国内最成熟的自动驾驶解决方案之一,已在多个城市开展Robotaxi商业化运营。",
    "百度的文心一言大模型在中文创作和逻辑推理方面表现突出,特别是在处理中国传统文化相关内容时具有优势。",
    "小米的IoT平台连接了超过5亿台智能设备,构建了全球最大的消费级AIoT生态,其小爱同学AI助手月活跃用户超过1亿。",
    "小米手机采用高通和联发科芯片,但在影像处理芯片和充电芯片方面有自研成果,澎湃系列芯片已应用于多款产品。",
]

docs = [Document(text=t) for t in texts]

## 分块
splitter = SentenceSplitter(chunk_size=120, chunk_overlap=20)
nodes = splitter.get_nodes_from_documents(docs)

## ===== 自定义 BM25 检索器 =====
def tokenize_zh(text: str):
    """中文文本分词 + 清理"""
    text = re.sub(r"[^\w\s]", "", text)  # 去除标点符号
    tokens = jieba.lcut(text)
    return [t.strip() for t in tokens if t.strip()]

## 构建 BM25 索引
corpus = [node.text for node in nodes]
tokenized_corpus = [tokenize_zh(doc) for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)

class CustomBM25Retriever:
    def __init__(self, nodes, bm25_model, similarity_top_k=3):
        self.nodes = nodes
        self.bm25 = bm25_model
        self.similarity_top_k = similarity_top_k

    def retrieve(self, str_or_query_bundle):
        # 兼容字符串和 QueryBundle
        if isinstance(str_or_query_bundle, QueryBundle):
            query_str = str_or_query_bundle.query_str
        else:
            query_str = str_or_query_bundle  # 假设是 str

        tokenized_query = tokenize_zh(query_str)
        scores = self.bm25.get_scores(tokenized_query)
        top_k_indices = sorted(
            range(len(scores)), key=lambda i: scores[i], reverse=True
        )[:self.similarity_top_k]
        results = []
        for idx in top_k_indices:
            results.append(NodeWithScore(node=self.nodes[idx], score=float(scores[idx])))
        return results

    async def aretrieve(self, str_or_query_bundle):
        return self.retrieve(str_or_query_bundle)

## ===== 稠密检索器 =====
embed_model = DashScopeEmbedding(
    model_name="text-embedding-v2",
    api_key=DASHSCOPE_API_KEY
)

Settings.llm = DashScope(
    model_name="qwen-max",
    api_key=DASHSCOPE_API_KEY
)

vector_index = VectorStoreIndex(nodes, embed_model=embed_model)
dense_retriever = VectorIndexRetriever(index=vector_index, similarity_top_k=4)

## ===== 替换稀疏检索器 =====
sparse_retriever = CustomBM25Retriever(nodes=nodes, bm25_model=bm25, similarity_top_k=4)

## ===== 混合检索器 =====
hybrid_retriever = QueryFusionRetriever(
    retrievers=[dense_retriever, sparse_retriever],
    similarity_top_k=4,
    num_queries=1,
    mode="reciprocal_rerank",
)

## ===== 查询:专门设计的问题 =====
query_str = "华为的人工智能芯片和云AI服务"

print("🔍 Query:", query_str)

print("\n=== 稠密检索结果 (语义理解) ===")
dense_nodes = dense_retriever.retrieve(query_str)
for i, node in enumerate(dense_nodes):
    print(f"{i+1}. Score: {node.score:.4f}")
    print(f"   Text: {node.text[:80]}...")

print("\n=== 稀疏检索结果 (关键词匹配) ===")
sparse_nodes = sparse_retriever.retrieve(query_str)
for i, node in enumerate(sparse_nodes):
    print(f"{i+1}. Score: {node.score:.4f}")
    print(f"   Text: {node.text[:80]}...")

print("\n=== 混合检索结果 (语义+关键词融合) ===")
hybrid_nodes = hybrid_retriever.retrieve(query_str)
for i, node in enumerate(hybrid_nodes):
    print(f"{i+1}. Score: {node.score:.4f}")
    print(f"   Text: {node.text[:80]}...")
uv add llama-index llama-index-core llama-index-embeddings-openai llama-index-postprocessor-colbert-rerank rank_bm25 llama-index-embeddings-dashscope rank_bm25 jieba

运行uv run hybrid_retriever.py 得到以下结果: 可以看到,通过单纯的关键词检索和语义检索,检索效果都不如混合检索的效果好。所以说,混合检索是一种常见的做检索优化的方案。

Spring AI如何实现

比较遗憾的是 Spring AI 在这方面比较薄弱,没有提供完善的开箱即用的工具, 但是 Spring AI 提供了模块化架构和核心接口,我们可以混合检索的思想,自行实现一套可定制的混合检索 + 重排序流程。 基于之前的课程,我们已经实现了基于 PGvector 的向量相似度检索,它擅长语义召回。为了实现完整的混合检索,我们必须引入另一个互补的检索机制——关键词检索。 因此,我们需要引入一个全文搜索引擎组件,最理想的选择就是 Elasticsearch (ES)。有了 ES,我们可以将之前的文本块进行双重存储: 1. PGvector:存储向量(Embedding),用于语义搜索。 2. Elasticsearch:存储原始文本块,用于关键词、模糊或高亮查询,实现精确的稀疏召回。

实现混合检索

关于ES的部署,文档的存储,以及检索,看这篇: ✅使用ElasticSearch做关键词检索 上面的看完之后,继续看这里。我们就可以开始正式去做混合检索了。我们还是基于之前的样例来修改:

@GetMapping("/hybridchat")
public String hybridchat(@RequestParam("query") String query) throws Exception {
    log.info("========开始执行混合检索===========");
    // 1. 向量检索获取相似文档
    List<Document> vectorDocs = embeddingService.similarSearch(query);
    log.info("向量查询检索到 {} 个相关文档,chunkId列表:{}",
            vectorDocs.size(),
            vectorDocs.stream()
                    .map(doc -> doc.getMetadata().getOrDefault("chunkId", "unknown").toString())
                    .collect(Collectors.joining(", ")));


    // 2. ES 关键词检索
    List<EsDocumentChunk> keywordDocs = esRagService.searchByKeyword(query, 5, true);
    log.info("ES 关键词查询检索到 {} 个相关文档,chunkId列表:{}",
            keywordDocs.size(),
            keywordDocs.stream()
                    .map(doc -> doc.getMetadata().getOrDefault("chunkId", "unknown").toString())
                    .collect(Collectors.joining(", ")));

    // 3. 根据 id 去重并合并文档
    Map<String, String> idToContent = new LinkedHashMap<>();

    // 向量检索文档
    for (Document doc : vectorDocs) {
        idToContent.putIfAbsent(doc.getId(), doc.getText());
    }

    // ES 关键词检索文档
    for (EsDocumentChunk doc : keywordDocs) {
        idToContent.putIfAbsent(doc.getId(), doc.getContent());
    }

    List<String> mergedContents = rrfFusion(vectorDocs, keywordDocs, 5);
    log.info("RRF 融合后共 {} 个相关文档块。", mergedContents.size());

//        List<String> mergedContents = new ArrayList<>(idToContent.values());
//        log.info("共检索到 {} 个相关文档块(向量 + 关键词融合)。", mergedContents.size());

    // 4. 构建提示词模板
    String promptTemplate = """
            请基于以下提供的参考文档内容,回答用户的问题。
            如果参考文档中没有相关信息,请直接说明"没有找到相关信息",不要编造内容。
            如果有了参考文档内容,请务必尽量回答问题。有可能用户的输入比较随意,你可以先尝试回答用户的问题,猜测他的实际需求,先给出回复,你需要尽量去贴合用户的问题需求。

            参考文档:
            {documents}

            用户问题: {question}

            """;

    // 5. 拼接文档内容
    String documentContent = String.join("\n\n=========文档分隔线===========\n\n", mergedContents);
    log.info("查询到的文档信息:{}", documentContent);

    // 6. 填充模板参数
    PromptTemplate prompt = new PromptTemplate(promptTemplate);
    Prompt realPrompt = prompt.create(Map.of("documents", documentContent, "question", query));

    // 7. 调用大模型生成回答
    String text = chatClient.prompt(realPrompt).call().chatResponse().getResult().getOutput().getText();

    return text;
}

为了演示效果,我再拿出原来只有相似度查询的chat来对比:两个的提示词保持一致

@GetMapping("/chat")
public String chat(@RequestParam("query") String query) {
    // 1. 相似度检索获取相关文档
    List<Document> similarDocs = embeddingService.similarSearch(query);

    // 2. 构建提示词模板
    String promptTemplate = """
            请基于以下提供的参考文档内容,回答用户的问题。
            如果参考文档中没有相关信息,请直接说明"没有找到相关信息",不要编造内容。
            如果有了参考文档内容,请务必尽量回答问题。有可能用户的输入比较随意,你可以先尝试回答用户的问题,猜测他的实际需求,先给出回复,你需要尽量去贴合用户的问题需求。

            参考文档:
            {documents}

            用户问题: {question}

            """;

    log.info("共检索到 {} 个相关文档块。", similarDocs.size());

    // 3. 处理检索到的文档内容
    String documentContent = similarDocs.stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n\n=========文档分隔线===========\n\n"));

    log.info("查询到的文档信息:{}", documentContent);

    // 4. 填充模板参数
    Map<String, Object> params = new HashMap<>();
    params.put("documents", documentContent);
    params.put("question", query);
    PromptTemplate prompt = new PromptTemplate(promptTemplate);
    Prompt realPrompt = prompt.create(Map.of("documents", documentContent, "question", query));

    // 5. 调用大模型生成回答
    String text = chatClient.prompt(realPrompt).call().chatResponse().getResult().getOutput().getText();

    return text;
}

调用示例 问题一:RL和DL是什么? 相似度检索: 混合检索结果: 我们可以看到,两种查询都能回答出来,说明这个问题在语义上还是比较完整的。那我现在就构造一个比较奇葩的问题,看两者的效果。 问题二:D的RL是什么? 这个问题很奇葩,但是其实我们对用户的输入是无法预判的,也许只是用户的一个误输入,就会导致有这种问题出现,DL和RL本身是两个概念,但是这个问题,就可以很明显的让相似度查询失效,检索不到任何文档内容,这时候,就需要我们的关键词查询出场了,他会对这个问题进行分词,可能分成:“D”“的”“RL”“是什么”,这样就能检索到相应的文本块(实际应用中需要增加停用词,将“的”,“了”,“吗”等这种词不分词)。接下来我们看下效果 相似度检索: 我们可以看到日志中根本没检索到任何的文本块: 混合检索: 我们可以看到混合检索输出了一些相关内容,日志中体现的向量检索是没有检索到内容,但是通过ES关键词找到了几个相关度高的文本块,这就体现出了混合检索多个数据源的优势。

总结

混合检索通过向量检索与关键词检索的多策略融合,既能利用向量检索捕捉语义关联,又能依托关键词检索精准匹配核心术语(如专业名词、特定表述),从而扩大候选文档的覆盖范围,尤其适合需兼顾召回率与关键信息匹配的场景。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。