ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

检索增强生成实战:从分块到重排序的RAG系统优化指南

检索增强生成实战:从分块到重排序的RAG系统优化指南

1. 从“rea”这个标题说起:一个被低估的检索增强范式

第一次看到“rea”这个标题,很多人会一头雾水——三个字母,既不像缩写,也不像完整单词。但如果你最近在折腾大模型应用,尤其是想让模型“说人话、讲事实、别瞎编”,那你大概率已经撞上了它背后的那套东西:Retrieval-Augmented,也就是检索增强。而“rea”这个极简标题,恰恰是这类项目最典型的命名风格——作者懒得解释,直接把核心机制的首字母拎出来当名字,剩下的全靠代码和文档说话。

我接触这类项目差不多有一年多时间,从最早的朴素检索拼接,到后来的重排序、查询改写、多路召回,踩过的坑能写满一个笔记本。今天这篇博文,我就围绕“rea”这个标题,把它背后可能涉及的核心领域、技术选型、实操步骤和避坑经验,掰开揉碎了讲清楚。不管你是刚入门的新手,还是已经做过几个Demo的老手,都能从中找到可以直接抄作业的部分。

先说清楚这个内容是什么。“rea”本质上是一个检索增强生成(RAG)的轻量级实现或范式代称,它解决的核心问题是:大模型的知识截止于训练数据,面对私有文档、实时信息、垂直领域知识时,要么答不上来,要么一本正经地胡说八道。检索增强的思路很直接——在模型生成答案之前,先去外部知识库里把相关片段找出来,塞进上下文,让模型“看着材料答题”。这就像开卷考试,允许你翻书,正确率自然比闭卷高出一大截。

适合谁来参考?三类人最应该往下看。第一类是做企业知识库问答的开发者,手里有一堆PDF、Word、网页,想让模型基于这些材料回答问题;第二类是做智能客服、法律咨询、医疗辅助等垂直场景的工程师,对答案准确性要求极高,不能容忍模型自由发挥;第三类是对RAG感兴趣但一直没动手的学习者,想找一个结构清晰、可复现的入门路径。接下来我会从整体设计、核心细节、实操过程、问题排查四个维度展开,每个部分都配上我实际踩坑后总结的经验。

2. 内容整体设计与思路拆解

2.1 为什么是“检索+生成”而不是“微调”

很多人第一反应是:想让模型懂我的数据,直接微调不就行了?我早期也这么想,后来发现微调这条路对大多数团队来说性价比极低。微调需要准备高质量的问答对,动辄几千上万条,标注成本高;训练一次要烧GPU,迭代周期长;更麻烦的是,知识一旦更新,模型就得重新训练,否则它记住的还是旧版本。而检索增强把“知识”和“能力”拆开了——模型负责理解和生成,知识库负责提供事实,更新知识只需要替换文档,不用碰模型参数。

“rea”这类项目选择检索增强路线,背后的考量很务实:用工程手段解决知识时效性和私有性问题,而不是用训练手段。这就像你不需要为了读一本新书去重新上大学,只需要把书借来翻到对应章节就行。当然,检索增强也不是银弹,它的上限取决于检索质量——如果找回来的片段不相关,模型再强也答不对。所以这类项目的核心工程量,八成花在检索链路的优化上。

2.2 整体架构的四个关键模块

一个完整的“rea”式系统,通常包含四个模块,我用做菜来类比。文档处理模块相当于备菜,把各种格式的原始材料切好、洗净、分类;索引构建模块相当于把菜放进冰箱的固定位置,方便随时取用;检索模块相当于根据菜谱去冰箱里找对应的食材;生成模块相当于厨师根据拿到的食材炒出一盘菜。四个环节环环相扣,任何一个环节拉胯,最终答案都会出问题。

具体到技术选型,文档处理阶段要解决格式解析和分块两个问题。格式解析就是把PDF、HTML、Markdown、Word等转成纯文本,这一步的坑最多,后面会细说。分块是把长文本切成适合检索的小段,切得太碎会丢失上下文,切得太大会引入噪声,通常需要根据文档类型调整策略。索引构建阶段主流方案是向量索引,把每个文本块通过嵌入模型转成向量,存进向量数据库。检索阶段包括查询向量化、相似度计算、返回Top-K结果,进阶做法还会加重排序和查询改写。生成阶段就是把检索结果拼进提示词,调用大模型输出答案。

2.3 方案选型的三个权衡点

