ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

进阶 RAG:检索不是拼向量那么简单

进阶 RAG:检索不是拼向量那么简单

很多人以为 RAG 的难点在生成:给模型塞点文档它就能答对。现实恰恰相反,检索质量才是生死线。你丢一个 query 进去,embedding 算完相似度,top-3 一取拼给大模型,结果它要么答非所问,要么自信地编。问题几乎从不出在 LLM,而出在你喂给它的那几段文本本身就不对。向量相似度只是众多信号里的一种,而且是最容易被语义漂移带偏的一种。这篇把 RAG 检索这层讲透:从 naive 向量检索的坑,到 chunk 切分、混合检索、rerank、query 改写,最后串成一条"召回→粗排→精排→压缩"的管道。

一、三个坑:为什么只丢一个 embedding 检索就会答非所问

RAG 入门教程告诉你:把文档切成块、各自向量化、存进向量库,来问题时算个相似度取 top-k。这套流程跑通 demo 很快,一到真实文档就露馅。根子上的问题有三个。

  • 语义漂移向量检索衡量的是"语义场"的接近,不是"答案的命中"。用户问"怎么重置密码",语义最接近的可能是一段讲"密码强度要求"的内容——词义高度相关,但答非所问。embedding 模型把"重置"和"强度"都归到了相近的语义簇里,相似度虚高。
  • 长文档切分丢上下文一篇万字技术手册切成 500 字一块后,单块可能只讲了半句话的前提。模型拿到这块,缺了上下的限定条件,很容易过度泛化,把局部表述当成全局结论。
  • top-k 截断丢关键信息即便真答案排在第三,top-k=2 也会把它直接截掉。更糟的是,排在前面的往往是"语义相关但无关"的干扰块,模型被它们带偏,反而离答案更远。

这三件事叠加,就是 RAG 答非所问和幻觉的主要来源。下面这个反模式,我在不少生产代码里见过。

反模式:文档随便切 + 单向量检索 + top-3 直喂
# 一段过时的__STR0__代码,问题全在检索端 from langchain.text_splitter import CharacterTextSplitter from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings, ChatOpenAI texts = CharacterTextSplitter(chunk_size=500, chunk_overlap=0).split_text(doc) vs = FAISS.from_texts(texts, OpenAIEmbeddings()) # 只用一个向量检索,top-3 直接拼给模型 ctx = "\n".join(d.page_content for d in vs.similarity_search(query, k=3)) answer = ChatOpenAI().invoke(f"问题:{query}\n上下文:{ctx}") # 结果:答非所问 + 幻觉,锅却在 LLM 头上

注意,这段代码里 LLM 一点没做错。它只是忠实地基于"你给的几段文本"作答。错在检索端:硬切、单向量、top-3 截断——三道关卡每一道都在漏掉真答案。

chunk 切分策略对比:固定长度硬切斩断句子 vs 标题结构+语义切分+overlap

二、Chunk 切分:检索质量真正的地基

很多人把 chunk 当成 preprocessing 里顺手做的事,殊不知切分方式直接决定了检索的上限。向量再准,块里没有完整语义,召回的永远是碎片。

  • 固定长度硬切的代价按字符数一刀切,句子、段落、章节边界全被无视。一个完整的"注意事项"可能被劈成两半,前半句在块 A、后半句在块 B,检索时两块都只命中一半,语义残缺。
  • 按标题 / 语义切分用文档自身结构当刀:Markdown 的#/##、HTML的标签、\n\n段落符,都是天然的语义边界。LangChain 的MarkdownHeaderTextSplitter会按标题层级先劈,再在块内细切,每块都带着"属于哪一章哪一节"的元信息。
  • overlap 防边界断裂相邻块重叠一小段(如 64 字),保证跨边界的语义不被腰斩。代价是存储翻倍,但换回的是边界处不再丢上下文,这笔账划算。

真实切分代码长这样:

from langchain.text_splitter import ( MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter, ) # 第一刀:按标题结构切,保留层级元信息 headers = [("#", "h1"), ("##", "h2"), ("###", "h3")] header_splitter = MarkdownHeaderTextSplitter(headers_to_split_notallow=headers) structured = header_splitter.split_text(md_doc) # 第二刀:按语义长度细切,带 overlap 防止边界断裂 splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=64, separators=["\n\n", "\n", "。", " ", ""], ) chunks = splitter.split_documents(structured)

先把结构捋清楚,再在结构内细切,最后用 overlap 兜底——这才是一套对检索友好的切分。

三、混合检索:BM25 关键词 + 向量的双保险

向量检索有个绕不开的短板:它弱在精确词。专有名词、错误码、版本号、人名,这些低频但决定性的 token,在稠密向量里被稀释了。用户搜"ORA-00600",向量可能觉得它和"数据库报错"更像,却召回不了一段精确讲 ORA-00600 的文档。

  • BM25 补精确匹配BM25 是经典稀疏检索,靠词频和逆文档频率算分。它对精确词、稀有词极度敏感,正好补上向量的盲区。它不理解语义,但"ORA-00600"出现在哪段,它一秒命中。
  • 两套信号并行同一句 query,一路走 BM25 拿关键词命中,一路走向量拿语义命中。两者召回的候选集合天然互补:向量找得到"意思接近"的,BM25 找得到"字面命中"的。
  • RRF 融合去重排序用 Reciprocal Rank Fusion 把两套排名融成一个:每个文档的最终得分是它在各路结果里排名的倒数之和(score = Σ 1/(k+rank)),不依赖各自的分数量纲,直接按名次合并。

