RAG 这个词这两年出现的频率太高了高到很多人一上来就问用哪个向量库Embedding 选哪个模型却很少有人先把整条链路走通一遍。我刚开始接触 Agent 开发的时候也是这样东拼西凑找了一堆教程结果建库、检索、生成三段各跑各的拼在一起就是不出活。这篇笔记就把我自己从零搭一套 RAG 的完整过程摊开讲包括建库时怎么切块、检索时怎么调参、生成时怎么拼 Prompt以及中间踩过的那些坑。适合已经了解 LangChain 基本概念、想动手跑通一个本地知识库问答的开发者也适合正在做企业知识库、想搞清楚 RAG 各环节到底在干什么的人。1. 先把 RAG 这条链路拆成三段来看很多人把 RAG 当成一个黑盒觉得喂文档进去问问题出答案就完事了。真动手才发现中间任何一环出问题最终答案都会离谱。所以我习惯先把 RAG 拆成三段建库Indexing、检索Retrieval、生成Generation。这三段各自独立又通过向量这个中间产物串起来。1.1 建库阶段到底在做什么建库的本质是把人类能读的自然语言转成机器能算的向量然后存进一个能快速做相似度匹配的地方。听起来简单但里面有几个关键决策点。第一步是文档加载。你的知识可能来自 PDF、Word、Markdown、网页、数据库LangChain 提供了对应的 Loader比如PyPDFLoader、UnstructuredMarkdownLoader、WebBaseLoader。这一步的坑在于格式解析PDF 里的表格、双栏排版、页眉页脚解析出来经常是乱的。我一般会先加载一小批文档打印前几页的文本看看干不干净不干净就先做清洗别急着往下走。第二步是切块Chunking。这是最容易被低估的一步。切块的核心矛盾是块太大检索出来的内容包含太多无关信息会稀释语义块太小上下文被切断模型拿到半句话也答不好。常见的做法是按字符数切比如RecursiveCharacterTextSplitter默认 chunk_size1000、chunk_overlap200。但字符数只是个近似真正合理的是按语义边界切——段落、标题、句子。我自己的经验是技术文档按标题层级切问答类按一问一答切长文章按段落切overlap 保留 10%~20%。overlap 的作用是防止关键信息正好卡在切割点上被劈成两半代价是存储和检索时会有冗余。这个冗余是值得的我宁愿多存一点也不想因为切断了上下文导致答非所问。第三步是向量化Embedding。把每个 chunk 送进 Embedding 模型得到一个高维向量。这里要理解一个核心概念向量之间的距离代表语义相似度。语义越接近的文本向量在空间里越靠近。这就是为什么如何申请报销和报销流程是什么即使字面不同也能被检索到。第四步是存入向量数据库。向量库的作用是高效地做最近邻搜索——给定一个查询向量快速找出库里最相似的 N 个向量。Milvus、Chroma、Qdrant 都是干这个的区别在于规模、部署方式和功能丰富度。1.2 检索阶段的核心是找得准检索阶段接收用户的问题把问题也向量化然后去向量库里找最相似的 chunk。听起来就是一次相似度查询但实际要做的事不少。最基础的是相似度搜索通常用余弦相似度。但纯向量检索有个问题它对关键词不敏感。比如你问LangChain 的 conda 环境怎么配向量检索可能召回一堆讲 LangChain 安装的内容但没抓住conda这个关键词。所以工业界普遍用混合检索——向量检索 关键词检索BM25再把两路结果融合。再往上一层是重排序Rerank。向量检索是粗筛召回 top 20 或 top 50然后用一个更精细的 Rerank 模型对这几十个结果重新打分排序取 top 3 到 top 5 送给生成模型。Rerank 模型通常比 Embedding 模型更重、更准但慢所以只用在少量候选上。1.3 生成阶段是把资料变成答案生成阶段拿到检索回来的 chunk加上用户问题拼成一个 Prompt 送给大模型让它基于这些资料回答。这里的关键是约束模型不要瞎编。Prompt 里必须明确只根据提供的上下文回答上下文里没有的信息就说不知道。很多人忽略的一点是引用来源。让模型在回答时标注信息来自哪个 chunk一方面方便用户核实另一方面也逼着模型更忠实地使用检索结果。这个在知识库场景里几乎是刚需。把这三段理清楚后面每一步的取舍就有了判断依据。下面我按实际搭建顺序一段一段展开。2. 建库实操从原始文档到可检索的向量库建库是整个 RAG 的地基地基没打好后面检索再花哨也白搭。我按加载、切块、向量化、入库四步走每步都说说我实际怎么做的。2.1 文档加载与清洗别让脏数据进库加载这一步我强烈建议先小批量验证。拿 3 到 5 个代表性文档跑一遍 Loader把结果打印出来看。我踩过的坑包括PDF 里的表格被解析成一堆错位的数字、扫描件 PDF 直接是空的需要 OCR、Markdown 里的代码块被当成正文切碎。清洗主要做几件事去掉多余的空行和空白字符、去掉页眉页脚这类重复噪声、把连续的短行合并成段落。这些看起来是小事但直接影响切块质量。一个满是换行符的文档切出来的 chunk 全是碎片。from langchain_community.document_loaders import PyPDFLoader loader PyPDFLoader(docs/handbook.pdf) pages loader.load() # 先看前两页解析效果 for p in pages[:2]: print(p.page_content[:500]) print(---)如果解析质量差可以考虑换 Loader或者先用工具把 PDF 转成 Markdown 再加载。转成 Markdown 的好处是结构保留得好标题、列表、表格都在切块时能利用这些结构。2.2 切块策略chunk_size 和 overlap 怎么定切块没有万能参数但有一套判断逻辑。我的做法是先看文档类型。结构化强的手册、规范按标题切对话式的客服记录、FAQ按轮次切叙述性的文章、报告按段落切。再定 chunk_size。一般 500 到 1000 字符是常见区间。太小语义不完整太大噪声多。中文的话因为信息密度高我倾向 500 到 800。overlap 取 chunk_size 的 10% 到 20%。比如 chunk_size800overlap 就取 100 到 150。RecursiveCharacterTextSplitter的好处是它会按分隔符优先级递归切先按段落切段落还太大就按句子切再大就按字符切。这样能尽量保住语义边界。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size800, chunk_overlap120, separators[\n\n, \n, 。, , , , ] ) chunks splitter.split_documents(pages) print(f切出 {len(chunks)} 个块)提示中文文档一定要把中文标点加进 separators否则默认按空格切中文会被切得乱七八糟。还有一个细节是给每个 chunk 加元数据。比如来源文件名、页码、章节标题。这些元数据在检索时可以用来过滤在生成时可以用来标注引用。别嫌麻烦后面会感谢自己。2.3 Embedding 模型选型不是越贵越好Embedding 模型决定了语义能不能被正确表达。选型时我看三个维度语言支持、维度、性能。中文场景下我一般优先考虑对中文优化过的模型。维度方面常见的是 768、1024、1536。维度越高表达能力越强但存储和计算成本也越高。对于中小规模知识库768 到 1024 完全够用。性能这块要实测。同一个问题用不同模型检索看召回的结果哪个更相关。我通常会准备 20 到 30 个测试问题人工标注正确答案然后对比不同模型的召回率。这个投入是值得的因为 Embedding 一旦定了后面换成本很高——所有文档都要重新向量化。from langchain_community.embeddings import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-base-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True} )normalize_embeddingsTrue这个参数很关键。归一化之后向量长度都是 1余弦相似度就等价于点积计算更快而且不同长度的文本可比性更好。2.4 向量库选型Milvus、Chroma、Qdrant 怎么选向量库的选型我按场景分向量库适合场景部署方式特点Chroma本地开发、小规模嵌入式轻量零配置适合原型Qdrant中小规模生产单机/集群过滤功能强API 友好Milvus大规模生产分布式性能强支持十亿级向量我自己的路径是原型阶段用 Chroma跑通了再迁到 Qdrant 或 Milvus。Chroma 最大的好处是嵌入式不用起服务几行代码就能跑。缺点是规模上去了性能会掉而且过滤能力弱。Qdrant 是我目前用得最多的。它的 payload 过滤很灵活可以在检索时按元数据过滤比如只在某个部门的文档里搜。Milvus 适合数据量特别大的场景但部署和运维成本高小项目没必要上。from langchain_community.vectorstores import Chroma vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./chroma_db ) vectorstore.persist()建库这一步做完你就有了一个可以检索的向量库。但建库不是一次性的文档更新了要增量入库这涉及到去重和更新策略后面单独说。3. 检索调优为什么你的 RAG 总是答非所问检索是 RAG 里最影响效果的一环。我见过太多案例生成模型没问题Prompt 也没问题就是检索回来的东西不对导致答案离谱。这一节专门讲检索怎么调。3.1 相似度检索的默认参数陷阱最朴素的检索就是similarity_search(query, k4)取最相似的 4 个。但 k 取多少、用什么相似度度量都有讲究。k 太小可能漏掉关键信息k 太大噪声多还会挤占 Prompt 的上下文窗口。我的经验是先取大一点比如 20再用 Rerank 精筛到 3 到 5。如果不用 Rerank那 k 取 4 到 6 比较稳。相似度度量方面余弦相似度是默认选择。但如果向量没归一化欧氏距离和余弦相似度的结果会不一样。所以前面强调的normalize_embeddingsTrue在这里就体现价值了。还有一个容易被忽略的点是查询改写。用户的问题往往口语化、有指代比如它怎么配置。直接拿这个去检索效果很差。可以先让大模型把问题改写成独立、完整的查询再去检索。# 查询改写示例 rewrite_prompt 把下面的问题改写成独立、完整、适合检索的查询只输出改写后的查询 问题{question}3.2 混合检索向量 关键词的双保险纯向量检索对关键词不敏感这是它的固有短板。比如产品型号、专有名词、代码函数名向量检索经常抓不住。解决办法是引入关键词检索最常用的是 BM25。混合检索的思路是向量检索召回一批BM25 召回一批然后用RRFReciprocal Rank Fusion把两路结果融合。RRF 的好处是不需要调权重直接按排名倒数求和简单有效。def rrf_fusion(vector_results, bm25_results, k60): scores {} for rank, doc in enumerate(vector_results): scores[doc.id] scores.get(doc.id, 0) 1 / (k rank 1) for rank, doc in enumerate(bm25_results): scores[doc.id] scores.get(doc.id, 0) 1 / (k rank 1) return sorted(scores.items(), keylambda x: -x[1])实测下来混合检索在专有名词多的场景里提升非常明显。我做过一个对比纯向量检索的召回率大概 70%加上 BM25 之后能到 85% 以上。3.3 Rerank把粗筛结果精排一遍Rerank 是检索质量的最后一道保险。它的原理是用一个交叉编码器Cross-Encoder把查询和每个候选文档拼在一起送进模型直接输出相关性分数。因为查询和文档是一起编码的交互更充分所以比向量检索准得多。代价是慢。向量检索是预先算好向量检索时只做距离计算很快Rerank 要对每个候选都跑一次模型候选多了就慢。所以标准做法是粗筛 top 20 到 50Rerank 后取 top 3 到 5。from sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-base) pairs [(query, doc.page_content) for doc in candidates] scores reranker.predict(pairs) ranked sorted(zip(candidates, scores), keylambda x: -x[1])Rerank 模型的选择也有讲究。中文场景下bge-reranker 系列是比较稳的选择。如果追求极致效果可以用更大的模型但延迟会上去。生产环境要在效果和延迟之间找平衡。3.4 元数据过滤缩小检索范围当知识库很大、文档类型很多时全库检索会引入大量噪声。这时候元数据过滤就派上用场了。比如用户问的是2024 年的政策那就先按年份过滤再在过滤后的子集里做向量检索。Qdrant 和 Milvus 都支持在检索时带过滤条件。Chroma 也支持但功能相对弱一些。过滤字段的设计要在建库时就规划好比如source、category、date、department。results vectorstore.similarity_search( query, k10, filter{category: policy, year: 2024} )过滤的坑在于过滤太严会漏。如果过滤条件把正确答案排除了那检索再准也没用。所以过滤条件要谨慎设计宁可宽一点让 Rerank 去精筛。4. 生成环节Prompt 怎么拼才不让模型瞎编检索回来的资料最终要通过 Prompt 送给大模型。这一步看似简单其实决定了答案的忠实度和可用性。4.1 上下文拼接的顺序与格式检索回来的多个 chunk怎么拼进 Prompt 有讲究。我的做法是按相关性排序最相关的放最前面。模型对开头的内容注意力更高。每个 chunk 加编号和来源方便模型引用也方便用户核实。chunk 之间加分隔符避免模型把不同来源的内容混在一起。def build_context(docs): parts [] for i, doc in enumerate(docs): source doc.metadata.get(source, unknown) parts.append(f[{i1}] 来源{source}\n{doc.page_content}) return \n\n---\n\n.join(parts)分隔符用---这种明显的标记比空行更清晰。编号用[1]、[2]这种模型引用时也方便。4.2 系统提示词里的三条硬约束Prompt 的核心是约束模型的行为。我总结下来有三条硬约束必须写进去只根据上下文回答。明确告诉模型不要用上下文之外的知识。上下文没有就说不知道。给模型一个拒答的出口比让它硬编强。标注引用来源。要求模型在回答里标出信息来自哪个编号。system_prompt 你是一个知识库助手。请严格根据下面提供的上下文回答问题。 规则 1. 只使用上下文中的信息不要使用你自己的知识。 2. 如果上下文中没有相关信息直接回答根据现有资料无法回答。 3. 回答时用 [编号] 标注信息来源。 上下文 {context} 这三条看起来简单但效果立竿见影。我做过对比不加约束的模型编造率能到 30% 以上加上约束后编造率降到 5% 以下。4.3 引用标注让答案可追溯引用标注是知识库场景的刚需。用户看到答案第一反应是这靠谱吗有了引用就能点进去看原文。实现上就是在 Prompt 里要求模型标注然后在展示时把编号映射回原文链接。这里有个细节模型有时候会标错编号或者标一个不存在的编号。所以展示层要做校验编号超出范围的直接忽略。另外如果模型回答里一个引用都没有那这个答案的可信度就要打问号可以考虑降低展示优先级或者提示用户。4.4 流式输出与多轮对话的处理生产环境里用户等不了模型一次性吐完。流式输出是标配LangChain 的stream方法可以直接用。流式的好处是首字延迟低用户体验好。多轮对话则要处理历史消息。我的做法是历史消息只保留最近几轮且不参与检索。检索只用当前问题或改写后的问题。如果把历史消息也拼进检索查询会引入噪声。历史消息的作用是让模型理解指代比如它指的是什么这个在生成阶段处理就够了。# 多轮对话时用历史改写当前问题 def rewrite_with_history(question, history): if not history: return question prompt f根据对话历史把当前问题改写成独立完整的查询。 历史{history} 当前问题{question} 只输出改写后的查询 return llm.invoke(prompt)5. 那些文档里不会写的踩坑记录前面讲的都是应该怎么做这一节讲讲我实际踩过的坑。这些坑教程里基本不会提但每一个都能让你卡半天。5.1 切块切断代码和表格的惨案我最早做技术文档知识库时用默认参数切块结果代码块被从中间切断检索出来的 chunk 是半截代码模型看了也答不对。表格更惨被切成一行一行的完全失去结构。解决办法是针对代码和表格做特殊处理。代码块用 Markdown 的 标记识别整块保留不切表格识别表头切的时候带上表头。LangChain 的MarkdownHeaderTextSplitter可以按标题切配合RecursiveCharacterTextSplitter做二次切分效果比纯字符切好很多。5.2 向量库更新时的重复入库问题文档更新是常态但很多人第一次做增量更新时会发现同一个文档被重复入库了检索时返回一堆重复内容。原因是每次跑入库脚本都无脑add_documents没有去重。解决办法是给每个 chunk 生成稳定的 ID通常用文档路径 chunk 序号做哈希。入库前先按 ID 查一下存在就更新不存在就插入。Qdrant 和 Milvus 都支持 upsertChroma 可以用delete加add模拟。import hashlib def make_id(source, index): raw f{source}::{index} return hashlib.md5(raw.encode()).hexdigest()5.3 Embedding 模型换了库就废了这是最痛的坑。我一开始用某个模型建了库后来觉得另一个模型效果更好直接换了模型结果检索全乱套。原因是不同模型的向量空间不兼容用 A 模型建的库必须用 A 模型来查询。所以换 Embedding 模型 全量重建库。这个成本要在选型时就考虑进去。我的建议是选型阶段多花时间对比一旦定了就别轻易换。如果非要换做好全量重跑的准备并且要保证新旧库能平滑切换。5.4 检索分数高不代表答案对有个反直觉的现象检索回来的 chunk 相似度分数很高但答案还是错的。原因可能是chunk 里确实有相关词但语义不是用户要的或者多个 chunk 拼在一起产生了误导。这时候 Rerank 能救一部分但救不了全部。根本的解决办法是优化切块和补充测试集。我一般会维护一个 50 到 100 条的测试问答对每次调整参数都跑一遍看召回率和答案准确率的变化。没有测试集调参就是盲调。5.5 上下文窗口塞太满反而变差一开始我贪心检索回来 10 个 chunk 全塞进 Prompt觉得信息越多越好。结果模型反而抓不住重点答案变得又长又散。后来改成只塞 3 到 5 个最相关的答案质量明显提升。这背后的道理是模型的注意力是有限的上下文越长每个位置分到的注意力越少。与其塞一堆相关度一般的不如精选几个最相关的。这也是 Rerank 存在的意义。6. 从原型到可用我实际项目的参数配置讲了这么多原理和坑最后把我一个实际项目的配置摊出来供参考。这是一个中等规模的企业内部知识库文档量大概几千份中文为主。6.1 完整链路的参数清单环节配置说明切块chunk_size700, overlap100中文文档按段落和标点递归切Embeddingbge-base-zh-v1.5, 768 维中文优化归一化向量库Qdrant 单机支持 payload 过滤检索向量 top 30 BM25 top 30混合检索融合RRF, k60无需调权重Rerankbge-reranker-base, top 5精排后取前 5生成上下文 5 个 chunk带编号系统提示词三条约束这套配置在我的场景下召回率能到 90% 左右答案准确率 85% 上下。当然具体数字因数据而异但参数区间是有参考价值的。6.2 上线前必须做的三件事第一件是建测试集。至少 50 条问答对覆盖常见问题和边界情况。每次改动都跑一遍看指标变化。没有测试集你根本不知道改动是变好还是变坏。第二件是压测延迟。RAG 的延迟来自好几段Embedding 编码、向量检索、Rerank、大模型生成。要分别测找出瓶颈。我遇到过 Rerank 成为瓶颈的情况后来换成更小的模型才解决。第三件是准备降级方案。向量库挂了怎么办大模型超时怎么办这些都要有兜底。最简单的降级是返回系统繁忙请稍后再试好一点的是缓存常见问题的答案。6.3 后续可以继续优化的方向这套链路跑通之后还有不少可以深挖的地方。比如查询改写可以做得更细针对不同类型的问题用不同的改写策略多路召回可以加入更多检索器比如基于图的检索生成阶段可以引入自我反思让模型检查自己的答案是否忠实于上下文。还有一个方向是Agentic RAG让 Agent 自己决定要不要检索、检索几次、用哪个检索器。这个比固定链路灵活但也更复杂适合有一定基础之后再尝试。我在实际项目里最大的体会是RAG 的效果八成取决于建库和检索两成取决于生成。很多人把精力花在换大模型、调 Prompt 上但真正该花时间的是切块策略和检索质量。把这两块做扎实用一个中等规模的模型也能出好效果反过来检索一塌糊涂用再强的模型也是白搭。所以如果你刚开始做 RAG建议先把建库和检索这两段打磨透生成环节反而是最容易见效的。