RAG进阶--检索效果差,如何优化?

5 阅读6分钟

前情回顾

上篇文章最后遗留了一个问题:

RAG私有知识库共有里有三篇文档——曹操介绍、诸葛亮介绍、赤壁之战介绍。问曹操能答,问诸葛亮却回答"文档中未提及"。

打印检索日志后发现:问"诸葛亮是哪里人"时,检索到的 3 个片段全部来自 caocao.txt

第一反应是调参——把 chunk_size 调小(从400到300再到200),希望每个片段语义更纯粹;把 top_k 调大(3-5),希望覆盖更多候选。但反复试验后效果依然不理想,诸葛亮的问题还是答不对。

问题出在更上游:这三篇文档本身就高度交织。诸葛亮的介绍里多次提到曹操,曹操的介绍里也提到了赤壁和诸葛亮。无论 chunk 切得多细,只要文档内容互相穿插,向量检索计算的整体语义相似度就会"认错人"——它找到的是语义最近的片段,而不是名字最匹配的片段。

这是向量检索的结构性缺陷,调参只是在错误的方向上用力。真正的解法需要从检索策略上改进。本文将记录几个改进检索效果的方法。

一、混合检索

纯向量搜索的缺陷

向量搜索擅长语义相似,但对精确的专有名词处理不好。比如搜索"BGE-M3 的参数量",向量搜索可能把"大规模语言模型参数"排在最前面,但 BGE-M3 这个型号就是没出现在结果里。BM25 是传统的关键词搜索算法,会优先匹配完全相同的词——两者互补,结合起来能兼顾语义和精确匹配。

安装依赖

pip install rank_bm25 langchain-community

核心代码:

from langchain_community.retrievers import BM25Retriever
from langchain.retrievers.ensemble import EnsembleRetriever
​
def build_hybrid_retriever(chunks: list, vectorstore, k=5):
    """
    构建混合检索器
    chunks: split_documents() 返回的 Document 列表
    vectorstore: 已建好的 ChromaDB
    """
    # BM25 检索器(关键词)
    bm25_retriever = BM25Retriever.from_documents(chunks)
    bm25_retriever.k = k
​
    # 向量检索器(语义)
    vector_retriever = vectorstore.as_retriever(
        search_kwargs={"k": k}
    )
​
    # EnsembleRetriever:两路结果融合
    # weights 控制两路的权重,0.5/0.5 表示等权重
    # 如果你的文档专有名词多,可以调高 BM25 权重:[0.6, 0.4]
    hybrid_retriever = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[0.5, 0.5]
    )
    return hybrid_retriever
​
# 用法:直接替换原来的 vectorstore.as_retriever()
# hybrid_retriever.invoke(question) 即可

但混合检索也有局限,虽然能提高召回率,但是检索到的片段与问题的相关性程度没有个高低排序,top-k片段未必是最合适的结果,这就需要下面的方法。

二、Reranking重排序

Reranking-根据回答相关度打分,重新排序

向量检索召回的是余弦相似度高的片段,但余弦相似度是一个粗粒度的指标,结果里会有不少"相关但没那么相关"的噪音。Reranker 是一个专门做交叉注意力(Cross-Attention) 打分的模型,它把问题和每个文档片段一起输入,给出精确的相关度分数。计算量比 Embedding 大,所以只用来对召回结果精排,不用来全库扫描。典型用法:检索召回 20 条,Reranker 打分后取 Top-3。

硅基流动提供 BGE-Reranker 的 API,直接调用,和 Embedding 一样的方式。

核心代码:

import requests, os
​
SILICONFLOW_KEY = os.environ.get("SILICONFLOW_API_KEY")
RERANK_URL = "https://api.siliconflow.cn/v1/rerank"def rerank_docs(query: str, docs: list, top_n=3):
    """
    用 BGE-Reranker 对候选文档重排序
    docs: Document 对象列表(从检索器拿到的)
    top_n: 最终返回几条
    返回:按相关度从高到低排序的 Document 列表
    """
    if not docs:
        return docs
​
    texts = [doc.page_content for doc in docs]
    response = requests.post(
        RERANK_URL,
        headers={
            "Authorization": f"Bearer {SILICONFLOW_KEY}",
            "Content-Type": "application/json"
        },
        json={
            "model": "BAAI/bge-reranker-v2-m3",
            "query": query,
            "documents": texts,
            "top_n": top_n,
            "return_documents": True
        }
    )
    result = response.json()
​
    # 按 reranker 返回的顺序重组 Document 对象
    reranked = []
    for item in result.get("results", []):
        idx = item["index"]
        doc = docs[idx]
        reranked.append(doc)
    return reranked

但有时不是检索策略不够好,输入的问题太模糊也会导致检索不到内容,Query也可以优化。

三、Query改写

用户问: "他打赤壁那次用了什么计策?"

问题里没有"曹操""赤壁之战""火攻"这些关键词,向量检索依赖语义相似度,但这种口语化表达和文档里的书面表达差距很大,容易检索到不相关片段。

Query改写后:

扩展成多个精确的检索问题 改写成 3 个查询: ① "赤壁之战中曹操失败的原因" ② "周瑜火攻的战术策略" ③ "208年赤壁之战经过"

分别检索,结果合并去重,大幅提高命中率。

问题本身是合法的,只是表达不完整,开销是多几次 LLM 调用

核心代码:

REWRITE_PROMPT = """你是一个检索优化助手。请将用户的问题改写成 {n} 个不同角度的检索查询,
每个查询单独一行,用于在历史文献数据库中检索相关内容。
只输出查询本身,不要编号,不要解释。
​
用户问题:{question}"""
​
def rewrite_query(question: str, n=3) -> list[str]:
    """把一个用户问题改写成 n 个不同角度的检索词"""
    prompt = ChatPromptTemplate.from_template(REWRITE_PROMPT)
    chain = prompt | llm
    result = chain.invoke({"question": question, "n": n})
    queries = [q.strip() for q in result.content.strip().split("\n") if q.strip()]
    # 始终把原始问题也加进去
    queries.insert(0, question)
    return queries[:n + 1]
​
def multi_query_retrieve(question: str, vectorstore, k=5):
    """改写 + 多路检索 + 去重合并"""
    queries = rewrite_query(question)
    retriever = vectorstore.as_retriever(search_kwargs={"k": k})
    seen_ids = set()
    all_docs = []
    for q in queries:
        for doc in retriever.invoke(q):
            doc_id = doc.page_content[:50]  # 用前50字符作去重key,(有局限性,有两段前50字相同但内容不同的情况,暂且用这个key举例)
            if doc_id not in seen_ids:
                seen_ids.add(doc_id)
                all_docs.append(doc)
    return all_docs
​
# 测试
if __name__ == "__main__":
    q = "他打赤壁那次用了什么计策?"
    rewrites = rewrite_query(q)
    for r in rewrites:
        print(r)

四、总结

方法解决的问题额外开销推荐场景
混合检索专有名词漏召回默认开启
Reranking召回噪音多中(一次 API)召回 top-k 较大时
Query 改写口语化/模糊提问高(多次 LLM)面向 C 端用户

当然,上面三种方法不是孤立的,完全可以结合起来:先用 Query 改写扩展查询 → 再用混合检索召回候选 → 最后用 Reranker 精排。

经过上面的优化,再问诸葛亮的时候,RAG系统不再找错人了,都能检索到相应的文档片段,给出回答。

五、预告--优化效果评估

不过具题的优化效果没有量化评估。我找到一个专业的评估 RAG 系统的框架,可以从多个角度给系统打分。

下一篇文章将继续探讨 RAG 系统评估框架--RAGAS, 回见。