第一个权衡是嵌入模型的选择。开源的有BGE、M3E、GTE等系列,闭源的有各家API。我的经验是,中文场景优先试BGE-large-zh,它在中文语义匹配上表现稳定,而且可以本地部署,没有调用成本。如果追求极致效果且预算充足,可以上闭源API,但要注意数据隐私问题。第二个权衡是向量数据库的选择。轻量级场景用FAISS就够了,单机内存索引,几百万条数据毫无压力;需要持久化、多用户、过滤条件复杂时,Milvus、Qdrant、Weaviate这些专业向量库更合适。第三个权衡是是否引入重排序。重排序模型(如BGE-Reranker)会对初步检索的结果做精细打分,能显著提升Top结果的准确性,代价是增加一次模型推理,延迟会上升几百毫秒。对准确性要求高的场景,这个代价值得付。

提示:不要一上来就追求全链路最优。我见过不少项目卡在选型阶段,纠结用哪个嵌入模型、哪个向量库,结果两周过去一行代码没写。正确做法是先跑通最小闭环,用最简单的方案(比如FAISS + BGE-small),拿到端到端效果后,再针对瓶颈逐项优化。

3. 核心细节解析与实操要点

3.1 文档分块:最容易被忽视的胜负手

分块策略直接决定了检索质量的上限。我做过一个对比实验:同一份技术文档,分别用固定长度分块、按段落分块、按语义分块三种方式处理,然后问同一个问题,检索回来的片段相关性差异巨大。固定长度分块最简单,比如每500个字符切一刀,但很容易把一句话拦腰截断,导致语义不完整。按段落分块尊重文档的自然结构,效果通常更好,但遇到超长段落还是得二次切分。按语义分块最理想,用嵌入模型判断相邻句子的语义相似度,在语义转折处切分,但计算成本高,实现复杂度也高。

我的实操建议是:默认按段落分块,设置最大块长为512个token,块间重叠64个token。重叠是为了防止关键信息刚好落在切分边界上被割裂。对于代码文档、法律条文这类结构极强的材料,可以按章节或条款分块,保留层级标题作为元数据。元数据很重要,检索时可以按来源、时间、类别做过滤,比如只检索最近半年的文档,或者只检索某个产品线的资料。

还有一个细节:给每个文本块加上来源标识和上下文摘要。来源标识用于追溯答案出处,上下文摘要是一句话概括这个块讲什么,检索时把摘要也纳入匹配范围,能提升召回率。这个技巧是我从一次失败案例里学到的——用户问“XX功能的限制条件是什么”,答案分散在三个段落里,单纯靠段落原文匹配很难全部召回,但加上摘要后,三个段落都被准确找出来了。

3.2 嵌入与索引:向量化背后的门道

嵌入模型把文本转成高维向量,语义相近的文本在向量空间里距离更近。这里有个常见误区:不是维度越高越好。高维度向量表达能力强,但存储和计算成本也高,而且当数据量不大时,高维反而容易过拟合。BGE-large-zh是1024维,BGE-small-zh是512维,对于几万条文本块的场景,512维完全够用,检索速度还快一倍。

索引构建时要注意归一化。大多数嵌入模型输出的向量需要做L2归一化,这样相似度计算就可以用内积代替余弦相似度,速度更快。FAISS里用IndexFlatIP配合归一化向量,就是标准做法。如果数据量超过百万级,可以考虑IndexIVFFlat做近似检索,牺牲一点召回率换速度。但我的经验是,百万级以下的数据,Flat索引的暴力检索完全扛得住,没必要过早引入近似索引,否则调参成本反而更高。

注意:嵌入模型和检索时的查询向量必须用同一个模型生成。我踩过一次坑,索引时用了A模型,检索时手滑调了B模型的接口,结果相似度分数完全乱套,排查了半天才发现是模型不一致。建议在代码里把模型名称写成常量,索引和检索共用。

3.3 提示词组装:让模型“看着材料说话”

检索回来的片段怎么塞给模型,直接影响到最终答案的质量。最朴素的拼法是:把Top-K个片段按顺序拼接,前面加一句“请根据以下材料回答问题”。但这种做法有两个问题:一是片段之间可能重复或矛盾,模型会困惑;二是模型可能忽略材料,凭自己的记忆回答。改进做法是在提示词里明确约束:“如果材料中没有相关信息,请回答‘根据现有资料无法回答’,不要编造。”这句话能大幅降低幻觉率。

另一个技巧是给每个片段编号,并要求模型在答案中标注引用来源。比如“根据[1]和[3]的描述,……”。这样做有两个好处:一是方便用户验证答案可信度,二是模型在标注引用时会更认真地阅读材料,间接提升答案准确性。我实测下来,加了引用标注要求后,答案的事实错误率下降了约三成。

