1. RAG检索增强到底在解决什么问题
1.1 从一个尴尬场景说起
大语言模型有个让人又爱又恨的特点:它什么都懂一点,但问到具体细节就容易一本正经地胡说八道。你问它某个内部技术文档里的接口参数,它能给你编出一套看起来煞有介事的答案,格式工整、语气笃定,但内容全是幻觉。这不是模型故意骗人,而是它的知识来自训练语料,训练完成之后知识就冻结了,你私有的、最新的、垂直领域的信息它根本没见过。
RAG(Retrieval-Augmented Generation,检索增强生成)就是冲着这个痛点来的。它的核心思路特别朴素:既然模型不知道,那就在它回答之前,先把相关资料找出来塞进它的上下文里,让它"看着材料答题"。这就像开卷考试和闭卷考试的区别——闭卷考靠记忆,容易记错;开卷考可以翻书,答案有据可查。
我第一次在生产环境里落地RAG的时候,最大的感受是:它不是一个模型技术,而是一套工程链路。模型只是链路末端那个负责"组织语言"的环节,真正决定效果好坏的是前面的检索质量。很多人一上来就纠结用哪个大模型,其实方向反了,检索召回不准,再强的模型也只能拿着错误材料编出更流畅的错话。
1.2 RAG适合谁、不适合谁
先说适合的场景。第一类是私有知识问答,比如企业内部文档、产品手册、客服知识库,这些内容模型训练时压根接触不到。第二类是时效性强的信息,模型知识有截止日期,而新闻、政策、价格这类信息天天变。第三类是需要溯源引用的场景,RAG能给出答案对应的原文出处,方便核对,这在法律、医疗、金融这类严谨领域特别重要。第四类是成本敏感的场景,相比重新训练或微调模型,RAG的落地成本低得多,改知识库比改模型权重快得多。
再说不太适合的。如果你的任务是纯创意写作、代码生成、逻辑推理,RAG帮助有限,甚至可能因为塞了一堆无关上下文反而干扰模型。如果知识总量极小、结构极其规整,那直接查数据库返回结果可能比RAG更靠谱。还有一种情况是问题需要跨大量文档做复杂推理,单轮检索搞不定,得配合多跳检索或者Agent架构,这已经超出最小可用RAG的范畴了。
提示:判断要不要上RAG,先问自己一句——这个问题的答案是不是"藏在某段文字里,找到就能答"?如果是,RAG很合适;如果答案需要大量计算或推理才能得出,那RAG只是辅助。
1.3 最小可用RAG的价值定位
网上讲RAG的文章很多,但不少一上来就堆各种高级技巧:重排序、查询改写、混合检索、图检索。这些当然有用,但对刚上手的人来说,容易陷入"还没跑通就想优化"的陷阱。我更推崇先把最小可用版本跑起来——能加载文档、能切分、能向量化、能检索、能生成,端到端跑通一条链路,然后再针对薄弱环节逐个优化。
最小可用RAG的意义在于:它让你快速建立对整条链路的直觉。你会亲眼看到切分粒度怎么影响召回,会看到相似度阈值设高了会漏、设低了会引入噪声,会看到提示词怎么写模型才不跑偏。这些体感是看一百篇文章都换不来的。下面我就按七步链路,把每一步的原理、坑和代码都摊开讲。
2. 七步链路整体设计与选型思路
2.1 七步链路全景
我把一个最小可用RAG拆成七个步骤,顺序如下:
- 文档加载:把各种格式的原始资料读进来,统一成纯文本。
- 文本切分:把长文档切成大小合适的片段(chunk)。
- 向量化:用嵌入模型把每个片段转成向量。
- 向量存储:把向量和原文一起存进向量库,建立索引。
- 查询向量化:把用户问题也转成向量。
- 相似度检索:在向量库里找出与问题最相近的若干片段。
- 拼接生成:把检索到的片段和问题一起塞给大模型,生成答案。
这七步里,前三步是离线预处理,一次做完可以反复用;后四步是在线查询,每次提问都要走一遍。理解这个"离线+在线"的划分很重要,因为它决定了你的优化重点——离线部分追求质量和覆盖,在线部分追求速度和准确。
2.2 为什么是这个顺序
有人会问,能不能跳过切分直接整篇文档向量化?技术上可以,但效果通常很差。原因在于嵌入模型对文本长度有上限,而且一篇长文档往往包含多个主题,压成一个向量会"语义稀释",检索时匹配不准。切分就是为了让每个向量代表一个相对聚焦的语义单元。
那能不能不向量化,直接用关键词检索?可以,这就是传统的全文检索(比如BM25)。它快、可解释、对精确匹配友好,但对同义改写无能为力——你搜"怎么退款",文档里写的是"如何申请退货",关键词匹配就抓瞎了。向量检索擅长语义匹配,两者其实是互补的,这也是后面混合检索的由来。最小可用版本先用纯向量检索,够用且简单。
2.3 技术选型的取舍逻辑
最小可用版本我建议这样选型,理由逐条说:
- 嵌入模型:优先选开源的中文友好模型,本地跑不花钱、数据不出域。如果追求效果且预算允许,可以用商业API。选型时重点看它在中文语义相似度任务上的表现,别只看英文榜单。
- 向量库:小规模数据(几万条以内)用内存版或轻量本地库就够了,比如FAISS、Chroma这类。别一上来就上分布式向量数据库,运维成本高,收益在早期体现不出来。
- 大模型:生成环节可以用任意支持上下文输入的模型。本地小模型省钱但质量一般,商业API质量好但有成本。建议先用一个中等规模的模型把链路跑通,再按需升级。
- 编排框架:可以用现成框架,也可以纯手写。我倾向手写最小版本,因为框架封装太厚,出问题不好排查,而且手写一遍你对每个环节的理解会深很多。
注意:选型没有绝对最优,只有匹配当前阶段。早期追求"跑通+可调",中期追求"召回准",后期才追求"极致效果"。别在第一步就追求完美。
3. 核心细节解析与实操要点
3.1 文档加载:脏数据是万恶之源
文档加载看着简单,其实是最容易被忽视的环节。PDF里的表格、扫描件的OCR噪声、网页里的导航栏和广告、Word里的页眉页脚,这些都会混进文本里,成为后续检索的干扰项。我踩过最深的坑就是:一份PDF解析出来,每页顶部都带着公司名和页码,结果检索时这些无关文字反复出现,拉低了有效内容的权重。
实操上,加载阶段要做三件事:格式统一、噪声清洗、元数据保留。格式统一是把docx、pdf、html、md都转成纯文本;噪声清洗是去掉重复的页眉页脚、多余空行、特殊符号;元数据保留是给每个片段打上来源、页码、标题等标签,方便后面溯源引用。
import re def clean_text(raw: str) -> str: # 去掉多余空白 text = re.sub(r'\s+', ' ', raw) # 去掉常见的页码模式,如 "第 3 页" 或 "- 3 -" text = re.sub(r'第\s*\d+\s*页', '', text) text = re.sub(r'-\s*\d+\s*-', '', text) # 去掉连续重复的短行(页眉页脚常见特征) lines = text.split('\n') cleaned = [] for line in lines: if not cleaned or line.strip() != cleaned[-1].strip(): cleaned.append(line) return '\n'.join(cleaned).strip()这段清洗逻辑不复杂,但能挡掉大部分低级噪声。元数据我一般用一个字典结构存,比如{"source": "产品手册.pdf", "page": 12, "title": "退款流程"},检索时一起带出来,生成答案时可以让模型引用来源。
3.2 文本切分:粒度决定成败
切分是RAG里最需要反复调参的环节。切太大,一个片段混了多个主题,检索时匹配不准;切太小,语义不完整,模型拿到半句话也答不好。经验值是每个片段200到500个中文字符,但这个数字不是死的,得看你的文档类型。
切分策略我常用两种。第一种是固定长度+重叠,简单粗暴,按字符数切,相邻片段留10%到20%的重叠,防止关键信息正好被切断。第二种是按语义边界切,优先在段落、句子、标题处断开,保持语义完整。实际项目里我通常结合两者:先按标题和段落切,如果某段还是太长,再按句子切,最后兜底按字符切。
def split_by_structure(text: str, max_len: int = 400, overlap: int = 50): # 先按段落切 paragraphs = [p.strip() for p in text.split('\n') if p.strip()] chunks = [] buffer = "" for para in paragraphs: if len(buffer) + len(para) <= max_len: buffer += para + "\n" else: if buffer: chunks.append(buffer.strip()) # 处理超长段落 if len(para) > max_len: for i in range(0, len(para), max_len - overlap): chunks.append(para[i:i + max_len]) buffer = "" else: buffer = para + "\n" if buffer: chunks.append(buffer.strip()) return chunks重叠的作用是防止"跨片信息"丢失。举个例子,答案需要结合上一段结尾和下一段开头,如果正好切在中间,没有重叠就检索不全。重叠的代价是存储和计算量增加,但这点成本换来召回率的提升,很值。
实操心得:切分后一定要抽样看几十个片段,肉眼检查有没有切得莫名其妙的地方。我见过切出来一个片段全是标点符号的,也见过把表格切成一行一行的。这一步的人工检查,比任何自动指标都管用。
3.3 向量化:语义匹配的基石
向量化的本质是把文本映射到一个高维空间,语义相近的文本在空间里距离也近。嵌入模型就是这个映射函数。选模型时我关注三点:中文效果、向量维度、推理速度。维度高表达能力强但存储和计算成本高,维度低反之。常见的中文嵌入模型维度在几百到一千多之间。
向量化时有个细节容易被忽略:查询和文档要用同一个模型。不同模型训练出来的向量空间不通用,混用会导致相似度计算完全失效。这个坑我早期踩过,用A模型编码文档、B模型编码查询,检索结果乱七八糟,排查半天才发现是模型不一致。
# 伪代码示意,实际调用替换成你选用的嵌入模型 def embed_texts(texts: list) -> list: # 批量编码,注意归一化,方便后续用余弦相似度 vectors = embedding_model.encode(texts, normalize_embeddings=True) return vectors归一化是个小技巧。向量归一化之后,余弦相似度就等价于点积,计算更快,数值也更稳定。批量编码比逐条编码快得多,预处理阶段一定要批量处理。
3.4 向量存储与索引:让检索快起来
数据量小的时候,暴力遍历所有向量算相似度也能接受。但数据量一上来,就得靠索引加速。常见的近似最近邻(ANN)索引有基于图的、基于聚类的、基于量化的几大类。最小可用版本用FAISS的扁平索引或轻量本地库就够,等数据量到几十万上百万再考虑更复杂的索引。
存储时我建议把向量、原文、元数据三者绑定存一起。检索返回向量ID后,要能立刻拿到原文和来源信息。如果分开存,容易因为同步问题导致对不上号。
import faiss import numpy as np dim = 768 # 与嵌入模型维度一致 index = faiss.IndexFlatIP(dim) # 内积索引,配合归一化向量等价于余弦相似度 vectors = np.array(embed_texts(chunks)).astype('float32') index.add(vectors) # 检索 query_vec = np.array(embed_texts([query])).astype('float32') scores, ids = index.search(query_vec, top_k=5)IndexFlatIP是精确检索,不损失精度,适合中小规模。数据量大了可以换成IndexIVFFlat这类近似索引,用一点点召回损失换大幅速度提升。
3.5 检索与生成:把材料喂给模型
检索环节的核心参数是top_k,即返回几个片段。太小可能漏掉关键信息,太大则引入噪声、挤占上下文窗口。我一般从3到5开始试,根据效果调整。检索出来的片段最好按相似度排序,并且可以设一个最低相似度阈值,低于阈值的直接丢弃,避免把完全不相关的内容塞给模型。
生成环节的关键是提示词设计。要让模型明白:只能基于给定材料回答,材料里没有就说不知道,不要自己发挥。这个约束能大幅降低幻觉。
def build_prompt(query: str, contexts: list) -> str: context_text = "\n\n".join( f"[片段{i+1}] {c['text']}" for i, c in enumerate(contexts) ) prompt = f"""你是一个严谨的问答助手。请仅根据下面提供的资料回答问题。 如果资料中没有相关信息,请直接回答"根据现有资料无法回答",不要编造。 资料: {context_text} 问题:{query} 回答:""" return prompt提示词里明确"不要编造"和"无法回答"的兜底话术,是控制幻觉最有效的手段之一。我实测下来,加了这句约束之后,模型硬编答案的情况明显减少。
4. 实操过程与核心环节实现
4.1 环境准备与依赖
最小可用版本不需要太重的依赖。核心就是嵌入模型、向量库、大模型调用三块。我一般用Python,装几个基础库就能跑。嵌入模型如果本地跑,需要相应的推理框架;向量库用FAISS或Chroma;大模型调用看你是本地还是API。
pip install numpy faiss-cpu # 嵌入模型和大模型按你选用的方案安装对应依赖依赖版本要锁死,尤其是嵌入模型和向量库,版本不匹配容易出现维度对不上、接口变了这类问题。我习惯用虚拟环境隔离,避免污染全局环境。
4.2 端到端代码骨架
把前面各环节串起来,一个最小可用的RAG大概长这样:
class MiniRAG: def __init__(self, embed_model, llm_client, dim=768): self.embed_model = embed_model self.llm = llm_client self.index = faiss.IndexFlatIP(dim) self.chunks = [] # 存原文和元数据 def ingest(self, documents: list): all_chunks = [] for doc in documents: text = clean_text(doc['content']) pieces = split_by_structure(text) for p in pieces: all_chunks.append({'text': p, 'meta': doc.get('meta', {})}) texts = [c['text'] for c in all_chunks] vectors = np.array(self.embed_model.encode(texts, normalize_embeddings=True)).astype('float32') self.index.add(vectors) self.chunks.extend(all_chunks) print(f"已入库 {len(all_chunks)} 个片段") def query(self, question: str, top_k: int = 5, threshold: float = 0.3): q_vec = np.array(self.embed_model.encode([question], normalize_embeddings=True)).astype('float32') scores, ids = self.index.search(q_vec, top_k) contexts = [] for score, idx in zip(scores[0], ids[0]): if idx == -1 or score < threshold: continue contexts.append({'text': self.chunks[idx]['text'], 'meta': self.chunks[idx]['meta'], 'score': float(score)}) if not contexts: return "根据现有资料无法回答" prompt = build_prompt(question, contexts) answer = self.llm.generate(prompt) return answer, contexts这个骨架麻雀虽小五脏俱全,加载、切分、向量化、存储、检索、生成全在里面。你可以直接拿它当起点,把各个模块替换成自己的实现。
4.3 参数选择与计算过程
几个关键参数我展开说说怎么定。
切分长度:假设你的嵌入模型最大输入是512个token,中文大概一个字对应一到两个token,那单片段控制在300到400字比较稳妥,留出余量。太接近上限容易被截断。
重叠长度:一般取切分长度的10%到20%。400字的片段,重叠40到80字。重叠太少防不住跨片信息,太多则冗余严重。
top_k:假设你的上下文窗口能容纳2000字,每个片段400字,那最多塞5个片段。考虑到还要留空间给问题和回答,top_k取3到5比较合理。如果检索质量高,3个就够;如果召回不稳,可以适当加大再靠重排序过滤。
相似度阈值:这个没有固定值,取决于你的嵌入模型和数据类型。我的做法是先跑一批测试问题,观察正确片段的相似度分布,把阈值设在能过滤掉大部分无关片段、又不误伤正确片段的位置。通常0.3到0.5之间是个起点。
提示:这些参数不是一次定死的,要结合你的实际数据反复调。建议准备一组测试问答对,每次调参后跑一遍,看召回率和答案质量的变化。
4.4 一次完整的查询实录
假设知识库里有一份产品手册,用户问"退款需要几天到账"。链路走下来是这样的:
第一步,问题被编码成向量。第二步,向量库检索出最相近的几个片段,比如"退款流程说明""到账时间说明""售后政策"这几段。第三步,这些片段和问题一起拼成提示词。第四步,模型读到材料里有"退款审核通过后3到5个工作日到账",于是回答"根据资料,退款审核通过后一般3到5个工作日到账",并附上来源片段。
整个过程里,如果检索没召回"到账时间说明"那段,模型就只能说"资料里没有相关信息"。所以你看,答案质量的天花板其实是检索质量决定的。这也是为什么我一直强调,优化RAG先优化检索,别急着换大模型。
5. 常见问题与排查技巧实录
5.1 检索召回不准怎么排查
召回不准是最常见的问题,表现是答案答非所问或者干脆说不知道。排查我按这个顺序走:
先看切分是否合理。把没召回的那个正确片段找出来,看它是不是被切碎了,或者和一堆无关内容混在一起。如果是,调整切分策略。
再看嵌入模型是否匹配。你的文档是中文技术文档,用的却是英文为主的模型,效果自然差。换一个中文友好的模型试试。
然后看查询和文档是否同模型编码。这个前面提过,混用模型是隐形杀手。
最后看top_k和阈值。是不是top_k太小漏掉了,或者阈值太高把正确片段过滤了。临时把阈值调到0、top_k调到10,看正确片段在不在结果里,在的话就是参数问题,不在就是前面几个环节的问题。
5.2 模型还是胡编怎么办
如果检索明明召回了正确片段,模型还是编,那问题在生成环节。检查提示词有没有明确"仅根据资料回答"。有些模型对指令遵循不够强,可以加重语气,比如"必须""严禁编造"。还可以在提示词里要求模型引用片段编号,逼它对着材料说话。
另一个可能是上下文太长,模型"看花了眼"。把top_k降下来,只留最相关的几个片段,反而可能更准。我遇到过塞了10个片段模型反而答错、降到3个就对了的情况。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 答非所问 | 检索召回错误片段 | 检查切分、嵌入模型、阈值 |
| 总说不知道 | 召回为空或阈值过高 | 降低阈值、增大top_k |
| 答案编造 | 提示词约束不足 | 强化"仅根据资料"指令 |
| 检索很慢 | 数据量大且用暴力检索 | 换ANN索引 |
| 维度报错 | 嵌入模型与索引维度不一致 | 核对模型维度和索引dim |
| 结果重复 | 切分重叠过多 | 降低重叠比例 |
| 关键信息丢失 | 切分切断了语义 | 调整切分边界或加重叠 |
5.4 几个独家避坑技巧
第一个,建一个测试集。准备二三十个有标准答案的问题,每次改动后跑一遍,用命中率衡量效果。没有测试集,调参就是盲人摸象。
第二个,保留检索日志。每次查询把召回的片段、相似度、最终答案都记下来。出问题时能回溯,也能积累数据用于后续优化。
第三个,别迷信单一指标。召回率高不代表答案好,有时候召回了正确片段但模型没用好。要端到端看最终答案质量。
第四个,增量更新要小心。知识库更新时,如果只是追加新片段,旧的可能过时;如果全量重建,成本又高。我的做法是给片段打时间戳和版本号,检索时优先返回新版本,定期做全量清理。
6. 从最小可用到进阶优化的路径
6.1 什么时候该升级
最小可用版本跑通之后,你会逐渐发现它的瓶颈。当召回率上不去、答案质量遇到天花板时,就该考虑进阶优化了。但升级要有针对性,别为了用新技术而用。
如果发现语义匹配不够准,可以引入重排序模型。先粗召回一批(比如20个),再用重排序模型精排取前几个。重排序模型通常比嵌入模型更强,但更慢,所以只用在少量候选上。
如果发现关键词精确匹配丢失,可以上混合检索,把向量检索和BM25的结果融合。融合方式有加权求和、倒数排名融合等。这对包含专有名词、型号、代码的查询特别有用。
如果发现用户问题表述和文档差异大,可以做查询改写,让模型先把问题改写成更适合检索的形式,或者生成多个查询变体分别检索再合并。
6.2 优化优先级建议
我的经验是,优化按这个优先级来:切分质量 > 嵌入模型 > 检索策略 > 重排序 > 查询改写 > 生成提示词。越靠前的环节影响越大,越靠后的越是锦上添花。很多人一上来就搞查询改写、重排序,结果基础切分一团糟,怎么优化都上不去。
切分和嵌入模型是地基,地基不稳,上面盖再多花活也白搭。等这两块打磨好了,再逐步加检索策略和重排序,效果提升会很明显。
6.3 评估体系的搭建
没有评估就没有优化。最小可用阶段可以靠人工看,但要做持续优化,得有一套评估机制。我一般从两个维度评:检索质量和生成质量。
检索质量看召回率(正确片段有没有被召回)和准确率(召回的片段有多少是相关的)。生成质量看答案是否正确、是否忠于材料、是否完整。可以人工标注一批测试样本,也可以借助模型自动评估,但自动评估只能参考,关键场景还得人工把关。
评估集要覆盖各种问题类型:事实型、对比型、多跳型、无法回答型。特别是"无法回答"这类,专门测模型会不会硬编,很能反映系统的可靠性。
7. 我踩过的那些坑与个人体会
7.1 三个印象最深的教训
第一个教训是别忽视数据清洗。我曾经直接把一堆PDF丢进去,结果检索出来的片段全是页眉页脚和目录,有效内容被淹没。后来加了清洗环节,效果立竿见影。脏数据的破坏力远超想象,清洗这一步省不得。
第二个教训是切分不是越细越好。有段时间我追求细粒度切分,把每句话都切成一个片段,结果检索出来的都是零碎句子,模型拼不出完整答案。切分要在"聚焦"和"完整"之间找平衡,太细反而伤语义。
第三个教训是提示词值得反复打磨。同样的检索结果,提示词写得好不好,答案质量能差一大截。我前后改了十几版提示词,从最初的简单拼接,到后来明确角色、约束、兜底话术、引用格式,每一步都有提升。
7.2 关于RAG的一点个人看法
RAG这个方向,技术迭代很快,新方法层出不穷。但底层逻辑其实很稳定:把对的材料在对的时间给到模型。所有花哨的技巧,本质上都是在提升"找对材料"和"用好材料"这两件事的效率。
所以我的建议是,别被各种新名词带跑,先把最小链路吃透。你能亲手调通一条链路,理解每个环节的作用和瓶颈,再去学进阶方法,会事半功倍。反过来,如果连基础链路都没跑过,看再多高级技巧也只是纸上谈兵。
最后分享一个小技巧:如果你不确定某个优化有没有用,就做个A/B对比,固定其他变量只改一个,用测试集量化效果。RAG调优最怕凭感觉,数据说话才靠谱。这套最小可用代码你可以直接拿去改,把每个模块换成自己的实现,跑通之后你会发现,RAG没那么神秘,它就是一条需要耐心打磨的工程链路。