LangChain 里这件事已经是标准化组件:

from langchain.retrievers import EnsembleRetriever from langchain.retrievers.bm25 import BM25Retriever from langchain_community.vectorstores import FAISS bm25 = BM25Retriever.from_documents(chunks) bm25.k = 10 vec = FAISS.from_documents(chunks, embeddings).as_retriever( search_kwargs={"k": 10} ) # BM25 与向量各占一半权重,召回阶段就双路并行 hybrid = EnsembleRetriever( retrievers=[bm25, vec], weights=[0.5, 0.5], ) hits = hybrid.get_relevant_documents("如何重置 ORA-00600 相关密码")

到这里,召回阶段已经从"单向量"升级成"关键词 + 语义双路融合",漏召回的概率大幅下降。

四、Rerank 与 query 改写:精排阶段的两板斧

召回解决"别漏",精排解决"排对"。混合检索后你手里可能有上百个候选,但 LLM 上下文窗口有限、且对塞进来的噪声极其敏感。精排要做的,是把真正相关的那几个顶到最前面。

  • 交叉编码器重排双塔向量是"先编码再比",query 和文档从没见过彼此。交叉编码器(cross-encoder)则是把 query 和文档拼在一起联合编码,让两者 token 级互相注意,相关度判断准得多。代价是慢,所以只用在召回后的少量候选上做精排,不在全库上跑。
  • query 改写 / 扩展用户的问题常口语、缺上下文、带指代。让 LLM 把原 query 改写成更利于检索的版本,或扩展成多个子问题并发检索(MultiQuery),能显著改善召回。这一步改的是"问法",不动文档。
from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder from langchain.retrievers.multi_query import MultiQueryRetriever from langchain_openai import ChatOpenAI # 交叉编码器精排,只留 top_n=5 rerank_model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3") reranker = CrossEncoderReranker(model=rerank_model, top_n=5) # 套在混合检索外面,召回结果先被精排一遍 ranked = ContextualCompressionRetriever( base_compressor=reranker, base_retriever=hybrid, ) # 再叠加 query 改写:让 LLM 把原问题扩写成多个检索视角 expanded = MultiQueryRetriever.from_llm( retriever=hybrid, llm=ChatOpenAI(model="gpt-4o-mini"), )

精排不是可选项。实践证明,在召回候选上过一道 cross-encoder,top-1 命中率能比纯向量检索高十几个百分点——而这恰恰是决定答案对不对的那一步。

召回→粗排→精排→压缩 检索管道:候选数从千级逐层收敛到几段精华

五、串成一条管道:召回→粗排→精排→压缩

拆开看是四件武器,合起来是一条逐层收敛的检索管道。每一层都在帮上一层纠错、瘦身,最终留给 LLM 的是又准又干净的上下文。

  • 召回(Recall)混合检索并行出手,目标是"别漏",宁可多召一些噪声。这一步的候选可以是上千个。
  • 粗排(Coarse Rank)EnsembleRetriever 融合两路结果,按融合分砍到百级,先把明显无关的请出去。
  • 精排(Rerank)交叉编码器在百级候选上逐对打分,只留 top-5~10。这一步决定"谁排第一",是答案命中的关键。
  • 压缩(Compress)即便 top-10 都对,每块里也夹杂着无关句子。上下文压缩只抽取和 query 相关的片段,把"整块噪声"压成"几句精华",喂给 LLM 的 token 数骤降,幻觉也跟着降。

最后这道压缩,用 LangChain 的LLMChainExtractor或EmbeddingsFilter都能做,把整条管道收口:

from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import LLMChainExtractor from langchain_openai import ChatOpenAI # 压缩器:只抽取每个 chunk 里和 query 相关的句子 compressor = LLMChainExtractor.from_llm(ChatOpenAI(model="gpt-4o-mini")) # 完整管道:混合召回 → 交叉编码器精排 → 上下文压缩 pipe = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=ranked, # ranked 已在第四节叠了 reranker ) final_docs = pipe.get_relevant_documents("如何重置密码") # final_docs 里每一段,都是和__STR0__直接相关的精华句子

上下文压缩前后对比:检索到的原始 chunk 含冗余噪声,压缩后只留相关句子

把这条管道讲完,RAG 的"检索不是拼向量"这句话才算落地。向量检索是入口,不是终点;chunk 决定上限,混合检索补盲区,rerank 定名次,压缩去噪声。四层各管一段,合起来才把"检索质量"这件事真正做扎实。

下次再有人跟你说"RAG 不就是向量库查一下",你可以把这条管道甩给他:差的就是这四道工序。

资料展示:
下面是我整理的AI大模型 学习资料和工具包预览,适合收藏后按主题逐步学习

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进