提示词的长度也要控制。大模型的上下文窗口虽然越来越大,但塞太多片段会稀释关键信息,而且增加调用成本。我的经验是Top-K取3到5个,每个片段控制在300字以内,总提示词长度不超过2000字。如果检索结果确实很多,可以先做一轮重排序,只保留最相关的几个。

3.4 重排序与查询改写:进阶优化的两个抓手

重排序是提升检索精度的利器。初步检索用向量相似度召回Top-20,然后用重排序模型对这20个结果做精细打分,取Top-5送给生成模型。重排序模型通常是交叉编码器,把查询和文档拼在一起输入,输出相关性分数,比向量内积更准。BGE-Reranker系列是开源方案里比较好用的,中文效果不错。代价是每个候选都要过一次模型,20个候选大概增加200到500毫秒延迟。对延迟不敏感的场景,强烈建议加上。

查询改写解决的是“用户问法和文档写法不一致”的问题。用户问“怎么退钱”,文档里写的是“退款流程”,字面不匹配但语义相同,向量检索通常能处理。但如果用户问“我买错了想退”,文档里写“七天无理由退货”,这就需要查询改写了。简单做法是用大模型把用户问题改写成多个相关查询,分别检索后合并结果。比如把“我买错了想退”改写成“退货流程”“退款条件”“七天无理由退货”,三路召回后去重,召回率明显提升。这个技巧在电商客服场景特别管用。

4. 实操过程与核心环节实现

4.1 环境准备与依赖安装

先搭一个最小可运行的环境。Python版本建议3.10以上,太老的版本有些库不支持。核心依赖包括:sentence-transformers用于加载嵌入模型和重排序模型,faiss-cpu或faiss-gpu用于向量索引,pypdf和python-docx用于文档解析,langchain或llama-index可选,它们封装了很多常用流程,但我的建议是初期自己手写一遍核心逻辑,理解每个环节在做什么,后期再考虑用框架提效。

pip install sentence-transformers faiss-cpu pypdf python-docx numpy

如果要用GPU加速嵌入计算,把faiss-cpu换成faiss-gpu,并确保CUDA环境配置正确。嵌入模型首次加载会从网络下载,国内环境可能较慢,可以提前把模型文件下载到本地,用绝对路径加载。

4.2 文档解析与分块实现

假设你手里有一批PDF文档,第一步是把它们转成纯文本。pypdf的extract_text()能处理大部分文本型PDF,但扫描件PDF需要OCR,那是另一个话题。解析出来的文本往往带有页眉页脚、页码、乱码,需要做清洗:去掉连续空行、去掉纯数字行、合并被硬换行拆散的句子。

from pypdf import PdfReader def extract_text_from_pdf(path): reader = PdfReader(path) full_text = [] for page in reader.pages: text = page.extract_text() if text: full_text.append(text) return "\n".join(full_text)

分块函数的核心逻辑是按段落切分,再按最大长度合并。我写了一个简化版:

def split_into_chunks(text, max_len=512, overlap=64): paragraphs = [p.strip() for p in text.split("\n") if p.strip()] chunks = [] current = "" for para in paragraphs: if len(current) + len(para) <= max_len: current += para + "\n" else: if current: chunks.append(current.strip()) # 处理超长段落 while len(para) > max_len: chunks.append(para[:max_len]) para = para[max_len - overlap:] current = para + "\n" if current.strip(): chunks.append(current.strip()) return chunks

这段代码的逻辑是:逐段累加,超过最大长度就切一刀,同时保留重叠部分。重叠的实现是在超长段落切分时保留尾部,普通段落切换时不做重叠,因为段落本身是语义完整的。实际项目中,我会给每个chunk附加元数据,比如来源文件名、页码、块序号,存成一个JSON列表。

4.3 向量索引构建与检索

加载嵌入模型,把所有chunk转成向量,构建FAISS索引。这里用BGE-small-zh做演示,它的模型体积小、速度快,适合快速验证。

from sentence_transformers import SentenceTransformer import faiss import numpy as np model = SentenceTransformer("BAAI/bge-small-zh-v1.5") def build_index(chunks): embeddings = model.encode(chunks, normalize_embeddings=True) dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) index.add(embeddings.astype(np.float32)) return index, embeddings def search(query, index, chunks, top_k=5): q_vec = model.encode([query], normalize_embeddings=True) scores, indices = index.search(q_vec.astype(np.float32), top_k) results = [] for score, idx in zip(scores[0], indices[0]): results.append({"score": float(score), "text": chunks[idx]}) return results

