很多人以为 RAG 的难点在生成给模型塞点文档它就能答对。现实恰恰相反检索质量才是生死线。你丢一个 query 进去embedding 算完相似度top-3 一取拼给大模型结果它要么答非所问要么自信地编。问题几乎从不出在 LLM而出在你喂给它的那几段文本本身就不对。向量相似度只是众多信号里的一种而且是最容易被语义漂移带偏的一种。这篇把 RAG 检索这层讲透从 naive 向量检索的坑到 chunk 切分、混合检索、rerank、query 改写最后串成一条召回→粗排→精排→压缩的管道。一、三个坑为什么只丢一个 embedding 检索就会答非所问RAG 入门教程告诉你把文档切成块、各自向量化、存进向量库来问题时算个相似度取 top-k。这套流程跑通 demo 很快一到真实文档就露馅。根子上的问题有三个。语义漂移向量检索衡量的是语义场的接近不是答案的命中。用户问怎么重置密码语义最接近的可能是一段讲密码强度要求的内容——词义高度相关但答非所问。embedding 模型把重置和强度都归到了相近的语义簇里相似度虚高。长文档切分丢上下文一篇万字技术手册切成 500 字一块后单块可能只讲了半句话的前提。模型拿到这块缺了上下的限定条件很容易过度泛化把局部表述当成全局结论。top-k 截断丢关键信息即便真答案排在第三top-k2 也会把它直接截掉。更糟的是排在前面的往往是语义相关但无关的干扰块模型被它们带偏反而离答案更远。这三件事叠加就是 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_size500, chunk_overlap0).split_text(doc) vs FAISS.from_texts(texts, OpenAIEmbeddings()) # 只用一个向量检索top-3 直接拼给模型 ctx \n.join(d.page_content for d in vs.similarity_search(query, k3)) 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_notallowheaders) structured header_splitter.split_text(md_doc) # 第二刀按语义长度细切带 overlap 防止边界断裂 splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap64, 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/(krank)不依赖各自的分数量纲直接按名次合并。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_n5 rerank_model HuggingFaceCrossEncoder(model_nameBAAI/bge-reranker-v2-m3) reranker CrossEncoderReranker(modelrerank_model, top_n5) # 套在混合检索外面召回结果先被精排一遍 ranked ContextualCompressionRetriever( base_compressorreranker, base_retrieverhybrid, ) # 再叠加 query 改写让 LLM 把原问题扩写成多个检索视角 expanded MultiQueryRetriever.from_llm( retrieverhybrid, llmChatOpenAI(modelgpt-4o-mini), )精排不是可选项。实践证明在召回候选上过一道 cross-encodertop-1 命中率能比纯向量检索高十几个百分点——而这恰恰是决定答案对不对的那一步。召回→粗排→精排→压缩 检索管道候选数从千级逐层收敛到几段精华五、串成一条管道召回→粗排→精排→压缩拆开看是四件武器合起来是一条逐层收敛的检索管道。每一层都在帮上一层纠错、瘦身最终留给 LLM 的是又准又干净的上下文。召回Recall混合检索并行出手目标是别漏宁可多召一些噪声。这一步的候选可以是上千个。粗排Coarse RankEnsembleRetriever 融合两路结果按融合分砍到百级先把明显无关的请出去。精排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(modelgpt-4o-mini)) # 完整管道混合召回 → 交叉编码器精排 → 上下文压缩 pipe ContextualCompressionRetriever( base_compressorcompressor, base_retrieverranked, # ranked 已在第四节叠了 reranker ) final_docs pipe.get_relevant_documents(如何重置密码) # final_docs 里每一段都是和__STR0__直接相关的精华句子上下文压缩前后对比检索到的原始 chunk 含冗余噪声压缩后只留相关句子把这条管道讲完RAG 的检索不是拼向量这句话才算落地。向量检索是入口不是终点chunk 决定上限混合检索补盲区rerank 定名次压缩去噪声。四层各管一段合起来才把检索质量这件事真正做扎实。下次再有人跟你说RAG 不就是向量库查一下你可以把这条管道甩给他差的就是这四道工序。资料展示下面是我整理的AI大模型 学习资料和工具包预览适合收藏后按主题逐步学习