ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RAG落地实践全流程:从数据准备到上线调优的踩坑记录

RAG落地实践全流程:从数据准备到上线调优的踩坑记录 1. 为什么我又把RAG翻出来重做了一遍RAG这个词从2023年火到现在网上的教程一抓一大把但真正落到自己业务里你会发现一个很尴尬的现实跑通Demo只要一个下午想让它稳定可用可能要折腾一个月。我前后在三个不同规模的项目里落地过RAG从最初用LangChain拼一个“能问答就行”的原型到后来要给几十个内部用户提供知识库检索服务中间踩的坑足够写一本小册子。这篇文章不讲虚的就讲一件事一个能真正跑起来的RAG系统从数据准备到上线调优完整步骤长什么样每一步为什么这么做以及我在哪些地方摔过跤。核心关键词就三个RAG、落地实践、踩坑记录。适合谁看如果你已经知道RAG大概是什么但自己动手时发现检索结果总是不对、回答总是胡编、或者不知道该从哪一步开始优化那这篇内容就是写给你的。零基础也能看懂因为我会把每个环节的“为什么”讲清楚而不是甩一堆代码让你自己悟。先说一个我自己的判断RAG的瓶颈从来不在模型本身而在数据管线和检索策略。很多人一上来就纠结用哪个LLM、要不要微调结果忽略了最基础的问题——你的文档切对了吗你的检索真的能命中吗我见过太多项目模型换了一轮又一轮效果就是上不去最后发现是切块策略有问题。所以这篇文章的重点会放在数据侧和检索侧这两块做好了哪怕用一个普通的开源模型效果也能超过大部分“堆配置”的方案。2. 整体架构设计与技术选型思路2.1 先想清楚你的RAG到底要解决什么问题在动手之前必须先回答一个问题你的用户会问什么类型的问题这个问题决定了后面所有的技术选择。我把它分成三类第一类是事实型查询比如“XX产品的保修期是多久”“XX流程的第三步是什么”。这类问题的答案通常集中在文档的某一段落里检索精度要求高但对上下文理解要求低。第二类是归纳型查询比如“总结一下这份报告的核心结论”“对比A方案和B方案的优缺点”。这类问题需要跨多个段落甚至多个文档整合信息对检索的召回率要求高单靠向量相似度往往不够。第三类是推理型查询比如“如果客户要求延期交付按照合同条款应该怎么处理”。这类问题需要结合多个知识点进行推理对知识库的结构化程度要求最高。我自己的项目里第一类和第二类占了80%以上。所以我的架构设计优先保证这两类的效果第三类通过引入结构化知识比如把合同条款做成表格来辅助。如果你一上来就想做全能型RAG大概率会陷入“什么都想要什么都做不好”的困境。2.2 技术栈选择为什么我最终选了这套组合市面上的RAG工具链太多了LangChain、LlamaIndex、Haystack还有各种国产框架。我试过其中大部分最终稳定下来的组合是这样的环节选型理由文档解析Unstructured 自写规则通用解析器处理PDF表格容易丢结构关键文档我加了自定义规则文本切分递归切分 语义切分混合纯递归切分会把完整语义块切断纯语义切分又太慢向量模型BGE-M3中文效果好支持多语言本地部署成本低向量库Milvus数据量上到百万级后Milvus的检索延迟明显优于FAISS检索策略混合检索向量关键词纯向量检索对专有名词和编号不敏感必须加BM25兜底重排序BGE-Reranker召回阶段放宽精排阶段收紧效果提升明显生成模型Qwen2.5-14B中文理解好支持长上下文本地部署可控这套组合不是拍脑袋定的。我试过用OpenAI的embedding效果确实好但数据出境的合规问题过不了试过用FAISS小数据量很快但数据上到50万条以后内存占用和检索延迟都成了瓶颈试过纯向量检索结果用户搜“第三章第二节”这种带编号的内容向量模型完全找不到。所以每一个选型背后都是实际踩坑后的妥协。提示如果你刚开始做数据量在10万条以内FAISS完全够用没必要上Milvus。但如果你预期数据会持续增长建议一开始就用Milvus迁移成本比你想的高。2.3 数据流设计从原始文档到可检索知识库整个数据流我分成四个阶段阶段一采集与解析。原始文档可能是PDF、Word、Excel、HTML、Markdown甚至图片。我的做法是先用Unstructured做统一解析输出带元数据的文本块。对于表格我单独用Camelot提取转成Markdown表格再嵌入。对于扫描件先用OCR处理但OCR结果一定要人工抽检我遇到过OCR把“0”识别成“O”导致检索完全失效的情况。阶段二清洗与切分。解析出来的文本有很多噪音比如页眉页脚、页码、重复的标题。我写了一套规则清洗然后做切分。切分策略后面会详细讲这里只说一个原则切分的目标是让每个块在语义上自包含同时不超过模型的上下文限制。阶段三向量化与索引。每个文本块用BGE-M3生成向量同时保留原始文本和元数据来源文件、页码、章节标题。元数据非常重要后面做过滤和溯源都靠它。阶段四检索与生成。用户提问后先做查询改写然后混合检索召回Top-K再用Reranker精排最后把Top-N个块拼成上下文送给LLM生成回答。这个流程看起来简单但每个阶段都有坑。下面我逐个拆解。3. 核心细节解析与实操要点3.1 文档切分RAG效果的第一道生死线切分是RAG里最容易被低估的环节。我见过太多人直接用LangChain的RecursiveCharacterTextSplitter设一个chunk_size1000就完事了。结果就是检索出来的块要么缺头少尾要么包含大量无关内容。我的切分策略是三层切分第一层按文档结构切。如果文档有明确的章节标题比如Markdown的##、Word的标题样式优先按章节切。这样每个块天然有语义边界。我写了一个解析器把标题层级提取出来作为每个块的元数据。第二层按语义切。对于没有明确结构的文档我用语义相似度做切分。具体做法是先把文本按句子切分然后计算相邻句子的向量相似度相似度低于阈值的地方就是切分点。这个方法比固定长度切分效果好很多但计算量大我只在关键文档上用。第三层按长度兜底。如果某个块还是太长超过模型上下文限制再用递归切分强制切断。但这时候我会加一个重叠窗口通常是块大小的10%-15%保证切断处的语义不会完全丢失。# 语义切分的核心逻辑示意 from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(BAAI/bge-m3) def semantic_split(text, threshold0.6): sentences split_into_sentences(text) embeddings model.encode(sentences) chunks [] current_chunk [sentences[0]] for i in range(1, len(sentences)): sim np.dot(embeddings[i-1], embeddings[i]) / ( np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i]) ) if sim threshold: chunks.append( .join(current_chunk)) current_chunk [sentences[i]] else: current_chunk.append(sentences[i]) chunks.append( .join(current_chunk)) return chunks注意语义切分的阈值不要设得太高否则会把完整段落切碎。我实测下来0.6-0.7之间比较平衡。另外语义切分对短文档效果不明显长文档超过5000字才值得用。3.2 向量化模型选型与批量处理的坑向量模型的选择直接决定检索效果。我试过OpenAI的text-embedding-3-large效果确实好但有两个问题一是成本二是数据合规。后来换成本地部署的BGE-M3效果差距在可接受范围内但成本和可控性好了很多。BGE-M3有几个特点值得注意它支持多语言中文效果在开源模型里属于第一梯队它支持长文本最大输入长度8192个token它还能同时输出稠密向量和稀疏向量这对混合检索非常友好。但用BGE-M3也有坑。第一个坑是批量大小。如果你一次性把几百个块塞进去编码显存很容易爆。我的经验是单卡24G显存批量大小设在32-64之间比较稳。第二个坑是归一化。BGE-M3输出的向量需要做L2归一化否则余弦相似度计算会出问题。第三个坑是指令前缀。BGE系列模型对查询和文档有不同的指令前缀查询要加为这个句子生成表示以用于检索相关文章文档不需要。这个细节如果不注意检索效果会打折扣。# BGE-M3 批量编码的正确姿势 from FlagEmbedding import BGEM3FlagModel model BGEM3FlagModel(BAAI/bge-m3, use_fp16True) # 文档编码不加指令前缀 doc_embeddings model.encode( documents, batch_size32, max_length8192, return_denseTrue, return_sparseTrue ) # 查询编码加指令前缀 query_embedding model.encode( [为这个句子生成表示以用于检索相关文章 query], batch_size1, max_length512, return_denseTrue, return_sparseTrue )3.3 混合检索为什么纯向量检索不够用纯向量检索有一个致命缺陷它对精确匹配不敏感。比如用户搜“GB/T 19001-2016”向量模型可能返回一堆关于“质量管理体系”的文档但就是找不到那个标准号。再比如用户搜“第三章第二节”向量模型完全无法理解这种结构化引用。所以我在向量检索之外加了一路BM25关键词检索。两路检索各自召回Top-K然后合并去重。合并策略有两种一种是简单加权向量检索权重0.7BM25权重0.3另一种是RRFReciprocal Rank Fusion按排名融合。我实测下来RRF更稳定因为它不依赖分数归一化。# RRF 融合示例 def rrf_fusion(vector_results, bm25_results, k60): scores {} for rank, doc_id in enumerate(vector_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) return sorted(scores.items(), keylambda x: x[1], reverseTrue)提示BM25的实现我推荐用rank_bm25这个库轻量够用。如果你的数据量很大可以考虑Elasticsearch但运维成本会上去。3.4 重排序召回放宽精排收紧混合检索召回的结果可能有几十条但真正能用的可能只有三五条。这时候就需要重排序。我用的BGE-Reranker是一个交叉编码器它会把查询和每个候选块拼在一起打分精度比向量相似度高很多但速度慢。所以我的策略是召回阶段放宽到Top-50精排阶段收紧到Top-5。这样既保证了召回率又控制了延迟。Reranker的批量大小也要注意我一般设在16-32之间再大延迟就明显了。重排序还有一个好处它可以过滤掉那些“看起来相似但实际无关”的块。我遇到过很多次向量检索返回的块和查询在字面上很像但内容完全不相关。Reranker能有效识别这种情况。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我假设你用的是Linux环境有NVIDIA显卡。如果没有显卡CPU也能跑但速度会慢很多。以下是核心依赖# 创建虚拟环境 python -m venv rag_env source rag_env/bin/activate # 核心依赖 pip install FlagEmbedding1.2.10 pip install pymilvus2.4.0 pip install rank_bm250.2.2 pip install unstructured0.12.0 pip install camelot-py0.11.0 pip install langchain0.2.0 pip install langchain-community0.2.0Milvus我用的是Docker部署单机版足够docker run -d --name milvus-standalone \ -p 19530:19530 \ -p 9091:9091 \ -v ./milvus_data:/var/lib/milvus \ milvusdb/milvus:v2.4.0注意Milvus的版本要和pymilvus匹配我遇到过版本不匹配导致连接失败的情况。另外Milvus的数据目录一定要挂载出来否则容器重启数据就没了。4.2 文档解析与清洗的完整流程文档解析我分成三步格式识别、内容提取、噪音清洗。格式识别很简单看文件后缀就行。但要注意有些PDF是扫描件需要用OCR。我用的OCR是PaddleOCR中文识别效果不错。内容提取用Unstructured但它的默认配置对中文支持一般。我改了几个参数from unstructured.partition.pdf import partition_pdf elements partition_pdf( filenamedoc.pdf, strategyhi_res, # 高精度模式保留布局信息 languages[chi_sim, eng], # 中文简体英文 infer_table_structureTrue, # 提取表格结构 include_page_breaksTrue # 保留分页信息 )噪音清洗我写了一套正则规则主要处理这几类问题页眉页脚通常出现在每页的固定位置我通过统计每页首尾行的重复频率来识别。页码纯数字行且出现在页面边缘。重复标题如果某个标题在多个页面重复出现只保留第一次。乱码非中文、非英文、非数字的连续字符。清洗完之后我会把文本按章节重组保留标题层级作为元数据。这一步很关键因为后面的检索过滤和溯源都依赖元数据。4.3 向量库的Schema设计与索引配置Milvus的Schema设计直接影响检索性能。我的Schema是这样的from pymilvus import CollectionSchema, FieldSchema, DataType fields [ FieldSchema(nameid, dtypeDataType.INT64, is_primaryTrue, auto_idTrue), FieldSchema(namedoc_id, dtypeDataType.VARCHAR, max_length128), FieldSchema(namechunk_text, dtypeDataType.VARCHAR, max_length8192), FieldSchema(namedense_vector, dtypeDataType.FLOAT_VECTOR, dim1024), FieldSchema(namesparse_vector, dtypeDataType.SPARSE_FLOAT_VECTOR), FieldSchema(namesource_file, dtypeDataType.VARCHAR, max_length512), FieldSchema(namechapter, dtypeDataType.VARCHAR, max_length256), FieldSchema(namepage_num, dtypeDataType.INT64) ] schema CollectionSchema(fields, descriptionRAG knowledge base)索引配置我用的是HNSW参数M16efConstruction200。这个配置在召回率和速度之间比较平衡。如果你更看重速度可以用IVF_FLAT但召回率会降一些。index_params { metric_type: IP, # 内积因为向量已经归一化 index_type: HNSW, params: {M: 16, efConstruction: 200} } collection.create_index(dense_vector, index_params)提示metric_type选IP还是L2取决于你的向量是否归一化。BGE-M3输出的是归一化向量用IP等价于余弦相似度。如果你不确定用COSINE最保险但性能会略低。4.4 检索链路的完整实现检索链路我封装成一个类核心方法就一个retrieveclass RAGRetriever: def __init__(self, milvus_collection, bm25_index, reranker): self.collection milvus_collection self.bm25 bm25_index self.reranker reranker def retrieve(self, query, top_k50, top_n5): # 查询改写 rewritten_query self.rewrite_query(query) # 向量检索 query_embedding model.encode( [为这个句子生成表示以用于检索相关文章 rewritten_query] ) vector_results self.collection.search( dataquery_embedding[dense_vecs], anns_fielddense_vector, param{metric_type: IP, params: {ef: 128}}, limittop_k, output_fields[chunk_text, source_file, chapter] ) # BM25检索 bm25_results self.bm25.get_top_n( rewritten_query, self.corpus, ntop_k ) # RRF融合 fused rrf_fusion(vector_results, bm25_results) # 重排序 candidates [self.get_chunk_text(doc_id) for doc_id, _ in fused[:top_k]] rerank_scores self.reranker.compute_score( [[rewritten_query, c] for c in candidates] ) reranked sorted( zip(candidates, rerank_scores), keylambda x: x[1], reverseTrue ) return reranked[:top_n]查询改写这一步很多人会忽略但它对效果影响很大。我的做法是用一个小模型比如Qwen2.5-1.5B把用户的口语化问题改写成更适合检索的形式。比如用户问“那个啥就是关于报销的流程是啥来着”改写成“报销流程 步骤 要求”。4.5 生成环节的Prompt设计与上下文拼接生成环节的Prompt我改了十几版最终稳定下来的模板是这样的你是一个知识库助手请根据以下参考资料回答用户问题。 参考资料 {context} 用户问题{question} 回答要求 1. 只根据参考资料回答不要编造信息。 2. 如果参考资料中没有相关信息直接说“根据现有资料无法回答”。 3. 回答时注明信息来源文件名和章节。 4. 如果参考资料中有矛盾信息指出矛盾并说明。上下文拼接也有讲究。我按Reranker分数从高到低排列但会做去重和截断。去重是防止同一个文档的多个块重复出现截断是保证总长度不超过模型的上下文限制。我一般保留Top-5个块总长度控制在3000 token以内。注意上下文不是越多越好。我试过把Top-10都塞进去结果模型反而被无关信息干扰回答质量下降。Top-5是一个比较平衡的值。5. 常见问题与排查技巧实录5.1 检索命中率低从查询侧和索引侧双向排查检索命中率低是最常见的问题。我的排查思路是先看查询侧再看索引侧。查询侧的问题包括查询太短、查询太口语化、查询包含错别字。解决办法是查询改写和查询扩展。查询扩展我用的是同义词词典LLM生成。比如用户搜“年假”扩展成“年假 年度休假 带薪休假”。索引侧的问题包括切分太碎、向量模型不适合领域、索引参数不合理。我遇到过一次切分太碎导致每个块只有一两句话向量模型无法捕捉完整语义。后来把块大小从200字调到500字命中率提升了30%。还有一个隐蔽的问题元数据过滤太严。我一开始为了精确加了很严格的元数据过滤结果把很多相关块过滤掉了。后来改成软过滤只在用户明确指定来源时才过滤。5.2 回答胡编乱造如何让模型“闭嘴”模型胡编是RAG的另一个大坑。我的解决办法有三层第一层是Prompt约束。上面那个Prompt模板里明确说了“只根据参考资料回答”但光靠这个不够。第二层是引用溯源。我要求模型在回答里注明每个信息的来源这样用户能自己判断。同时我在后处理里检查模型引用的来源是否真的在上下文里如果不在就标记为“可能不可靠”。第三层是置信度阈值。如果Reranker的最高分低于某个阈值我设的是0.5就直接返回“根据现有资料无法回答”不送给LLM生成。这个策略虽然会降低回答率但能大幅减少胡编。5.3 性能瓶颈从检索延迟到生成速度的优化性能问题我遇到过几次。第一次是检索延迟高排查发现是Milvus的索引没建好ef参数设得太小。后来把ef从64调到128召回率上去了延迟只增加了5ms。第二次是生成速度慢。Qwen2.5-14B在单卡A100上生成速度大概是30 token/s一个200字的回答要7秒左右。我的优化是用vLLM做推理加速开启连续批处理速度提升了3倍。第三次是内存占用高。BGE-M3和Reranker同时加载显存占用接近20G。我的解决办法是把Reranker放到CPU上跑虽然慢一点但显存压力小了很多。实测下来Reranker在CPU上处理50个候选块大概需要200ms可以接受。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果不相关切分太碎/向量模型不匹配人工检查Top-10结果调整切分策略/换模型专有名词搜不到纯向量检索对精确匹配不敏感测试带编号的查询加BM25混合检索回答胡编Prompt约束不够/上下文无关检查上下文和回答的对应关系加引用溯源/置信度阈值检索延迟高索引参数不合理/数据量大看Milvus的查询耗时调ef参数/加缓存生成速度慢模型太大/没有推理加速测token生成速度用vLLM/换小模型显存不够模型太多/批量太大看nvidia-smi模型分卡/减小批量表格内容丢失解析器不支持表格检查解析结果用Camelot单独提取多文档冲突没有冲突处理机制看回答是否矛盾Prompt里加冲突说明5.5 几个我踩过的坑和独家技巧坑一PDF解析的隐藏字符。有些PDF里藏着不可见字符比如零宽空格、软连字符。这些字符会让向量模型产生奇怪的向量。我的解决办法是在清洗阶段用正则把这些字符全部删掉。坑二向量库的删除操作。Milvus的删除是软删除数据不会立即释放。如果你频繁更新知识库数据会越积越多。我的做法是定期做compact或者干脆重建collection。坑三Reranker的输入长度限制。BGE-Reranker的最大输入长度是512 token如果你的块超过这个长度会被截断。我的做法是在切分阶段就控制块大小确保不超过512 token。技巧一用LLM做查询改写。我试过用规则做查询改写效果一般。后来换成用Qwen2.5-1.5B做改写效果好很多。成本也不高一次改写大概0.1秒。技巧二缓存高频查询。我统计了一下Top-20的查询占了总查询量的40%。所以我在检索前面加了一层Redis缓存命中缓存的查询直接返回延迟从200ms降到5ms。技巧三定期评估检索质量。我建了一个测试集包含100个问题和对应的标准答案。每次调整参数后跑一遍测试集看命中率和回答准确率的变化。这个习惯让我避免了很多“感觉变好了但实际变差了”的情况。6. 上线后的持续优化与扩展方向RAG系统上线不是终点而是起点。我上线后做了几件事第一是日志分析。我记录了每次查询的原始问题、改写后的问题、检索结果、生成回答、用户反馈。通过分析这些日志我发现了很多之前没注意到的问题。比如有些查询的改写完全跑偏了导致检索失败。第二是bad case收集。我让用户可以对回答点赞点踩点踩的case我会人工分析。分析了几百个bad case后我发现大部分问题集中在三类切分不合理、检索没命中、生成胡编。针对这三类我分别做了优化。第三是知识库更新。知识库不是一成不变的我建了一个定时任务每周扫描一次源文档目录有变化的文档自动重新解析、切分、向量化。但这里有个坑更新时要保证原子性不能出现新旧数据混在一起的情况。我的做法是建两个collection更新时写新collection更新完切换别名。第四是多路召回扩展。除了向量和BM25我还加了基于知识图谱的召回。把文档里的实体和关系抽出来建成一个简单的图谱。对于推理型查询图谱召回能提供向量检索找不到的信息。这块我还在探索目前效果有限但方向是对的。最后分享一个我个人的体会RAG的优化是一个迭代过程没有一劳永逸的方案。我一开始总想找一个“最优配置”后来发现根本不存在。不同的数据、不同的查询分布、不同的用户预期都需要不同的策略。所以我的建议是先跑通一个最小可用版本然后根据实际bad case持续迭代。每次只改一个变量改完跑测试集确认有效再继续。这样虽然慢但每一步都走得扎实。
返回列表