normalize_embeddings=True做了L2归一化,配合IndexFlatIP内积索引,等价于余弦相似度。检索时返回Top-K的分数和文本,分数越高越相关。实测中,BGE-small-zh在中文问答上的Top-5召回率能到80%左右,换large模型能提升到90%以上,但速度慢三倍左右。

4.4 生成模块与提示词模板

生成模块调用大模型API,把检索结果拼进提示词。下面是一个经过多次迭代的模板:

PROMPT_TEMPLATE = """你是一个严谨的问答助手。请根据以下参考资料回答问题。 参考资料: {context} 问题:{question} 要求: 1. 答案必须基于参考资料,不要使用你自己的知识。 2. 如果参考资料中没有相关信息,回答"根据现有资料无法回答"。 3. 在答案中标注引用的资料编号,如[1]、[2]。 4. 答案要简洁准确,不要展开无关内容。 答案:"""

context部分把检索结果格式化成编号列表:

def format_context(results): lines = [] for i, r in enumerate(results, 1): lines.append(f"[{i}] {r['text']}") return "\n\n".join(lines)

调用大模型时,温度参数建议设低一些,0.1到0.3之间,减少随机性。如果模型支持,开启do_sample=False做确定性解码。实测下来,低温度配合明确的提示词约束,幻觉率能控制在可接受范围内。

4.5 端到端串联与效果验证

把上面的模块串起来,就是一个完整的“rea”式问答流程。我习惯写一个ask函数作为入口:

def ask(question, index, chunks, top_k=5): results = search(question, index, chunks, top_k) context = format_context(results) prompt = PROMPT_TEMPLATE.format(context=context, question=question) # 调用大模型API,此处省略具体调用代码 answer = call_llm(prompt) return answer, results

验证效果时,准备一组测试问题,覆盖三种类型:答案在文档中明确存在的、需要跨段落综合的、文档中完全没有的。第一类看准确率,第二类看召回率,第三类看拒答率。我通常会准备20到30个问题,人工核对答案,记录错误案例,分析是检索没找对还是生成没用好。这个测试集是后续优化的基准,每次调整策略都跑一遍,看指标是升是降。

5. 常见问题与排查技巧实录

5.1 检索不准的四种典型表现与对策

检索环节出问题,表现五花八门,但归根结底就四种。第一种是答非所问,检索回来的片段和问题主题完全不相关。这通常是嵌入模型不适合当前领域,或者查询和文档的语言风格差异太大。对策是换一个在目标领域微调过的嵌入模型,或者用查询改写把用户口语化的问题转成文档风格的表述。第二种是漏掉关键片段,答案明明在文档里,但Top-K里没有。这往往是分块策略有问题,关键信息被切散了,或者Top-K设得太小。对策是调整分块粒度,增大Top-K,引入重排序。

第三种是召回重复内容,Top-5里有三条是同一段话的不同副本。这通常是文档本身有重复,或者分块重叠设置过大。对策是在入库前做去重,用SimHash或简单的文本相似度判断,重复内容只保留一条。第四种是相似度分数普遍偏低,所有结果分数都在0.3以下。这说明嵌入模型对当前文本的区分度不够,可能是文本太短、太专业,或者模型训练数据和当前领域差距大。对策是换模型,或者用领域数据做一次轻量微调。

5.2 生成幻觉的五种触发场景

即使检索到了正确材料,模型仍可能编造答案。我总结过五种触发场景。场景一:材料中有相似但不同的实体,比如文档里同时提到“产品A支持功能X”和“产品B支持功能Y”,用户问产品A是否支持功能Y,模型可能混淆。对策是在提示词里强调“严格区分不同实体”。场景二:材料是疑问句或否定句,比如文档写“该功能不支持批量导入”,模型可能忽略“不”字,答成支持。对策是检索时保留否定词,提示词里要求模型逐字阅读。

场景三:材料信息不完整,模型自动脑补缺失部分。对策是明确要求“只使用材料中明确写出的信息”。场景四:多个材料互相矛盾,模型选择性地采纳了错误的那条。对策是在提示词里要求模型指出矛盾,或者优先采用更新时间更近的材料。场景五:问题本身有歧义,模型理解偏了。对策是加一步查询澄清,让模型先复述问题再回答。

5.3 性能瓶颈的定位与优化

