RAG检索增强实战:原理、七步链路与最小可用代码
这一篇拆开看 RAG 的七步链路——文档怎么切、向量怎么搜、答案怎么拼。理解了这条链路,才能判断知识库答不好时该动的是哪一环(切块、嵌入模型,还是提示词),也才能在现成工具之外,用 Python 手写一个自己的版本。
一、为什么需要 RAG
本地模型跑起来后,你会发现三个绕不过去的限制,它们恰好对应 RAG 的三个存在理由:
第一,模型会"一本正经地编"(幻觉)。问它"我们公司产品的保修期是多久",它不会说"我不知道",而是基于训练时的通用知识编一个听起来很合理的数字。对企业场景这是致命的。RAG 的思路是:先把你文档里真正相关的段落搜出来,强制模型"照着资料答题"——答案的出处在检索结果里,编造的空间被大幅压缩。
第二,私有知识根本不在模型脑子里。公司合同、内部手册、上月周报,训练语料里不可能有。RAG 不要求模型"记住"这些材料,只要求它"现场读":每次提问时把相关资料临时塞进上下文。这也意味着知识更新不需要重新训练——文档改了、入库了,下一次提问就生效,这是 RAG 相对微调的主要运营优势。
第三,模型的上下文窗口有限。就算不嫌贵,几十万字的整本文档也塞不进一次请求;而 RAG 只检索"最相关的 3-8 个块",上下文占用稳定可控。
可以这样概括:RAG 就是给模型配一套"开卷考试"的资料检索系统。AI-17/AI-18 里的知识库按钮,背后跑的就是本篇这条链路。会了原理,你在任何工具里调参(切块大小、top-k、嵌入模型)都有依据,而不是盲猜。
二、环境要求
| 项目 | 要求 | 说明 |
|---|---|---|
| 系统 | Windows 10/11 或 Linux(WSL2) | 本篇代码示例两者通用 |
| Python | 3.10 / 3.11 / 3.12(保守选 3.11) | 环境隔离方法见《AI-03 Python环境与虚拟环境》 |
| Ollama | 已装好,能跑对话模型 + 嵌入模型 | 安装见《AI-07 Ollama本地部署大模型》 |
| 对话模型 | 如 qwen2.5:7b(Q4_K_M 权重约 4-5 GB) | 以ollama list输出为准 |
| 嵌入模型 | nomic-embed-text 或 bge-m3 | 模型名以 ollama.com/library 为准 |
| Python 包 | chromadb、requests | pip install可走清华镜像,见第四节 |
| 显存 | 嵌入模型是小模型,不吃多少显存;瓶颈在对话模型 | 预算方法见《AI-05 显存计算与模型选择》 |
| 磁盘 | 向量库随文档量增长,留 10 GB 空闲 | 大文件远离 C 盘(系列惯例) |
注意一个常见误解:RAG 本身不需要 GPU。嵌入模型参数量小、CPU 就能跑,真正吃显存的只有最后一步的对话 LLM。纯 CPU 机器搭 RAG 完全可行,只是回答速度慢。
三、标准链路七步:从文档到答案
RAG 的完整链路分两条泳道:离线建库(文档只处理一次)和在线问答(每次提问都走一遍)。七步逐一拆,每步给可调参数和典型坑。
3.1 第①步:文档准备
输入是原始文档:PDF、Markdown、TXT、HTML 等。这一步最容易出的坑在源头:
- 扫描版 PDF 没有文字层,解析出来是空的,后面全白干——先确认能复制出文字,不行就先 OCR;
- 格式混杂(一页里图、表、正文混排)时,表格抽出来的文字顺序常常是乱的,这类文档适合人工整理成 Markdown 再入库;
- 按主题拆文档:一百份产品手册和一百份合同混在一个库里,检索精度会被明显拖累,我一般按主题分库(Chroma 里就是不同的 collection)。
3.2 第②步:解析(Loader)
解析器负责把不同格式还原成纯文本。用 AnythingLLM/Dify 时这步自动完成;手写时一般用 LangChain 的各类 Loader 或各格式自己的解析库(接口迭代快,以官方文档当前版本为准)。
可调点:元数据(metadata)。解析时建议给每段文本附带来源信息(文件名、页码、章节名),后面调试"答案出处对不对"全靠它,也是给最终答案附引用的基础。
3.3 第③步:切块(Splitter)——最影响检索质量的参数
把长文本切成"块(chunk)",是 RAG 里很考验经验的地方。经验区间:单块 800-1200 字,相邻块 10-15% 重叠。为什么是这个区间,两个方向各有一个坑:
- 块太大:一个块塞了太多主题,语义被"稀释"。用户问"保修期",命中的块里一半是别的主题,模型要在一大段里自己找答案,精度下降、还占用了上下文空间;
- 块太小:一句话一个块,语义不完整。"本产品保修期为一年"没问题,但"保修期内免费换新,保外维修仅收工本费"拆成两半后,每半单独拿去嵌入都表达不清;
- 重叠(overlap):切块边界恰好把一句话、一条逻辑切断时,重叠区让"跨边界的语义"在相邻块里各留一份,缓解"关键信息正好卡在切缝上"的问题。经验值 10-15%,重叠太大则库里冗余度上升、top-k 命中多个近似重复块。
另一个实操建议:优先按结构切(按章节、按段落边界)而不是按字数硬切——"按结构切为主、超长段落再按字数切"是更稳的组合。
3.4 第④步:嵌入(Embedding)
嵌入模型把文本映射成高维向量,语义相近的文本向量距离近。两个硬约束:
- 入库和检索必须用同一个嵌入模型——换模型后老库向量全部作废,必须重建。这是 RAG 里最常见的坑,第六节第 2 条专门讲;
- 嵌入模型选中文能力强的:本地 Ollama 里
bge-m3对中文文档更稳,nomic-embed-text轻量通用(模型名以 ollama.com/library 为准);维度与模型选型的完整对比是下一篇《AI-20 向量数据库与嵌入模型》的主题。
3.5 第⑤步:入库(向量库)
所有块的向量连同原文、元数据一起写进向量数据库。本地自搭的常用选择:
| 向量库 | 部署方式 | 适合 |
|---|---|---|
| Chroma | 嵌入式,pip install chromadb | 个人 / 小数据量,本篇用它 |
| FAISS | pip install faiss-cpu,内存中 | 数据量大、追求检索速度 |
| Milvus | Docker 部署 | 生产 / 海量数据,见《AI-40 Docker-GPU一键全家桶》 |
三者选型细节(维度、持久化、备份)下一篇展开,本篇用 Chroma 跑通链路。
3.6 第⑥步:检索(top-k)
把用户问题用同一个嵌入模型转成向量,去库里找距离最近的 k 个块。经验区间top-k = 3-8:
- k 太小(1-2):真正相关的那块如果没排进前二,答案直接缺料;
- k 太大(>10):引入噪声块,不仅浪费上下文,还会把模型注意力带偏——检索结果里混进"看着相关其实答非所问"的段落,模型可能顺着它编。
判断 top-k 是否合适有个土办法:把检索出来的块打印出来自己看一遍,前 3 个块里必须包含答案原文,否则不是模型的问题,是检索没命中。
3.7 第⑦步:拼 Prompt 与生成
把"系统指令 + 检索到的块 + 用户问题"拼成一次请求发给 LLM。Prompt 模板里两句指令很关键:
- “只根据下面提供的资料回答,资料里没有就回答’资料中没有相关信息’”——给模型留"说不知道"的出口,是压制幻觉的直接办法;
- 资料和问题分开标清楚,别让模型分不清哪些是背景、哪些是问题。
生成步本身没有"RAG 参数"可调,但模型能力决定表达质量:7B 级模型检索给对了资料通常能答对,复杂推理类问题建议换更大的模型(如 14B)。
四、验证:跑通一个最小 RAG(Ollama 嵌入 + Chroma)
下面是最小可跑的代码骨架,演示"入库 → 检索 → 拼 Prompt → 调 Ollama"完整闭环。各库接口迭代较快,具体函数名与参数名以 chromadb 与 Ollama 官方文档当前版本为准,本篇不罗列未核实的 API 字段。
先装依赖(国内网络走清华 pip 镜像):
pip install chromadb requests -i https://pypi.tuna.tsinghua.edu.cn/simple拉嵌入模型(对话模型假定已有,如 qwen2.5:7b):
ollama pull bge-m3importrequestsimportchromadb OLLAMA="http://localhost:11434"LLM_MODEL="qwen2.5:7b"# 以 ollama list 输出为准EMB_MODEL="bge-m3"# 以 ollama.com/library 为准defembed(texts):"""调 Ollama 的嵌入接口,把文本列表变成向量列表。 接口路径与请求字段以 Ollama 官方文档(Embeddings)为准。"""resp=requests.post(f"{OLLAMA}/api/embed",json={"model":EMB_MODEL,"input":texts})resp.raise_for_status()returnresp.json()["embeddings"]# ---- 离线建库(一次性)----client=chromadb.PersistentClient(path="./rag_store")# 持久化路径col=client.get_or_create_collection("manual")# 按主题分 collection# 文档 → 解析 → 切块(chunk 800-1200 字 + 10-15% 重叠),# 具体切块函数以所用库官方文档为准,这里直接给出切好的文本示意chunks=["……第一个块……","……第二个块……"]# 实际来自 Loader + Splittercol.upsert(ids=[str(i)foriinrange(len(chunks))],embeddings=embed(chunks),documents=chunks,)# ---- 在线问答(每次提问)----defrag_answer(question:str,top_k:int=5)->str:hits=col.query(query_texts=[question],n_results=top_k)context="\n".join(hits["documents"][0])prompt=("只根据下面的资料回答用户问题;资料中没有相关信息就回答""'资料中没有相关信息'。\n\n【资料】\n"+context+"\n\n【问题】"+question)resp=requests.post(f"{OLLAMA}/api/chat",json={"model":LLM_MODEL,"messages":[{"role":"user","content":prompt}],})resp.raise_for_status()returnresp.json()["message"]["content"]print(rag_answer("这个产品的保修期是多久?"))验证标准(照 AI-17 的"三层探针"):
- Ollama 层:
curl http://localhost:11434/api/tags返回模型列表,且嵌入模型在列; - 检索层:把
hits["documents"][0]打印出来,确认命中块里确实包含答案原文; - 生成层:最终回答与文档原文一致,且对文档里没有的问题回答"资料中没有相关信息"而不是编造。
第 2 层是金标准:检索没命中时别去调 Prompt,先回切块和嵌入模型查。
五、进阶技巧
5.1 回答质量排查:症状对应链路
RAG 答错时先不要急于更换更大的模型,按症状反推是哪一步:
| 症状 | 最可能出问题的步骤 | 排查动作 |
|---|---|---|
| 检索不到(命中块全不相关) | 切块 / 嵌入 / 文档质量 | 打印命中块人眼确认;检查是否扫描版 PDF 没抽到字;确认入库和检索用同一嵌入模型;bge-m3 与 nomic-embed-text 换用对比 |
| 答非所问(命中对了但答偏) | top-k 过大引入噪声 / Prompt | 降 top-k 到 3 再看;检查 Prompt 是否写死"只根据资料回答" |
| 幻觉(资料里没有的也编) | Prompt 没留出口 / 模型能力 | Prompt 加"没有就回答不知道";仍不行则换更大的对话模型 |
| 答案"对但缺细节" | 块切得太碎(关键句被切散) | 加大 chunk 或加大 overlap 重切重建 |
调试习惯:每次只改一个变量(切块大小、重叠、top-k、嵌入模型各试一轮),并把检索命中块打印出来对比,改完立刻知道是哪次改动起的作用。
5.2 RAG vs 微调:先选路线再动手
两条路线常被混在一起,其实分工不同:
- 知识更新快 → 选 RAG。政策、价格、产品参数这类"月月变"的信息,微调一次要准备数据、训练、评估,RAG 改文档即可;
- 风格 / 格式 / 能力 → 选微调。你要的是"说话像我们品牌"“输出固定 JSON 结构”“学会某类专业推理”,这类"怎么答"的问题微调更合适,LoRA 是本地微调的主流方案,详见《AI-37 LoRA微调训练实战》;
- 两者可叠加:微调把基座调好,RAG 负责喂私有知识。
选型口诀:"让它知道新事实"用 RAG,"让它学会新本事"用微调。
5.3 RAG vs 长上下文:什么时候直接塞
现在有模型的上下文窗口很大,"文档不长为什么还要 RAG"是合理疑问。经验值:
- 文档总量在几万字以内、且每次提问都高度相关(比如就一份合同反复问),直接整份塞进上下文可行,还省掉检索失真的风险;
- 文档总量超过上下文预算、或每次提问只跟一小部分相关(知识库场景),必须走 RAG——不是模型"能不能装下"的问题,是"塞满了但注意力被稀释、成本上升"的问题;
- 折中做法:直接塞 + 检索各留一部分(关键全文进上下文 + 检索补充细节),以模型实际支持的上下文长度为准。
5.4 让答案"可引用"
生产环境里 RAG 答案最好带出处:解析时把文件名/页码存进 metadata,检索后把来源编号附在答案末尾。这既是用户体验,也是"答案对不对"的快速核对通道——引用对不上时,问题多半出在切块边界。
六、故障排查(按层定位)
| # | 症状(报错原文) | 层 | 原因 | 解决 |
|---|---|---|---|---|
| 1 | 连接 Ollama 失败(localhost:11434连接被拒 / Connection refused) | 服务 | Ollama 没启动,或端口 11434 被占 | 先ollama list确认本体正常;Linux 看sudo journalctl -u ollama;再确认 11434 未被其他进程占用 |
| 2 | 嵌入或入库时报维度不匹配(Chroma 报维度与 collection 不一致 / 旧向量维度对不上) | 框架 | 换过嵌入模型(新旧模型输出维度或向量空间不同),旧库向量作废 | 换嵌入模型必须重建库:删掉旧 collection 或换持久化路径,全部文档重新切块入库;此后同库只用一个嵌入模型 |
| 3 | 检索不到相关内容(命中块与问题无关 / “没找到相关知识”) | 应用 | 文档没解析成功(扫描版 PDF)、或切块/入库没走完、或入库与检索嵌入模型不一致 | 确认 PDF 能复制出文字(不行先 OCR);小文档单独重建验证;核对两侧嵌入模型名一致;再按 5.1 症状表调参 |
| 4 | ModuleNotFoundError: No module named 'torch'或chromadb(装包后仍报找不到) | 环境 | 激活了错误环境 / 包装到了别的解释器 | where python(Win)/which python(Linux)确认解释器指向当前 venv;重新激活后再装 |
| 5 | pip install chromadb下载卡住 /ConnectionError/ 超时 | 网络 | 国内访问 PyPI / HuggingFace 受限(Chroma 首跑可能拉默认嵌入组件) | pip 走清华镜像(本文命令已带-i);HuggingFace 侧设HF_ENDPOINT=https://hf-mirror.com或改用 ModelScope,思路同《AI-06 模型下载全攻略》 |
| 6 | Linux 下建库/嵌入时进程被杀:Killed/ dmesg 出现oom-kill | 内存 | 物理内存不足(RAM,不是显存):大量文档一次性嵌入吃满内存 | WSL2 在.wslconfig里加swap=(改完wsl --shutdown生效);物理机加 swap;或分批入库 |
| 7 | CUDA error: out of memory(最后一步 LLM 生成时) | 显存 | 对话模型 + 长 Prompt 超出显存 | 降量化位宽、压短上下文;nvidia-smi清后台占用;方法见《AI-28 CUDA报错大全与排查》 |
| 8 | 答案乱码 / 无意义重复(检索明明命中了) | 量化 | 对话模型量化位宽过低(Q2/Q3) | 升到 Q4_K_M 及以上;确认权重与 tokenizer 同源 |
排错顺序仍是先底后顶:Ollama 通不通(curl 探针)→ 包与环境对不对 → 文档解析好不好 → 检索命中没有(打印块)→ 最后才调 RAG 参数和怀疑模型。多数这类问题出在前四步。
七、本篇自检清单
- 能说清 RAG 解决的三类问题(幻觉、私有知识、上下文有限)与"开卷考试"类比
- 能按顺序背出七步链路:文档 → 解析 → 切块 → 嵌入 → 入库 → 检索 → 拼 Prompt 生成
- 知道切块经验区间(800-1200 字 + 10-15% 重叠)和两个方向的坑(太大稀释语义 / 太小语义不完整)
- 知道 top-k 经验区间 3-8,以及"打印命中块人眼确认"的土办法
- 已用 Ollama 嵌入 + Chroma 跑通最小 RAG,能说出三层验证标准(Ollama 层 / 检索层 / 生成层)
- 牢记两条硬约束:入库与检索必须同一嵌入模型;换嵌入模型必须重建库
- 会按症状定位问题:检索不到 → 切块/嵌入;答非所问 → top-k/Prompt;幻觉 → Prompt 出口/模型
- 会做选型:知识更新快选 RAG、风格格式选微调(AI-37);文档短且全相关可直接塞长上下文