1. 为什么RAG值得你花时间搞明白
大模型这东西,用过的都知道,它有个特别让人头疼的毛病——一本正经地胡说八道。你问它一个很具体的问题,它能给你编出一套听起来特别合理但完全经不起查证的答案。这个问题在业内叫“幻觉”,说白了就是模型在“编”。而RAG(检索增强生成)就是目前对抗幻觉最实用、落地最广的一套方案。
RAG的核心思路其实不复杂:既然模型自己记不住那么多东西,那就在它回答问题之前,先去外部知识库里把相关资料找出来,连同问题一起喂给模型,让它“看着资料答题”。这就像开卷考试和闭卷考试的区别——闭卷考试全靠脑子记,记错了就答错了;开卷考试可以翻书,答案的准确性自然高出一大截。
这套方案能解决的问题非常明确:让大模型基于你提供的私有数据、最新文档、内部资料来回答问题,而不是靠它训练时那点可能过时、可能残缺的记忆。适合谁来学?我觉得三类人最需要:一是做企业知识库、智能客服的开发者;二是想把自己积累的文档资料变成可问答系统的技术爱好者;三是任何对大模型应用落地感兴趣、想搞清楚RAG到底怎么回事的人。零基础也能看懂,因为我会把每个环节拆开讲,告诉你为什么这么做、不这么做会怎样。
接下来我会从RAG的整体设计思路讲起,然后逐个拆解核心环节,包括文档处理、切块策略、向量化、检索方式、重排序、生成整合,最后讲怎么评估检索质量。全程按我实际搭建过多个RAG系统的经验来说,该踩的坑、该注意的细节,一个不落。
2. RAG整体架构与核心思路拆解
2.1 不搞RAG行不行?先看清大模型的三个硬伤
很多人一开始会想:我直接把文档内容塞进提示词里不就行了?干嘛还要搞什么检索?这个想法在小规模场景下确实能用,但一旦文档量上去,立刻撞墙。
大模型有三个绕不过去的硬伤。第一是上下文窗口有限。虽然现在很多模型号称支持几十万甚至上百万token的上下文,但你真往里塞几十万字,先不说成本,模型对中间部分内容的注意力会明显下降,业内叫“迷失在中间”。第二是知识截止日期。模型训练完那一刻起,它的知识就冻结了,你公司昨天发的制度文件它不可能知道。第三是私有数据根本不在训练集里。你的内部文档、客户资料、产品手册,模型训练时压根没见过。
RAG就是针对这三个问题的组合拳:用检索解决上下文窗口限制(只取最相关的片段),用外部知识库解决知识截止和私有数据问题(知识库随时更新)。你可以把RAG理解成给大模型配了一个“随身图书馆管理员”——你问问题,管理员先去书架上找最相关的几页资料,然后连同你的问题一起递给模型,模型基于这几页资料来回答。
2.2 RAG的完整链路:从提问到回答到底经历了什么
一个标准的RAG系统,从用户输入问题到最终拿到答案,中间经历了一条完整的流水线。我把这条链路拆成两个阶段来讲,这样更清楚。
离线阶段(数据准备):你的原始文档(PDF、Word、网页、数据库记录等)先经过解析提取出纯文本,然后按照一定策略切成小块(chunk),每个小块通过嵌入模型转成一个向量(一串数字),最后存进向量数据库。这个阶段是一次性的或者定期更新的,相当于把图书馆的书都编好索引上架。
在线阶段(实时问答):用户提问后,问题同样被转成向量,然后在向量数据库里找最相似的若干个文本块,这些块经过重排序精筛后,和原始问题一起组装成提示词,发给大模型生成最终答案。这个阶段是每次提问都会触发的。
注意:很多人把RAG想得太简单,以为就是“向量检索+拼提示词”。实际上每个环节都有大量细节决定最终效果,检索不准,后面生成再好也是白搭。
2.3 朴素RAG vs 高级RAG:什么时候该上复杂度
最基础的RAG叫“朴素RAG”——就是上面说的那条链路,切块、向量化、检索Top-K、拼提示词、生成。这套方案在文档结构简单、问题类型单一的场景下够用。但实际业务中你会发现几个问题:切块切得不好导致语义断裂、单一向量检索召回率不够、检索回来的内容有冗余或矛盾。
于是就有了“高级RAG”,它在各个环节做了增强。比如切块阶段用语义切分代替固定长度切分,检索阶段用多路召回(向量检索+关键词检索)代替单一向量检索,检索后加重排序模型精排,生成前做上下文压缩去掉无关信息。这些增强手段不是花架子,每一个都对应着朴素RAG在实际中暴露出的具体问题。
我的建议是:先用朴素RAG跑通全流程,拿到基线效果,然后针对bad case逐个环节优化。一上来就堆所有高级技巧,调试成本极高,而且你根本不知道是哪个环节起了作用。
3. 核心环节深度解析与实操要点
3.1 文档解析:别小看这一步,它决定了后续所有环节的上限
文档解析是RAG流水线的第一道关口,也是最容易被忽视的环节。很多人拿到PDF直接往解析工具里一扔,出来什么文本就用什么,结果后面检索效果差,排查半天才发现是解析阶段就出了问题。
不同格式的文档,解析难度完全不同。纯文本和Markdown最好处理,直接读取就行。Word文档相对简单,用python-docx之类的库能提取段落和表格。PDF是最麻烦的——有原生电子版PDF(文字可选)、扫描版PDF(图片)、混合版PDF。原生PDF用PyMuPDF或pdfplumber能较好地提取文字,但表格和复杂排版容易乱。扫描版PDF必须先做OCR,OCR的准确率直接影响后续所有环节。
实操心得:解析PDF时,一定要保留文档的层级结构信息(标题、段落、表格)。很多解析工具只输出纯文本,丢掉了标题层级,导致后续切块时无法按语义单元切分。我一般会在解析阶段就把标题层级标记出来,比如用Markdown格式保留#、##这样的结构,后续切块时就能按标题来切。
表格处理是另一个大坑。PDF里的表格解析出来经常是错位的,行列对不上。如果文档里表格很多,建议用专门的表格提取工具,或者在解析后人工校验关键表格。对于RAG来说,表格内容如果解析错了,检索出来的信息就是错的,模型基于错误信息生成的答案自然也是错的。
3.2 文本切块:切得好不好,直接决定检索准不准
切块是RAG里最需要经验和反复调试的环节。切得太粗,一个块里混了好几个主题,检索时噪音大;切得太细,一个完整的语义单元被拆散,检索出来的片段缺少上下文,模型理解不了。
常见的切块策略有这么几种。固定长度切块最简单,按token数或字符数切,比如每500个token一块,块之间留50个token的重叠。这种方式实现简单,但完全不考虑语义边界,经常把一句话从中间切断。按标点/段落切块比固定长度好一些,按句号、换行符来切,保证句子完整,但块的长度不可控。语义切块是更高级的做法,用嵌入模型计算相邻句子的语义相似度,在相似度骤降的地方切分,这样每个块内部语义连贯。按文档结构切块适合有明确层级结构的文档,比如按章节、按标题切分。
我实际用下来,效果最稳的是“结构优先+语义兜底”的混合策略:先按文档的标题层级切大块,如果某个大块还是太长,再在这个块内部按语义相似度切分。块的大小一般控制在200-500个token之间,重叠50-100个token。这个范围不是拍脑袋定的——太小了语义不完整,太大了检索精度下降且浪费上下文窗口。
注意:重叠区域不是越多越好。重叠太多会导致检索结果大量重复,浪费上下文窗口还引入冗余信息。我一般设10%-20%的重叠比例就够了。
还有一个容易忽略的点:给每个块加上元数据。比如这个块来自哪个文档、哪个章节、第几页。这些元数据在检索时可以用来过滤(比如只搜某个文档),在生成时可以作为引用来源展示给用户。没有元数据的块,检索回来你都不知道它从哪来的,出了问题也没法追溯。
3.3 嵌入模型选型:向量化质量的天花板
嵌入模型负责把文本转成向量,它的质量直接决定了检索的语义匹配能力。选嵌入模型主要看几个维度:语义表征能力(能不能准确捕捉语义相似度)、支持的语言(中文场景必须选中文优化过的)、向量维度(维度越高表达能力越强但存储和计算成本也越高)、推理速度(影响离线处理和在线检索的延迟)、最大输入长度(要能覆盖你的块大小)。
中文场景下,我试过不少模型,目前表现比较稳的有几个方向:一是专门针对中文优化的开源嵌入模型,在中文语义相似度任务上表现不错;二是多语言嵌入模型,中英文混合场景下更省心;三是商业API提供的嵌入服务,省去了部署和维护的麻烦,但数据要过第三方。
选型时有个容易被忽略的点:嵌入模型的训练目标和你的检索任务是否匹配。有些模型擅长短文本匹配(比如搜索query和标题的匹配),有些擅长长文本语义表征(比如文档间的相似度)。RAG场景下,你的query通常比较短,而文档块比较长,这种“短对长”的匹配场景需要模型有较好的跨长度语义表征能力。
实操心得:不要盲目追求维度最高的模型。我实测过,在某些场景下,一个768维的中文优化模型比1536维的通用模型检索效果更好,而且存储成本减半、检索速度快一倍。选型时一定要在你的实际数据上做评测,别只看论文指标。
3.4 向量数据库:存和查的基础设施
向量数据库负责存储所有文本块的向量,并支持快速的相似度检索。选型时考虑几个因素:数据规模(几千条和几千万条的选择完全不同)、检索延迟要求(实时问答场景要求毫秒级返回)、过滤能力(能不能按元数据过滤)、运维成本(自建还是用云服务)。
小规模场景(几万条以下)其实用FAISS这种库就够了,不需要专门的向量数据库,直接在内存里做相似度计算,速度快还简单。中等规模(几十万到几百万条)可以考虑Milvus、Qdrant、Weaviate这类专门的向量数据库。大规模(千万级以上)就需要考虑分布式方案了。
索引类型的选择也影响很大。常见的有Flat索引(暴力计算,最准但最慢)、IVF索引(倒排文件,速度快但有精度损失)、HNSW索引(基于图的索引,速度和精度平衡得比较好)。我一般默认用HNSW,在大多数场景下表现均衡。如果对精度要求极高且数据量不大,用Flat也行。
注意:向量数据库里的向量距离度量方式要和嵌入模型的训练方式匹配。大部分嵌入模型用的是余弦相似度,那数据库里也应该用余弦距离。用错了度量方式,检索结果会莫名其妙地差。
3.5 检索策略:单一向量检索为什么不够用
很多人做完向量检索就以为RAG搞定了,结果发现有些问题就是搜不到相关文档。原因很简单:向量检索擅长语义匹配,但不擅长精确匹配。比如你搜一个产品型号“XR-2000”,向量检索可能给你返回一堆语义相关但型号不对的文档,因为向量模型对这类专有名词的区分能力有限。
这就是多路召回要解决的问题。向量检索负责语义层面的召回,关键词检索(比如BM25算法)负责精确匹配的召回,两路结果合并后再做去重和排序。这样既保证了语义相关性,又保证了关键词的精确命中。
混合检索的融合策略有两种常见做法:一种是加权融合,给向量检索和关键词检索的分数各设一个权重,加权求和后排序;另一种是倒数排名融合,不看具体分数,只看两路结果中的排名,把排名做倒数后相加。RRF的好处是不需要调权重,对两路检索的分数尺度不敏感,实际用起来更省心。
实操心得:多路召回不是路数越多越好。我见过有人搞了五六路召回,结果融合后的结果反而更差,因为噪音也成倍增加了。一般两到三路就够了:向量检索+关键词检索,如果有多语言需求再加一路翻译后的检索。
3.6 重排序:精筛环节决定最终质量
检索回来的Top-K结果里,真正相关的可能只有前几个,后面的都是凑数的。如果直接把Top-K全部塞给大模型,不仅浪费上下文窗口,还可能引入噪音干扰生成。重排序就是来解决这个问题的。
重排序模型(Reranker)和嵌入模型不同,它是对“query-文档对”做精细的相关性打分,而不是像嵌入模型那样分别编码后算距离。因为Reranker能同时看到query和文档的内容,所以判断更准确,但计算成本也更高,不适合对全量文档做检索,只适合对检索回来的Top-K做精排。
常见的做法是:检索阶段召回Top-20到Top-50,然后用Reranker精排出Top-3到Top-5,最后只把这几条送给大模型生成。这样既保证了召回率(检索阶段多召回一些),又保证了精度(精排后只留最相关的)。
注意:Reranker的选择要和嵌入模型配合。有些Reranker是基于特定嵌入模型训练的,混用可能效果打折。另外Reranker的推理延迟也要考虑,如果在线问答对延迟敏感,要选轻量级的Reranker或者控制精排的文档数量。
3.7 生成整合:提示词怎么写才不让模型跑偏
检索回来的文档块最终要和用户问题一起组装成提示词发给大模型。这个环节看似简单,其实提示词的设计对最终答案质量影响很大。
一个基本的RAG提示词模板通常包含几个部分:系统指令(告诉模型它的角色和任务)、检索到的上下文(把相关的文档块拼进去)、用户问题、输出格式要求(比如要求引用来源、要求用特定格式回答)。关键是要在系统指令里明确告诉模型:“只基于提供的上下文回答问题,如果上下文里没有相关信息,就说不知道,不要自己编。”
这个“不知道”的兜底机制特别重要。如果不加这个约束,模型在检索结果不相关时仍然会强行编一个答案出来。加了之后,至少模型会告诉你“根据现有资料无法回答”,这比编一个错误答案要好得多。
上下文拼接的顺序也有讲究。有研究表明,把最相关的文档放在上下文的开头和结尾,模型利用效果更好,中间部分容易被忽略。所以如果检索结果已经按相关性排好序了,可以考虑把最相关的放两头,次相关的放中间。
实操心得:上下文里最好给每个文档块加上来源标记,比如“【文档1】”“【文档2】”,然后在输出要求里让模型引用来源。这样用户能知道答案的依据是什么,也方便排查问题。
4. 检索评估:怎么知道你的RAG到底行不行
4.1 没有评估就没有优化方向
RAG系统搭起来容易,调好难。难就难在你不知道问题出在哪个环节。是检索没搜到相关文档?还是搜到了但排序不对?还是生成阶段模型没用好?没有评估体系,你只能凭感觉调,调了半天可能越调越差。
评估RAG需要分环节做。检索环节有独立的评估指标,生成环节也有独立的评估指标。先定位问题出在哪个环节,再针对性优化,效率才高。
4.2 检索环节的核心评估指标
检索评估的核心是看“该找到的有没有找到”和“找到的排得对不对”。常用的指标有这么几个。
召回率(Recall):在所有相关文档中,检索系统找回来了多少。比如一共有10个相关文档块,检索Top-10里找回了7个,召回率就是70%。召回率低说明检索阶段就漏了,后面生成再好也没用。
精确率(Precision):检索回来的文档中,有多少是真正相关的。比如检索回来10个,其中6个相关,精确率就是60%。精确率低说明噪音多,会干扰生成。
MRR(平均倒数排名):第一个相关文档出现在第几位,排名越靠前分数越高。这个指标衡量的是“最相关的那条有没有排在前面”。
NDCG(归一化折损累计增益):综合考虑了所有相关文档的排名和相关性等级,是检索评估里比较全面的指标。
实际评估时,你需要先构建一个评测集:一批问题,以及每个问题对应的标准答案和相关的文档块。这个评测集不用很大,几十到几百条就能看出问题。构建方式可以是人工标注,也可以用大模型辅助生成后人工校验。
实操心得:评测集一定要覆盖不同类型的问法。有的事实型问题(“XX的截止日期是什么时候”),有的总结型问题(“XX的主要观点有哪些”),有的对比型问题(“XX和YY有什么区别”)。不同类型的问法对检索的要求不同,评测集覆盖不全,优化就会偏。
4.3 生成环节的评估:答案对不对、有没有依据
生成环节的评估主要看两个方面:答案的正确性(和标准答案比对不对)和答案的忠实性(是不是基于检索到的上下文,有没有编造)。
正确性可以用人工评估,也可以用大模型做自动评估(让一个能力强的模型来判断生成的答案和标准答案是否一致)。忠实性评估是看生成的答案里的每一句话,能不能在检索到的上下文里找到依据。如果答案里有上下文里没有的信息,那就是幻觉。
自动评估忠实性有一个常用方法:把生成的答案拆成一个个事实陈述,然后逐个检查每个陈述是否被上下文支持。这个可以用NLI(自然语言推理)模型来做,也可以用大模型来判断。
4.4 端到端评估与常见问题定位
端到端评估就是直接看最终答案的质量,不关心中间环节。但出了问题需要定位时,还是要回到分环节评估。
我整理了一个常见问题定位表,遇到bad case时可以按这个思路排查:
| 问题现象 | 可能出问题的环节 | 排查方法 |
|---|---|---|
| 答案完全无关 | 检索没搜到相关文档 | 检查检索Top-K里有没有相关块 |
| 答案部分正确但缺信息 | 检索召回不全 | 增大Top-K看是否改善 |
| 答案有编造内容 | 生成阶段没约束好 | 检查提示词是否有“不知道”兜底 |
| 答案正确但排序混乱 | 重排序没做好 | 检查Reranker效果 |
| 相似问题结果差异大 | 嵌入模型不稳定 | 检查嵌入模型在该类问题上的表现 |
| 专有名词搜不到 | 向量检索不擅长精确匹配 | 加入关键词检索做多路召回 |
这个表是我在实际调试中总结出来的,大部分bad case都能对应到某个环节。定位到环节后,再针对性优化,比盲目调参高效得多。
5. 实操搭建:从零跑通一个RAG系统
5.1 环境准备与依赖安装
先说环境。Python 3.9以上就行,主要依赖几个库:文档解析用PyMuPDF和python-docx,嵌入模型用sentence-transformers,向量检索用FAISS(小规模够用),关键词检索用rank_bm25,大模型调用看你自己用什么服务。
pip install pymupdf python-docx sentence-transformers faiss-cpu rank_bm25如果你要用GPU加速嵌入模型的推理,把faiss-cpu换成faiss-gpu,sentence-transformers会自动检测GPU。数据量不大的话CPU也够用,嵌入几万个块也就几分钟的事。
5.2 文档解析与切块的完整代码
先写文档解析部分。这里以PDF为例,保留标题层级信息:
import fitz # PyMuPDF def parse_pdf(file_path): doc = fitz.open(file_path) blocks = [] for page_num, page in enumerate(doc): text = page.get_text("dict") for block in text["blocks"]: if "lines" not in block: continue block_text = "" max_font_size = 0 for line in block["lines"]: for span in line["spans"]: block_text += span["text"] max_font_size = max(max_font_size, span["size"]) if block_text.strip(): blocks.append({ "text": block_text.strip(), "page": page_num + 1, "font_size": max_font_size }) return blocks这段代码的关键是提取了每个文本块的字体大小。字体大的通常是标题,字体小的是正文。后续切块时可以根据字体大小来判断层级。
切块逻辑我一般这样写:
def chunk_blocks(blocks, max_tokens=400, overlap_tokens=80): chunks = [] current_chunk = [] current_len = 0 for block in blocks: block_len = len(block["text"]) if current_len + block_len > max_tokens and current_chunk: chunk_text = "\n".join([b["text"] for b in current_chunk]) chunks.append({ "text": chunk_text, "page_start": current_chunk[0]["page"], "page_end": current_chunk[-1]["page"] }) # 保留重叠部分 overlap_text = "" overlap_len = 0 for b in reversed(current_chunk): if overlap_len + len(b["text"]) > overlap_tokens: break overlap_text = b["text"] + "\n" + overlap_text overlap_len += len(b["text"]) current_chunk = [{"text": overlap_text.strip(), "page": current_chunk[-1]["page"]}] current_len = overlap_len current_chunk.append(block) current_len += block_len if current_chunk: chunk_text = "\n".join([b["text"] for b in current_chunk]) chunks.append({ "text": chunk_text, "page_start": current_chunk[0]["page"], "page_end": current_chunk[-1]["page"] }) return chunks这个切块逻辑是按块累积,超过阈值就切,同时保留尾部重叠。实际用的时候,max_tokens和overlap_tokens要根据你的文档特点和嵌入模型的最大输入长度来调。
5.3 向量化与索引构建
切好块之后,用嵌入模型把每个块转成向量:
from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer("your-embedding-model-path") def build_index(chunks): texts = [c["text"] for c in chunks] embeddings = model.encode(texts, batch_size=32, show_progress_bar=True) embeddings = embeddings.astype("float32") # 归一化,方便用内积算余弦相似度 norms = np.linalg.norm(embeddings, axis=1, keepdims=True) embeddings = embeddings / norms return embeddings import faiss def create_faiss_index(embeddings): dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) # 内积索引,配合归一化向量等价于余弦相似度 index.add(embeddings) return index这里用了IndexFlatIP,因为向量已经归一化了,内积就等于余弦相似度。数据量大的话可以换成IndexHNSWFlat,速度快很多,精度损失很小。
5.4 多路召回与混合排序实现
检索部分,我一般同时跑向量检索和BM25关键词检索,然后用RRF融合:
from rank_bm25 import BM25Okapi def build_bm25(chunks): tokenized = [list(c["text"]) for c in chunks] # 中文按字切分,简单但有效 return BM25Okapi(tokenized) def hybrid_retrieve(query, chunks, faiss_index, bm25, top_k=20): # 向量检索 query_vec = model.encode([query]).astype("float32") query_vec = query_vec / np.linalg.norm(query_vec, axis=1, keepdims=True) vec_scores, vec_indices = faiss_index.search(query_vec, top_k) # BM25检索 tokenized_query = list(query) bm25_scores = bm25.get_scores(tokenized_query) bm25_indices = np.argsort(bm25_scores)[::-1][:top_k] # RRF融合 rrf_scores = {} for rank, idx in enumerate(vec_indices[0]): rrf_scores[idx] = rrf_scores.get(idx, 0) + 1.0 / (60 + rank + 1) for rank, idx in enumerate(bm25_indices): rrf_scores[idx] = rrf_scores.get(idx, 0) + 1.0 / (60 + rank + 1) sorted_indices = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True) return [(idx, rrf_scores[idx]) for idx in sorted_indices[:top_k]]RRF里的60是个经验常数,来自原始论文,实际用的时候不用改。这个融合方式的好处是不需要调权重,两路检索的分数尺度不一致也没关系。
5.5 提示词组装与生成调用
最后把检索结果组装成提示词,调用大模型生成:
def build_prompt(query, retrieved_chunks): context = "" for i, (idx, score) in enumerate(retrieved_chunks): chunk = chunks[idx] context += f"【文档{i+1}】{chunk['text']}\n\n" prompt = f"""你是一个基于给定资料回答问题的助手。请严格根据以下资料回答问题。 如果资料中没有相关信息,请直接说"根据现有资料无法回答",不要编造。 资料: {context} 问题:{query} 请给出回答,并在回答中标注引用的文档编号。""" return prompt这个提示词模板的关键就是那句“如果资料中没有相关信息,请直接说无法回答”。别小看这一句话,它能挡掉大部分幻觉。
5.6 完整流程串联与首次运行
把上面的模块串起来,一个最简RAG系统就跑通了:
# 离线阶段 blocks = parse_pdf("your_document.pdf") chunks = chunk_blocks(blocks) embeddings = build_index(chunks) faiss_index = create_faiss_index(embeddings) bm25 = build_bm25(chunks) # 在线阶段 query = "你的问题" retrieved = hybrid_retrieve(query, chunks, faiss_index, bm25) prompt = build_prompt(query, retrieved) # 调用大模型API生成答案第一次跑通之后,先别急着优化。拿十几个典型问题测一下,看看哪些答得好、哪些答得差。把bad case记下来,对照第4章的排查表定位问题环节,然后针对性优化。这个迭代过程比一次性堆所有高级技巧有效得多。
6. 常见问题与排查技巧实录
6.1 检索相关的高频问题
问题一:明明文档里有答案,但就是搜不出来。这是最常见的问题。原因通常有几个:切块切得不好,答案被切散了;嵌入模型对这类问题的语义表征能力不够;检索Top-K设得太小。排查时先把Top-K调大(比如从5调到50),如果相关文档出现了,说明是Top-K太小;如果还是没出现,检查切块是否把答案切散了;如果切块没问题,那就是嵌入模型的问题,考虑换模型或加入关键词检索。
问题二:搜出来的文档语义相关但答非所问。这通常是嵌入模型的语义匹配粒度问题。比如你问“XX的截止日期”,模型搜出来一堆讲XX的文档,但没搜到具体日期。解决办法是加入关键词检索做多路召回,让精确匹配的文档也能被召回。
问题三:相似的问题检索结果差异很大。这说明嵌入模型对这类问题的表征不稳定。可以试试对query做改写或扩展,比如把一个问题改写成多个不同表述,分别检索后合并结果。
6.2 生成相关的高频问题
问题一:模型编造答案。首先检查提示词里有没有“不知道”兜底。如果没有,加上。如果加了还有,检查检索结果里是不是有误导性内容。有时候检索回来的文档块里包含相似但不相关的信息,模型会把它当成答案。
问题二:答案太长或太短。在提示词里明确输出长度要求。比如“请用不超过三句话回答”或“请详细说明,不少于200字”。模型对这类指令的遵循度还是比较高的。
问题三:答案没有引用来源。在提示词里明确要求标注引用,并且给每个文档块编号。如果模型还是不标,可以在输出后处理阶段用规则匹配的方式自动添加来源。
6.3 性能与成本优化技巧
嵌入模型推理加速:用GPU批量推理,batch_size设大一些(32或64),比逐条推理快很多。如果文档量特别大,可以考虑用ONNX Runtime或TensorRT加速。
向量检索加速:数据量超过10万条时,用HNSW索引代替Flat索引,检索速度能提升几十倍,精度损失很小。HNSW的参数M和efConstruction需要根据数据量调,一般M=16到32,efConstruction=100到200。
减少大模型调用成本:检索阶段多召回一些(Top-50),精排后只取Top-3送给大模型。这样既保证了质量,又控制了上下文长度。另外,对于简单的事实型问题,可以用小模型或规则匹配直接回答,不用调大模型。
缓存机制:对高频问题做缓存,相同或相似的问题直接返回缓存答案,省去检索和生成的步骤。缓存可以用向量相似度来做,相似度超过阈值就命中缓存。
6.4 我的避坑清单
最后分享几个我踩过的坑,都是文档里不会写的:
- 别在切块阶段过度优化。我见过有人花大量时间调切块参数,结果发现主要问题是嵌入模型选错了。先跑通全流程,再定位瓶颈环节。
- 评测集比调参重要。没有评测集,你调参就是盲调。花时间构建一个覆盖各种问题类型的评测集,比调任何参数都值。
- 提示词里的“不知道”兜底一定要加。这一句话能挡掉大部分幻觉,成本几乎为零。
- 多路召回的融合方式优先用RRF。加权融合需要调权重,RRF不需要,而且效果通常更好。
- Reranker不是必须的。如果检索Top-K的结果已经足够好,可以不用Reranker,省去推理延迟。先用检索指标评估,如果MRR和NDCG已经很高,就不需要精排了。
- 元数据过滤能解决很多问题。如果你的文档有明确的分类或时间属性,在检索时加上过滤条件,能大幅提升准确率。
这个RAG系统后续还可以扩展的方向很多,比如加入多轮对话能力(把历史对话也作为检索上下文)、加入Agent能力(让模型自己决定什么时候检索、检索什么)、加入多模态检索(支持图片和表格的检索)。但这些都是在你把基础RAG跑稳之后再考虑的事。先把检索准确率和生成忠实度这两个核心指标做好,其他的都是锦上添花。