系统跑起来后,延迟往往集中在三个地方:嵌入计算、向量检索、大模型生成。嵌入计算如果是CPU跑,几万条数据可能要几分钟,建议用GPU或者提前算好缓存。向量检索在百万级以下通常很快,几毫秒到几十毫秒,不是瓶颈。大模型生成是大头,一次调用可能几秒到十几秒,优化空间有限,但可以通过流式输出改善用户体验——先返回检索结果,再逐字输出答案。

内存方面,嵌入模型加载占几百MB到几GB,向量索引占内存与数据量和维度成正比。100万条512维向量,float32存储约2GB。如果内存吃紧,可以用float16量化,内存减半,精度损失很小。FAISS还支持PCA降维,把512维降到256维,内存和计算都减半,但召回率会降几个点,需要权衡。

5.4 常见问题速查表

问题现象可能原因排查方法解决对策
答案与问题无关嵌入模型不匹配检查Top-5片段相关性换模型或加查询改写
答案遗漏关键信息分块切散或Top-K太小人工检查文档分块调整分块粒度,增大Top-K
答案编造事实提示词约束不足检查提示词模板加强约束,降低温度
检索结果重复文档重复或重叠过大统计片段相似度入库前去重,减小重叠
响应速度慢嵌入计算或生成耗时分段计时GPU加速,流式输出
相似度分数低文本太短或领域差异查看分数分布换模型或领域微调
多文档矛盾材料冲突检查来源时间优先新文档,提示词说明
否定句理解错模型忽略否定词构造否定测试用例提示词强调逐字阅读

这张表是我在实际项目中反复查阅的,每次遇到问题先对号入座,能省不少排查时间。

5.5 三个我踩过的坑和独家技巧

第一个坑是PDF解析出来的文本顺序错乱。有些双栏排版的PDF,extract_text()会按视觉位置逐行提取,导致左栏和右栏的文字交错在一起,语义完全乱套。解决办法是用pdfplumber按列切分,或者用布局分析工具先识别分栏再提取。如果文档量不大,人工检查一遍解析结果是最稳妥的。

第二个坑是嵌入模型对长文本的截断。大多数嵌入模型有最大输入长度,比如512个token,超过部分会被截断。如果你的chunk长度设成了800,后面300个token的信息就丢了。解决办法是确保chunk长度不超过模型上限,或者在分块时就把长文本切短。我习惯把chunk上限设成模型上限的80%,留出余量。

第三个技巧是用问题生成来扩充测试集。人工写测试问题很费时间,我试过用大模型对每个文档块生成3到5个问题,然后人工筛选。这样能快速得到几百个测试问题,覆盖各种表述方式。生成的问题还可以反过来用于查询改写的训练数据,一举两得。这个技巧在项目初期特别管用,能快速建立起评估基准。

提示:不要迷信任何单一指标。检索准确率、答案准确率、拒答率、响应延迟,这几个指标要一起看。有时候检索准确率上去了,但答案准确率没变,说明瓶颈在生成环节;有时候答案准确率高了,但拒答率也高了,说明模型过于保守。找到平衡点比追求单项最优更重要。

6. 从“rea”延伸出去:还能怎么玩

“rea”这套框架跑通之后,扩展方向其实很多。最直接的是多路召回融合,向量检索一路,关键词检索(BM25)一路,两路结果合并去重,能兼顾语义匹配和字面匹配。我实测过,混合召回比单路向量召回在专业术语密集的场景下提升明显,因为BM25对精确术语的匹配更敏感。

另一个方向是多轮对话支持。单轮问答只需要考虑当前问题,多轮对话要把历史对话纳入查询改写,比如用户先问“产品A的价格”,再问“那产品B呢”,第二轮的查询要改写成“产品B的价格”。实现上可以把最近几轮对话拼进查询改写提示词,让模型补全指代。这个功能在客服场景几乎是刚需。

再进阶一点是答案溯源与置信度评估。每个答案都标注引用的文档块,用户点击可以跳转到原文。置信度可以用检索分数和生成时的token概率来估算,低置信度的答案主动提示用户“此答案可能不准确,请核实”。这个功能对法律、医疗等高风险场景很重要,能显著提升用户信任度。

最后分享一个小技巧:定期用新数据做回归测试。知识库更新后,旧问题的答案可能变了,或者新文档引入了矛盾信息。我习惯每月跑一次全量测试集,对比历史结果,发现异常及时排查。这个习惯帮我提前发现过好几次文档更新导致的答案漂移,避免了线上事故。

这套东西说到底就是工程活,没有太多玄学。把每个环节做扎实,效果自然就上来了。我见过太多项目在选型和调参上反复横跳,反而忽略了最基础的分块和提示词。先把最小闭环跑通,拿到真实反馈,再逐项优化,这条路最稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进