ARTICLE DETAIL

资讯详情

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

RAG系统实战:离线建库与在线查询两条链路的深度拆解与优化

RAG系统实战:离线建库与在线查询两条链路的深度拆解与优化 1. 为什么RAG值得你花时间搞懂两条链路RAG这个词这两年火得不行但很多人对它的理解还停留在“把文档塞进向量库然后搜出来拼到提示词里”这个层面。真到动手搭一套能用的系统时才发现事情远没有想象中那么简单——离线建库和在线查询这两条链路各自有各自的坑而且它们之间的衔接方式直接决定了整个系统的上限。我最早接触RAG是在一个内部知识库项目里当时团队觉得这东西不就是“文档切片Embedding向量检索”三步走吗结果第一版上线后检索命中率惨不忍睹用户问“报销流程是什么”系统返回的却是“报销标准对照表”里的表格碎片。后来花了整整两周时间重新梳理离线建库的每一个环节才把命中率从40%出头拉到85%以上。这段经历让我意识到RAG的功夫其实全在细节里。这篇文章面向的是已经了解RAG基本概念、准备动手搭建或者正在优化自己RAG系统的开发者。我会把离线建库和在线查询这两条链路彻底拆开讲清楚每个环节的设计意图、常见陷阱和实操方案。不管你是用LangChain、LlamaIndex还是自己手写流程底层的逻辑是相通的。读完你至少能明白为什么你的RAG检索不准、为什么同样的文档别人搭出来效果好、以及怎么根据自己业务场景做取舍。2. 离线建库链路决定RAG上限的隐形战场2.1 文档解析别让第一步就埋下隐患离线建库的第一步是文档解析这一步看起来最简单实际上最容易出问题。很多人拿到PDF直接往解析器里一扔出来什么就是什么结果后面检索效果差回头排查才发现是解析阶段就把内容搞乱了。文档解析的核心目标只有一个尽可能保留原文的语义结构和层级关系。为什么这么说因为后续的切片策略、Embedding质量都依赖解析结果的完整性。如果解析出来的文本丢失了标题层级、表格结构、列表关系那切片出来的内容就是一堆语义碎片Embedding再强也救不回来。不同格式的文档解析难度差异很大。纯文本和Markdown最好处理结构清晰、没有复杂的排版信息。Word文档次之python-docx这类库能提取段落和样式信息。PDF是最麻烦的尤其是扫描版PDF或者多栏排版的学术论文解析出来经常是乱序的。我一般会按照这个优先级选择解析方案文档类型推荐工具关键注意点Markdown/TXT直接读取保留标题层级标记Wordpython-docx / docx2python提取样式信息判断标题电子版PDFPyMuPDF / pdfplumber注意多栏排版和表格扫描版PDFOCR方案需要额外做版面分析HTMLBeautifulSoup / trafilatura去除导航和广告噪声实操心得解析完文档后一定要抽样检查。我通常会随机抽10%的文档人工看一眼解析结果重点检查标题是否保留、表格是否完整、列表项是否被正确识别。这个检查花不了多少时间但能避免后面大量的返工。还有一个容易被忽略的点文档的元数据提取。每篇文档的来源、标题、创建时间、所属分类这些信息在后续检索时可以作为过滤条件或者排序依据。比如用户问“最新的报销政策”如果你在元数据里存了文档的生效日期就可以优先返回最新的那一条。元数据不需要多复杂但一定要在建库阶段就规划好。2.2 切片策略粒度决定检索的精准度切片是离线建库中最核心的决策之一。切得太粗检索出来的内容包含太多无关信息浪费上下文窗口切得太细语义不完整Embedding质量下降。这个平衡点怎么找取决于你的业务场景。先说几种常见的切片策略固定长度切片是最简单的比如每500个字符切一刀相邻切片之间留50个字符的重叠。这种方式实现简单但问题是它完全不管语义边界经常把一句话从中间切断。对于结构规整的文档比如法律条文效果还行对于叙述性内容效果就很差。基于分隔符的切片是LangChain里最常用的方式按照段落、换行、句号等分隔符来切。这种方式比固定长度好一些因为它尊重了文本的自然边界。但问题在于如果某个段落特别长比如超过2000字还是需要二次切分。基于语义的切片是效果最好的但实现也最复杂。核心思路是先对文本做语义分段然后在语义边界处切开。常见做法是用Embedding计算相邻句子的相似度相似度低的地方就是潜在的切分点。这种方式能保证每个切片内部语义连贯但计算成本高适合对检索质量要求极高的场景。基于文档结构的切片是我个人最推荐的方式。如果文档本身有清晰的层级结构标题、小节、段落就按照这个结构来切。比如一篇技术文档可以按照“章-节-段”的层级把每个小节作为一个切片如果小节太长再按段落细分。这种方式切出来的内容语义完整而且天然带有层级信息检索时可以优先返回更具体的切片。# 基于Markdown标题层级的切片示例 import re def split_by_headers(text): # 按照标题层级切分 sections re.split(r\n(?#{1,3}\s), text) chunks [] for section in sections: if len(section.strip()) 50: continue # 如果section太长按段落二次切分 if len(section) 1500: paragraphs section.split(\n\n) current_chunk for para in paragraphs: if len(current_chunk) len(para) 1000: chunks.append(current_chunk.strip()) current_chunk para else: current_chunk \n\n para if current_chunk: chunks.append(current_chunk.strip()) else: chunks.append(section.strip()) return chunks切片大小怎么定我的经验值是中文内容300-800字英文内容200-500词。这个范围是基于Embedding模型的最佳输入长度和检索精度的平衡。太小了语义不完整太大了噪声太多。当然这只是一个起点具体还要根据你的文档特点和检索效果来调。注意事项切片之间的重叠很重要。我一般设置10%-20%的重叠比例确保跨切片边界的语义不会被完全切断。比如一个概念的定义在前一个切片的末尾解释在后一个切片的开头如果没有重叠检索时可能只召回其中一个导致信息不完整。2.3 Embedding模型选型不是越贵越好Embedding模型的选择直接决定了检索的质量。市面上的模型五花八门从OpenAI的text-embedding-3系列到开源的BGE、M3E、GTE到底怎么选先明确一个原则没有最好的模型只有最适合你场景的模型。选型时需要考虑几个维度语言支持。如果你的知识库全是中文那选一个中文优化过的模型比如BGE-large-zh、M3E-base会比通用的多语言模型效果好。如果是中英混合就要选多语言能力强的。维度与性能的权衡。Embedding维度越高表达能力越强但存储和计算成本也越高。OpenAI的text-embedding-3-large是3072维small是1536维。开源的BGE-large是1024维base是768维。对于大多数中小规模的知识库几万到几十万条切片768-1024维完全够用。部署方式。调用API的方式最省事但有网络延迟和数据隐私的顾虑。本地部署开源模型需要GPU资源但数据不出域而且可以针对自己的领域做微调。我整理了一个常见模型的对比模型维度语言部署方式适用场景text-embedding-3-small1536多语言API快速验证、中小规模text-embedding-3-large3072多语言API高质量要求BGE-large-zh-v1.51024中文本地中文知识库BGE-m31024多语言本地多语言混合M3E-base768中文本地资源受限场景GTE-large1024多语言本地通用场景实操心得选模型时不要只看排行榜上的分数。MTEB排行榜上的高分模型不一定适合你的领域。我建议的做法是准备100-200个你业务场景下的真实查询和对应的正确答案用几个候选模型分别跑一遍检索看命中率。这个测试花不了半天时间但能帮你省下后面大量的调优成本。还有一个容易被忽略的点Embedding模型的版本一致性。离线建库用的模型和在线查询用的模型必须是同一个版本。如果建库时用的是BGE-large-zh-v1.5查询时换成了v1.0向量空间不一致检索结果会完全乱掉。这个坑我踩过排查了大半天才想起来是模型版本的问题。2.4 向量库选型与索引构建存储与检索的基石向量库的选择取决于你的数据规模、查询频率和运维能力。小规模场景几万条以下用FAISS就够了单机内存就能扛住。中等规模几十万到几百万条可以考虑Milvus、Qdrant、Weaviate这些专业的向量数据库。大规模千万级以上就需要考虑分布式方案了。FAISS是Facebook开源的向量检索库严格来说它不是一个数据库而是一个索引库。它的优势是轻量、快、无需额外部署服务。缺点是它不提供持久化、过滤、更新这些数据库功能需要自己封装。Milvus和Qdrant是专门为向量检索设计的数据库支持持久化、标量过滤、分布式部署。Milvus功能更全但部署复杂Qdrant更轻量但功能也很完善。选哪个看你的团队技术栈和运维能力。索引类型的选择也很关键。以FAISS为例常用的索引类型有IndexFlatL2暴力检索精度最高但速度最慢适合小规模数据IndexIVFFlat倒排索引速度快但精度有损失需要训练IndexHNSWFlat基于图的索引速度和精度平衡得比较好import faiss import numpy as np # 假设embeddings是numpy数组shape为(n, dim) dim embeddings.shape[1] # 小规模数据直接用Flat索引 index faiss.IndexFlatL2(dim) index.add(embeddings) # 中等规模用IVF索引 quantizer faiss.IndexFlatL2(dim) nlist 100 # 聚类中心数量一般取sqrt(n) index_ivf faiss.IndexIVFFlat(quantizer, dim, nlist) index_ivf.train(embeddings) index_ivf.add(embeddings) index_ivf.nprobe 10 # 查询时搜索的聚类数量注意事项IVF索引需要训练训练数据的质量和数量直接影响索引效果。一般建议训练数据不少于聚类中心数量的39倍。如果数据量不够就老老实实用Flat索引。建库完成后一定要做一次全量检索测试。随机抽一批查询看返回结果是否合理。我通常会看三个指标Top-1命中率、Top-5命中率、以及返回结果的平均相似度分数。如果Top-5命中率低于70%说明建库环节有问题需要回头检查切片和Embedding。3. 在线查询链路从用户输入到精准答案3.1 查询理解与改写别让用户的问法限制你的检索用户输入的问题往往和知识库里的表述方式不一致。用户问“怎么报销”知识库里写的是“费用报销流程说明”。如果直接拿用户的原始查询去检索很可能匹配不上。查询改写要解决的就是这个“表述鸿沟”问题。常见的改写策略有几种同义词扩展是最基础的。维护一个领域相关的同义词表把用户查询中的关键词替换或扩展成多个变体。比如“报销”可以扩展成“费用报销、报销流程、报销申请”。这种方式实现简单但覆盖面有限。基于LLM的查询改写是现在的主流做法。把用户查询和知识库的领域信息一起给LLM让它生成几个不同表述的查询变体。比如用户问“报销怎么弄”LLM可以改写成“费用报销的流程是什么”、“如何申请报销”、“报销需要哪些步骤”。然后拿这几个变体分别去检索合并结果。# 基于LLM的查询改写示例 def rewrite_query(original_query, domain_context): prompt f你是一个查询改写助手。根据以下领域背景将用户的查询改写成3个不同表述的查询以提高检索命中率。 领域背景{domain_context} 用户查询{original_query} 请直接输出改写后的查询每行一个 # 调用LLM获取改写结果 rewritten llm.generate(prompt) queries [original_query] rewritten.strip().split(\n) return queriesHyDEHypothetical Document Embeddings是一种更高级的改写方式。核心思路是先让LLM根据用户查询生成一个假设性的答案文档然后用这个假设文档的Embedding去检索。为什么这样做有效因为假设文档的表述方式和知识库里的真实文档更接近向量空间里的距离更近。实操心得查询改写不是越多越好。我试过生成10个变体结果检索时间翻了好几倍但命中率只提升了不到5%。后来固定在3-5个变体性价比最高。另外改写后的查询要去重避免多个变体高度相似导致检索结果冗余。3.2 向量检索与混合检索召回阶段的核心策略向量检索是在线查询链路的核心。用户查询经过Embedding后在向量库中查找最相似的Top-K个切片。这个过程看似简单但有几个关键参数需要调优。Top-K的选择。K太小可能漏掉相关结果K太大引入噪声且增加后续处理成本。我的经验是如果后续有重排序环节K可以设大一些20-50让重排序来筛选如果没有重排序K设在5-10比较合适。相似度阈值。设置一个最低相似度阈值低于这个值的直接过滤掉。这个阈值需要根据你的Embedding模型和业务场景来定。我一般会先跑一批测试查询看正确结果的相似度分布然后取一个能过滤掉大部分噪声但不会漏掉正确结果的值。纯向量检索有一个天然缺陷它对关键词的精确匹配不敏感。比如用户搜一个产品型号“XR-2000”向量检索可能返回一堆语义相似但型号不同的产品。这时候就需要混合检索来补足。混合检索的核心思路是同时做向量检索和关键词检索比如BM25然后合并两路结果。合并策略有几种加权求和向量相似度和BM25分数各乘一个权重后相加RRFReciprocal Rank Fusion基于排名的融合不依赖分数绝对值级联先用关键词检索缩小范围再在范围内做向量检索RRF是我最常用的方式因为它不需要调权重对不同检索器的分数尺度不敏感。公式很简单对于每个文档RRF分数 Σ(1/(k rank_i))其中rank_i是文档在第i路检索中的排名k是一个常数通常取60。def reciprocal_rank_fusion(vector_results, keyword_results, k60): RRF融合两路检索结果 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(keyword_results): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) # 按分数降序排列 sorted_docs sorted(scores.items(), keylambda x: x[1], reverseTrue) return [doc_id for doc_id, _ in sorted_docs]注意事项混合检索的关键词检索部分需要根据你的文档语言做分词。中文分词用jieba英文直接用空格切分。如果文档里有大量专业术语建议自定义词典避免专业词汇被切碎。3.3 重排序让最相关的结果浮上来检索回来的Top-K结果顺序往往不是最优的。向量相似度高不代表内容真正相关尤其是当K比较大的时候前面几个结果可能只是“看起来像”但实际答非所问。重排序就是来解决这个问题的。重排序模型Reranker和Embedding模型的工作方式不同。Embedding模型是双塔结构查询和文档分别编码后计算相似度速度快但精度有限。Reranker是交叉编码器把查询和文档拼在一起输入模型输出一个相关性分数精度高但速度慢。所以典型的流程是先用Embedding检索召回Top-50再用Reranker对这50个结果精排取Top-5返回。常见的Reranker模型有BGE-reranker系列、Cohere Rerank等。BGE-reranker-base和large都是开源的本地部署方便。Cohere Rerank是API方式效果很好但需要付费。from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-large, use_fp16True) def rerank(query, candidates, top_n5): 对候选文档进行重排序 pairs [[query, doc] for doc in candidates] scores reranker.compute_score(pairs) # 按分数排序 scored_docs list(zip(candidates, scores)) scored_docs.sort(keylambda x: x[1], reverseTrue) return [doc for doc, _ in scored_docs[:top_n]]实操心得Reranker的引入会显著增加查询延迟。如果你的系统对响应时间要求高比如要求1秒内返回Reranker可能会成为瓶颈。这时候可以考虑用轻量级的Reranker比如bge-reranker-base或者只对Top-20做重排序而不是Top-50。另外Reranker和Embedding模型最好来自同一个系列比如都用BGE系列兼容性更好。3.4 上下文组装与答案生成最后一步的取舍检索到相关切片后需要把它们组装成LLM能理解的上下文然后生成答案。这一步看似简单但有几个关键决策。上下文长度控制。LLM的上下文窗口是有限的不能把所有检索结果都塞进去。需要根据LLM的最大输入长度、检索结果的数量和每个切片的长度来计算。一般来说检索结果的总token数不要超过LLM上下文窗口的60%留出空间给系统提示词和用户查询。切片排序。检索结果在上下文中的顺序会影响LLM的注意力。有研究表明LLM对上下文开头和结尾的信息关注度更高。所以重要的切片应该放在开头或结尾次要的放在中间。我通常会把Reranker分数最高的放在最前面。提示词设计。提示词要明确告诉LLM只根据提供的上下文回答不要编造信息。如果上下文里没有答案就明确说“根据现有信息无法回答”。这个约束非常重要能大幅减少幻觉。def build_prompt(query, contexts): context_text \n\n---\n\n.join(contexts) prompt f你是一个知识库问答助手。请严格根据以下提供的上下文信息回答用户问题。 要求 1. 只使用上下文中明确提到的信息不要自行推断或编造 2. 如果上下文不足以回答问题请直接说根据现有信息无法回答该问题 3. 回答要简洁准确引用具体的上下文内容 上下文信息 {context_text} 用户问题{query} 回答 return prompt注意事项上下文组装时要注意切片之间的重复内容。如果多个检索结果来自同一文档的相邻切片可能会有重叠部分。我一般会在组装前做一次去重把重叠度超过80%的切片合并或去掉。4. 两条链路的衔接与协同优化4.1 离线与在线的接口设计离线建库和在线查询虽然是两条独立的链路但它们之间的接口设计直接影响系统的灵活性和可维护性。核心接口包括向量库的读写接口、Embedding服务的调用接口、以及元数据的查询接口。我建议把Embedding服务封装成一个独立的模块离线建库和在线查询都调用同一个接口。这样做的好处是模型版本统一管理切换模型时只需要改一个地方而且可以在接口层做缓存避免重复计算。class EmbeddingService: def __init__(self, model_name, devicecuda): self.model load_model(model_name, device) self.cache {} def encode(self, texts, batch_size32): 统一的Embedding接口 results [] for i in range(0, len(texts), batch_size): batch texts[i:ibatch_size] # 检查缓存 uncached [t for t in batch if t not in self.cache] if uncached: embeddings self.model.encode(uncached) for text, emb in zip(uncached, embeddings): self.cache[text] emb results.extend([self.cache[t] for t in batch]) return results向量库的接口也要统一。离线建库时写入在线查询时读取两个操作使用同一套连接配置和集合名称。我见过有人离线建库写到了collection A在线查询读的是collection B排查了半天才发现是配置不一致。4.2 数据一致性保障离线建库不是一次性的工作知识库需要持续更新。新文档加入、旧文档修改、过期文档删除这些操作都要同步到向量库。如果处理不当就会出现“离线更新了但在线查不到”或者“在线查到了已删除的内容”这类问题。我的做法是给每个切片分配一个唯一ID这个ID由文档ID和切片序号组成。更新文档时先根据文档ID删除旧的切片再插入新的切片。删除文档时根据文档ID批量删除。这样能保证向量库和原始文档的一致性。def update_document(doc_id, new_content, vector_store, embedding_service): 更新文档先删后插 # 删除旧切片 vector_store.delete(filter{doc_id: doc_id}) # 重新切片和Embedding chunks split_document(new_content) embeddings embedding_service.encode(chunks) # 插入新切片 for i, (chunk, emb) in enumerate(zip(chunks, embeddings)): vector_store.insert({ id: f{doc_id}_{i}, doc_id: doc_id, content: chunk, embedding: emb })实操心得批量更新时要注意事务性。如果更新到一半失败了向量库可能处于不一致的状态。我一般会先在一个临时集合里完成所有更新操作确认无误后再原子性地切换别名。Milvus和Qdrant都支持集合别名用起来很方便。4.3 效果评估与迭代闭环RAG系统上线后需要持续监控和优化。没有评估就没有优化我一般会从两个层面做评估检索层面和生成层面。检索层面的核心指标是命中率Hit Rate和MRRMean Reciprocal Rank。命中率衡量的是正确文档是否出现在Top-K结果中MRR衡量的是正确文档的排名位置。这两个指标可以通过人工标注一批测试查询来计算。生成层面的评估更复杂可以用LLM来打分比如让GPT-4评估答案的准确性和完整性也可以人工抽检。我通常会维护一个测试集包含50-100个典型问题和标准答案每次系统有改动就跑一遍看指标有没有下降。def evaluate_retrieval(test_queries, ground_truth, retriever, top_k5): 评估检索效果 hit_count 0 mrr_sum 0 for query, correct_doc_id in test_queries: results retriever.search(query, top_ktop_k) result_ids [r[doc_id] for r in results] if correct_doc_id in result_ids: hit_count 1 rank result_ids.index(correct_doc_id) 1 mrr_sum 1 / rank hit_rate hit_count / len(test_queries) mrr mrr_sum / len(test_queries) return {hit_rate: hit_rate, mrr: mrr}5. 常见问题与排查技巧实录5.1 检索不准的排查思路检索不准是最常见的问题表现是返回的结果和用户查询不相关。排查时按照从后往前的顺序先看检索结果再看Embedding最后看切片。第一步检查检索结果本身。把用户查询和返回的Top-K切片打印出来人工判断相关性。如果明显不相关进入下一步。第二步检查Embedding质量。拿几个已知相关的查询-文档对计算它们的Embedding相似度。如果相似度很低比如低于0.5说明Embedding模型不适合你的领域或者文档切片有问题。第三步检查切片内容。看切片是否语义完整。如果切片都是断句碎片那Embedding质量肯定差。这时候需要调整切片策略。第四步检查查询表述。用户查询和文档表述差异太大时向量检索很难匹配。这时候需要引入查询改写或混合检索。我整理了一个排查速查表现象可能原因排查方法解决方案返回结果完全不相关Embedding模型不匹配计算已知相关对的相似度换模型或微调返回结果部分相关切片粒度过粗检查切片长度和内容调整切片策略关键词匹配但语义不匹配纯向量检索的缺陷对比关键词检索结果引入混合检索正确结果排名靠后缺少重排序看正确结果的排名分布加入Reranker相似查询结果差异大查询表述不一致对比不同表述的检索结果查询改写5.2 性能瓶颈的定位与优化RAG系统的性能瓶颈通常出现在三个地方Embedding计算、向量检索、LLM生成。定位瓶颈最简单的方法是打日志记录每个环节的耗时。Embedding计算慢的话可以考虑用GPU加速、批量处理、或者换更轻量的模型。向量检索慢的话检查索引类型是否合适IVF的nprobe参数是否太大或者考虑用GPU版的FAISS。LLM生成慢的话可以考虑流式输出、换更小的模型、或者减少上下文长度。实操心得我遇到过一个性能问题查询延迟忽高忽低。排查后发现是Embedding服务的缓存没有做并发控制多个请求同时计算同一个文本的Embedding导致重复计算。加了一个简单的锁机制后就稳定了。这种问题在单机测试时不容易发现上线后并发一上来就暴露了。5.3 知识库更新的坑知识库更新时最容易出的问题是新旧数据混在一起。比如修改了一篇文档旧切片没删干净新切片又加进去了检索时新旧内容都返回用户看到的是过时信息。避免这个问题的关键是更新操作要原子化。要么全成功要么全失败不能出现中间状态。我一般会用版本号来管理每次更新生成一个新版本检索时只查最新版本。旧版本保留一段时间后清理。另一个坑是Embedding模型升级。如果换了Embedding模型整个向量库都需要重建。因为新旧模型的向量空间不兼容混在一起检索结果会乱掉。所以换模型时一定要全量重建不能增量更新。6. 我踩过的坑和最后分享几个技巧先说一个让我印象最深的坑。有一次帮朋友调一个RAG系统检索效果时好时坏好的时候能到90%命中率差的时候只有30%。排查了很久最后发现是文档解析时有些PDF的编码有问题解析出来的文本里混入了大量不可见字符。这些字符影响了Embedding的质量导致同一篇文档的不同切片在向量空间里距离很远。后来加了一个文本清洗步骤把所有不可见字符和非标准标点都过滤掉问题就解决了。这个经历告诉我数据清洗虽然不起眼但绝对不能省。另一个坑是关于切片重叠的。我一开始觉得重叠越多越好设置了50%的重叠比例。结果向量库膨胀了一倍多检索时经常返回高度相似的重复内容。后来把重叠降到15%效果反而更好了。重叠的目的是保证语义连续性不是越多越好15%-20%足够了。最后分享几个实用技巧。第一个是给切片加标题前缀。在切片内容前面加上所属章节的标题比如“费用报销流程 申请步骤...”。这样Embedding时能包含层级信息检索时更容易匹配到相关章节。第二个是用查询日志来优化。把用户的实际查询记录下来定期分析哪些查询命中率低针对性地补充文档或调整切片。第三个是建立反馈闭环。让用户可以对答案点赞或点踩这些反馈数据是优化系统最宝贵的资源。RAG这个领域变化很快新的模型和技术层出不穷。但不管技术怎么变离线建库和在线查询这两条链路的底层逻辑是不变的。把这两条链路吃透不管换什么工具和模型你都能快速搭出一套可用的系统。
返回列表