
最近又有朋友来问我RAG应该怎么搭说按照网上的教程跑通了demo但一换自己的文档效果就崩。聊下来我发现大部分人的问题都出在同一个地方——太把RAG当成一个开箱即用的工具忽略了索引和检索这两端的细节打磨。这篇就结合我自己落地几个RAG项目的经验把从索引到智能检索这条链路完整拆一遍说说哪些地方值得花心思哪些坑能直接避开。这个系列写完正好有读者私信说前面几篇偏基础想要更贴近实战的内容。这篇以构建一套完整的知识问答RAG系统为例覆盖文档解析、分块、向量化、召回、重排、上下文组装这几个核心环节。不管你是刚入门想搭第一个RAG还是已经做了几轮迭代想要提升效果这篇应该都有能直接拿走用的东西。1. 整体设计思路把RAG拆成两个独立优化的子系统很多人把RAG当成一条直线流程——文档塞进去问题丢进去答案出来。但实际上RAG由两个相对独立的子系统组成索引系统和检索系统。索引系统负责把原始文档转化成可被计算机理解的结构化表示检索系统负责在用户提问时从这些表示中找出最相关的片段。两个子系统独立优化最后通过生成环节衔接。1.1 为什么索引质量决定了RAG的体验上限先说我踩过的坑。最初做知识库问答时我直接把PDF整本丢进系统让模型按从头到尾的顺序读。结果有两个问题一是token消耗巨大二是答案质量差——因为模型根本不知道哪里是重点只能靠注意力机制硬扫全文。后来改成先索引后检索才知道索引阶段的一个小决策能直接改写检索结果。比如分块时如果把一个长表格截成两块检索时无论问什么召回的都只有半个表格答案自然是残缺的。再比如embedding模型选得不合适切分后的块向量在语义空间中挤成一团检索时前几名召回的相似度都差不多重排也救不回来。这里建议每个想做RAG的人先把一句话刻脑子里检索结果的上限在索引阶段就已经定死了。召回环节能做的只是从索引里挑出相对好的不可能凭空变出高质量内容。所以索引阶段花的时间再多都不冤枉。1.2 完整链路拆解查询进来之后发生了什么一次完整的RAG请求我习惯把它拆成这么几段用户提交问题查询改写可选把口语化问题改成适合检索的表达向量化用同一个embedding模型把查询转成向量召回从向量库找出top-K个最相似的候选片段重排用更精细的模型对候选片段重新打分排序上下文组装把最相关的片段拼接成模型输入生成大模型基于上下文和原始问题生成回答索引侧则是另一条线文档解析从PDF、Word、HTML等格式中抽取纯文本清洗去掉页眉页脚、乱码、无关内容结构化分区按章节、段落、语义拆解分块控制在模型可接受的token范围内向量化把每个块转成向量写入向量库构建索引并保存元数据这两条线像两条独立的流水线在检索那一刻才交汇。所以项目启动的时候我建议先把这两条线的接口定义清楚别一上来就混着改。1.3 可用性和效果之间的平衡点做RAG有个绕不开的取舍精确匹配和语义模糊匹配的关系。纯向量检索擅长语义相似但对精确的关键词、型号、编号这类内容束手无策——比如用户问型号ABC-204的额定功率向量检索可能因为ABC-204分词后被拆散而召回失败。反过来纯关键词检索又处理不了我想找一个能处理大量并发请求的方案这种语义发散的问题。所以现在主流的做法是混合检索把向量召回和关键词召回的结果合并再做一次重排。虽然工程上复杂一些但对真实场景的提升非常明显。具体怎么做后面检索章节详细说。2. 索引阶段把文档变成合格的检索单元2.1 文档解析与清洗最脏最累的活儿先说解析。不同格式的解析难度完全不在一个等级Markdown/纯文本几乎没有解析成本但需要处理代码块和表格HTML要剔除标签保留标题层级PDF这里坑最多尤其是扫描件需要OCR多栏论文需要版面分析表格需要用专门的工具抽取Word兼容性问题多样式丢失是常态我的经验是第一版先能跑通就够但PDF必须格外注意。很多人用命令行工具直接转文本遇到多栏论文就满屏乱序——左边一栏的下一行直接接到右边栏检索时语义根本对不上。现在有几十个开源的版面分析工具能很好地解决这个问题建议解析PDF时务必先跑一步版面分析把页面拆成标题、正文、表格等区块再抽取。清洗这部分必须做的几件事去掉页眉页脚和重复的导航文本比如论文里的第X页共Y页合并被拦腰截断的行特别是PDF转出来的文本经常每行末尾有换行统一中文标点和编码格式避免全文夹杂繁体或全角半角混乱过滤广告、免责声明、联系方式这类噪声我做过一个知识库清洗前后检索准确率差了近15个百分点。原因不在算法而是原文里大量页脚噪声被分块后混进了向量空间检索时频繁召回真实的正文片段反而被挤出了top-K。2.2 分块策略块大小、重叠窗口和边界感知分块这一步是索引阶段最影响检索效果的单点决策。搞懂分块为什么重要得先理解embedding模型的工作方式。模型把一段文本映射为一个向量这个向量的语义粒度是全局性的——它表达的是整段文本的主题而不是局部的细节。所以如果分块太大块内塞了多个子主题向量表示就会变成这堆内容的平均值检索时对每个子主题都有点相关但都不精准。如果分块太小比如一句话一块虽然语义聚焦了但缺少上下文模型没法判断这句话在什么场景中出现召回后也容易张冠李戴。具体参数上我常用的起点是普通文档单块300~500个token代码仓库按函数或类切分单块200~400个token长表格或复杂结构单独处理尽量整块保留快与块之间加重叠窗口很有必要一般重叠量是块大小的10%~20%。为什么因为两块之间如果恰好有一句话被切掉一半语义就断了。加上窗口切掉的内容在上一块的结尾和下一块的开头都出现一遍就能减少信息丢失。边界感知比固定大小切分好得多。优先按章节标题、段落边界、代码函数定义来切。如果找不到明显边界再退回到按固定长度切。我见过不少项目一上来就直接按字符数硬切结果是同一个章节被拦腰截断检索结果天然残缺。2.3 向量化Embedding模型选型的四个判断维度选embedding模型这件事在中文场景尤其值得重视。很多开箱即用的embedding模型在英文上表现不错一到中文就稀烂。选型时我主要看四个方面第一是否在中文语料上做过充分训练。判断方法很简单拿几个你自己的文档片段做召回测试看看同义改写后能不能正确召回。第二向量维度。高维度如1024维通常表达能力强但存储和计算成本高低维度如384维速度快但可能表达不够细。不用盲目追求高维度够用就好。第三最大输入长度。有些模型支持很长的上下文比如8k但长了不一定好——块内内容太杂反而稀释语义。我个人体验是支持512~1024token的模型配合合适的分块策略效果通常是最好的。第四是否有领域适配。比如法律、医疗这类专业领域用通用模型还是垂直模型效果差异很大。预算允许的话建议都跑一遍对比再定。另一个值得提醒的检索用的embedding模型必须是同一个。我曾经有个项目前期测试时用模型A离线建了索引后来觉得模型B更好直接换掉了但向量库里还是模型A建的老数据。检索时问题用模型B编码老文档用模型A编码——两边向量空间都不一样相似度计算完全是乱的。这个坑务必避掉。2.4 向量存储除了向量库元数据同样重要选向量数据库时我会同时关注这几个方面支持索引类型、相似度度量方式、元数据过滤能力、扩展性和运维成本。索引类型主流是HNSW分层可导航小世界图和IVF倒排文件。HNSW检索延迟低、召回率高但构建索引占内存多IVF内存占用小但需要调候选集参数。我的经验是中小规模百万级以内直接用HNSW把neighbors参数调大一点就够了省心且稳定。相似度度量一般有内积、余弦、欧氏距离三种。对语义检索来说余弦相似度是最常用的——它只关心向量方向不受向量长度影响。但要留意很多库的默认配置有归一化处理配置时务必搞清楚实现细节否则会出现看起来配置的一样结果差一大截的情况。元数据存储是很多人忽略的点。每条向量都应该绑定来源文档ID、章节标题、页码、创建时间等元数据。我之前做一个多知识库聚合系统就是靠元数据过滤实现了按部门检索——用户提问时先限定部门范围再在子集内做最近邻搜索。没有元数据这种需求只能硬扫全库效果和速度都难看。3. 智能检索让正确的信息浮出水面3.1 向量召回Top-K和相似度阈值的艺术向量检索的核心操作是把用户问题编码成向量然后在向量库里找最近邻。这里有两个参数最需要调召回数量Top-K和相似度阈值。Top-K的选择很微妙。K太小可能漏掉关键信息K太大无关内容混进来的概率也变大还会让后续的重排和上下文组装压力增大。我常用的策略是在重排环节之前先多召回K设为10~20再用重排模型压缩到3~5个片段。第一轮召回讲覆盖第二轮重排讲精准两个阶段各司其职。相似度阈值的作用是兜底。设置一个下限低于这个分数的向量即使排在前K也直接丢。很多RAG系统一看召回结果不对就开始怀疑模型其实往往就是没设阈值——一堆低质量的垃圾片段全都浮上来了。阈值的设定没有通用标准跟你用的模型和数据分布强相关。实战时先跑一批问题统计命中正确的片段得分大概落在什么区间再设定阈值。还有一点召回前对查询做改写能显著提升命中率。用户的问题往往是口语化、指代不清的表述比如它支持并发吗——这个它如果不还原成实体名检索时向量相似度很分散。我在实际项目中常用的改写策略有用大模型把口语问题改成书面化的检索语句把指代词还原成具体的实体名词必要时扩展同义词提高召回命中这步改写成本很低但对检索质量提升非常明显。3.2 混合检索向量关键词的双引擎互补前面说过向量检索对精确匹配的召回效果不够稳定尤其是型号、编号、人名这类实体。混合检索的思路就是让两个引擎各自负责自己擅长的部分最后合并结果。具体实现上经典的做法是向量检索加BM25关键词检索。BM25是一种基于词频和逆文档频率的打分模型擅长精准匹配速度快。把两个引擎的召回结果合并用一组合适的权重做融合比如向量得分占0.7、关键词得分占0.3按加权总分取前K个。混合检索里最麻烦的是两种分数的可比性。向量相似度通常是0~1之间的floatBM25分数是0到几十的整数直接加权没法比。常见做法是分别做归一化比如min-max归一化或z-score再融合。或者用更简单的策略各自取top-N后用互斥补集的方式合并——关键词命中但向量漏掉的补进来向量召回但关键词没命中的也补进来去重后按向量分数排序。我的实操体验引入混合检索后知识库问答的准确率能提升5~10个百分点特别是在掺杂大量专有名词的领域。代价是多维护一个关键词索引但对中等规模知识库来说成本完全可接受。3.3 重排序用交叉编码器做二次精排召回阶段找回来的top-K个片段彼此之间的相关度差异其实很模糊。因为双编码器先分别编码查询和文档再算相似度效率高但精度有限。重排序干脆换一种玩法直接把查询和文档拼在一起用一个交叉编码器判断它们的相关程度精度高得多。代价是速度慢——每对都要过一遍模型所以只能对少量候选做。重排这个环节是把差不多相关变成准确相关的关键一步。我在项目中重排前后对比过重排前头名经常是次优结果重排后命中率能提升8%~12%。工程实现上重排模型选择和调用要注意几个点输入长度限制交叉编码器通常要求较短输入如512 token超出时需要截断或分段打分批量推理把多组查询-文档对拼成一个批次做推理能大幅提升吞吐分数归一化重排分数通常会做sigmoid或softmax归一化方便和后续逻辑对接3.4 上下文组装别让多余信息污染答案经过重排之后剩下的片段就是最终的上下文。组装这一步经常被轻视但耗token还是小事关键是给大模型喂太多不相关信息反而会诱导幻觉。大模型收到上下文里混着几条无关信息时很可能把其中不相关的部分也当作事实引用了。所以组装的核心原则是宁缺毋滥。我一般只保留重排后得分最高的3~5个片段每个片段控制在500个token以内。必要时对片段再做一次裁剪——保留得分最高的部分去掉冗余的句子再拼接。组装时还要给大模型明确的指令格式。比如要求仅根据以上参考资料回答如果资料中没有相关内容请直接说明不知道。这种防御式指令对幻觉有明显的抑制作用。4. 实操过程一个可复现的完整落地示例写到这里直接上可运行的示例。下面这套流程用Python实现了一个基础的RAG索引和检索链路适配了小规模知识库的搭建。4.1 环境准备与关键依赖先装好基础环境。我这里只列出关键依赖具体版本号按你的运行环境自行锁定。pip install sentence-transformers # 用于加载embedding模型 pip install pymilvus # 向量数据库客户端 pip install rank-bm25 # BM25关键词检索 pip install openai-style-sdk # 大模型推理接口按需替换环境准备阶段有一点要特别强调所有组件版本需要锁定因为embedding模型和向量库的兼容性问题经常导致线上事故。我有个经验把依赖锁进requirements文件之外还要记录模型文件本身的信息否则过了几个月你根本不知道库里是哪个模型建的索引。4.2 索引构建从原始文档到可检索向量第一步加载并清洗文档。import re def clean_text(text): # 去除页眉页脚具体规则按文档特征调整 text re.sub(r第\s*\d\s*页, , text) text re.sub(r\s, , text) return text.strip()第二步按章节边界分块设置重叠窗口。def split_into_chunks(text, chunk_size300, overlap50): paragraphs text.split(\n\n) chunks [] buffer [] buffer_len 0 for para in paragraphs: para_len len(para) if buffer_len para_len chunk_size and buffer: chunks.append(\n.join(buffer)) # 保留末尾最少N个字符作为重叠 tail for last in buffer[::-1]: tail last \n tail if len(tail) overlap: break buffer [tail] if tail else [] buffer_len len(tail) buffer.append(para) buffer_len para_len if buffer: chunks.append(\n.join(buffer)) return chunks第三步批量向量化并写入向量库。from sentence_transformers import SentenceTransformer model SentenceTransformer(path/to/your-chinese-embedding-model) def build_index(documents): embeddings [] metadata [] for doc_id, doc in enumerate(documents): for chunk_id, chunk in enumerate(split_into_chunks(doc[text])): vec model.encode(chunk, normalize_embeddingsTrue) embeddings.append(vec) metadata.append({ doc_id: doc_id, chunk_id: chunk_id, title: doc[title], source: doc[source], }) return embeddings, metadata写入向量库的过程要重点确认两件事是否开启了归一化、相似度度量是否设置为余弦。这两项设置错误检索结果会非常奇怪而且很难排查。4.3 检索链路实现召回、重排、组装、生成查询侧的整体实现如下。def retrieve(query, top_k20, threshold0.35): query_vec model.encode(query, normalize_embeddingsTrue) # 向量召回 vec_results vector_db.search(query_vec, top_ktop_k) # BM25关键词召回 bm25_results bm25_index.search(query, top_ktop_k) # 合并并归一化简化实现 merged merge_and_normalize(vec_results, bm25_results) # 阈值过滤 merged [item for item in merged if item[score] threshold] return merged def rerank(query, candidates): # 用交叉编码器逐条打分 pairs [(query, c[text]) for c in candidates] scores cross_encoder.predict(pairs) ranked sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue) return [r[0] for r in ranked[:5]] def build_prompt(query, top_chunks): context \n\n.join( f【来源{ i1 }】{ chunk[text] } for i, chunk in enumerate(top_chunks) ) return ( 请基于以下参考资料回答问题。若资料中不含答案请直接说明未知。\n\n f参考资料\n{ context }\n\n f问题{ query }\n\n 回答 )整体调用candidates retrieve(ABC-204的额定功率是多少) top_chunks rerank(ABC-204的额定功率是多少, candidates) prompt build_prompt(ABC-204的额定功率是多少, top_chunks) answer llm_completion(prompt)这条链路第一次跑通不难难的是把每个环节的参数调到互相匹配。我自己的调参顺序是先确定分块大小 → 再选embedding模型 → 然后调召回和阈值 → 最后加重排。顺序反过来调参很多问题会互相干扰很难定位根源。5. 常见问题与排查技巧实录5.1 检索系统典型问题速查表症状可能原因排查方向召回结果语义相关但不精准分块过大语义被稀释减小块大小检查重叠窗口召回结果完全无关Embedding模型不适合中文/领域换模型用自查集对比测试精确关键词召回失败只用了向量检索接入BM25混合检索答案中使用了错误信息上下文被无关片段污染降低Top-K加强阈值过滤同一个问题反复波动向量库索引参数未调优调HNSW的M和efConstruction参数查询速度慢全量扫描代替索引查询确认是否走HNSW索引而非暴力搜索5.2 幻觉问题的两个有效缓解手段RAG系统搭建起来后幻觉问题是大头。我自己用的两个最有效的手段第一强制引用来源。让大模型回答时标注是从哪条参考资料得出的结论比如根据资料2中描述……。一旦模型必须交代出处它编造事实的概率会明显下降。如果你用的推理框架支持结构化输出更可以将答案和引用片段一起作为字段输出。第二设置低置信度兜底。在检索阶段设定一个严格阈值当最高分的片段得分低于该阈值时不生成回答而是提示知识库中未检索到相关信息。这样虽然用户会碰壁但至少不会收到幻觉答案。在企业内部场景中明确说不知道比编造一个看似合理的答案要好得多。5.3 性能瓶颈和优化方向RAG系统的性能瓶颈一般出现在两个环节embedding推理和向量检索。embedding推理一般是批量离线进行的构建索引时慢一点问题不大真正考验性能的是在线查询时的查询向量编码和重排。重排是纯加法开销候选集范围大时会明显拖慢延迟建议控制在最多20个候选以内。另一方面向量检索虽然有HNSW索引加速但数据量大了以后内存占用会飙升。优化方向有两个一是把索引分成多段只加载热段二是对embedding做乘积量化压缩用一点精度换大幅内存下降。对于亿级以下的数据规模HNSW加充足的机器内存是最省心的方案。5.4 评估方法用真实业务问题建回归集最后聊聊评估。没有评估RAG系统的迭代就是摸黑走路。建议从一开始就准备一组真实的业务问题作为回归测试集每条问题标注期望命中的片段或期望答案。每次改动索引策略、分块参数、检索逻辑时跑一遍测试集记录命中率、忠实度、准确率几个指标。这里有个很关键的小技巧测试集一定要来自真实的用户问题不能用你自己编的问题。因为自己编的问题自带标准答案倾向测不出系统在真实场景里的表现。我通常从用户日志里抽100条高频问题再人工标注参考答案长期维护这个测试集。效果优化到一定程度后再请业务方盲测验收。评估指标上我比较关注两个命中率期望片段是否出现在召回/重排后的top结果里主要反映检索质量忠实度生成答案是否严格基于引用片段主要反映调用大模型时的提示词和组装质量这两个指标分别对应检索和生成两端调优时能快速定位问题出在哪一端。写在最后RAG这个方向如果说有什么心得体会那就是它没有一个环节能省掉。索引偷懒了检索端就找不回来检索偷懒了生成端就编瞎话。每一个组件都按应有的标准做到位整个系统的效果才能稳定住。同时也想提醒一点不要为了追求新思路而不断增加复杂度。混合检索、重排、查询改写这些手段都是好东西但也是逐步引入的。每加一个环节都要先确认它确实带来了收益而不是让系统变重。从最简单的单链路跑通再一步步加料是我建议的迭代方式。后续我打算聊聊如何针对特定领域微调embedding模型以及在长文档场景下做分层索引的思路。这期内容如果对你有帮助欢迎留言分享你自己在搭建RAG时遇到的最棘手的问题。