ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

RAG 检索增强生成七步链路实战:从文档切分到答案生成

RAG 检索增强生成七步链路实战:从文档切分到答案生成

RAG 检索增强生成七步链路实战:从文档切分到答案生成

一、为什么需要 RAG:三个绕不过去的限制

本地模型跑起来之后,你会撞上三个限制,它们恰好对应 RAG 的三个存在理由。

第一是幻觉。问模型"我们公司产品的保修期是多久",它不会说"不知道",而是基于训练时的通用知识编一个听起来合理的数字。对企业场景这是致命的。RAG 的思路是:先把文档里真正相关的段落搜出来,强制模型"照着资料答题",编造空间被大幅压缩。

第二是私有知识不在模型脑子里。公司合同、内部手册、上周的周报,训练语料里不可能有。RAG 不要求模型"记住"这些材料,只要求它"现场读"——每次提问时把相关资料临时塞进上下文。知识更新不需要重新训练,文档改了、重新入库,下一次提问就生效,这是 RAG 相对微调最大的运营优势。

第三是上下文窗口有限。就算不嫌贵,几十万字的文档也塞不进一次请求。RAG 只检索"最相关的 3-8 个块",上下文占用稳定可控。

一句话概括:RAG 就是给模型配一套"开卷考试"的资料检索系统。

二、标准链路七步:从文档到答案

RAG 的完整链路分两条泳道:离线建库(文档只处理一次)和在线问答(每次提问都走一遍)。

第①步:文档准备

输入是原始文档:PDF、Markdown、TXT、HTML。这一步最坑的在源头:扫描版 PDF 没有文字层,解析出来是空的,后面全白干,要先确认能复制出文字,不行就先 OCR。表格混排的文档抽取顺序常常是乱的,建议人工整理成 Markdown 再入库。还有一个经验:一百份产品手册和一百份合同混在一个库里,检索精度会被明显拖累,按主题分库更稳。

第②步:解析(Loader)

解析器把不同格式还原成纯文本。手写时一般用 LangChain 的各类 Loader 或各格式自己的解析库。可调点是元数据:解析时给每段文本附带来源信息(文件名、页码、章节名),后面调试"答案出处对不对"全靠它,也是给最终答案附引用的基础。

第③步:切块(Splitter)——最影响检索质量的参数

切块是 RAG 里最考验经验的地方。经验区间:单块 800-1200 字,相邻块 10-15% 重叠。两个方向各有一个坑:块太大,一个块塞太多主题,语义被"稀释",模型要在一大段里自己找答案;块太小,一句话一个块,语义不完整。重叠区让"跨边界的语义"在相邻块各留一份,缓解关键信息正好卡在切缝上的问题。

实操建议:优先按结构切(按章节、按段落边界),而不是按字数硬切。"按结构切为主、超长段落再按字数切"是更稳的组合。

第④步:嵌入(Embedding)

嵌入模型把文本映射成高维向量。两条硬约束:入库和检索必须用同一个嵌入模型,换模型后老库向量全部作废,必须重建——这是 RAG 最常见的坑;中文文档优先选中文能力强的模型,本地可用 bge-m3,轻量通用可选 nomic-embed-text。

第⑤步:入库(向量库)

所有块的向量连同原文、元数据写进向量数据库。选型参考:Chroma 嵌入式,适合个人和小数据量;FAISS 内存库,适合追求检索速度;Milvus 或 Qdrant 适合生产和海量数据。

第⑥步:检索(top-k)

把用户问题用同一个嵌入模型转成向量,去库里找距离最近的 k 个块。经验区间 top-k = 3-8:k 太小,相关块没排进前几,答案直接缺料;k 太大,噪声块引入,不仅浪费上下文,还会把模型注意力带偏。判断 top-k 是否合适有个土办法:把检索出来的块打印出来自己看一遍,前 3 个块里必须包含答案原文,否则不是模型的问题,是检索没命中。

第⑦步:拼 Prompt 与生成

把"系统指令 + 检索到的块 + 用户问题"拼成一次请求发给 LLM。Prompt 模板里两句指令很关键:“只根据下面提供的资料回答,资料里没有就回答’资料中没有相关信息’”——给模型留"说不知道"的出口,是压制幻觉的直接办法;资料和问题分开标清楚,别让模型分不清背景和问题。

三、最小可用代码:Ollama 嵌入 + Chroma

importrequestsimportchromadb OLLAMA="http://localhost:11434"LLM_MODEL="qwen2.5:7b"EMB_MODEL="bge-m3"defembed(texts):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")col.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"]+``` 验证分三层:Ollama 层确认嵌入模型在列表里;检索层把命中块打印出来,确认包含答案原文——这是金标准,检索没命中时别急着调 Prompt,先回切块和嵌入模型查;生成层确认回答与文档一致,且对资料里没有的问题回答"没有相关信息"而不是编造。## 四、回答质量排查:症状对应链路RAG 答错时,按症状反推是哪一步出了问题,不要一上来就换更大的模型:-检索不到(命中块全不相关)→ 切块/嵌入/文档质量有问题。检查是否扫描版 PDF 没抽到字,确认入库和检索用同一嵌入模型。--答非所问(命中对了但答偏)→ top-k 过大引入噪声或 Prompt 没写死"只根据资料回答"。降 top-k 到3再看。--幻觉(资料里没有的也编)→ Prompt 没留"说不知道"的出口,或模型能力不足。加"没有就回答不知道";仍不行换更大的对话模型。--答案"对但缺细节"→ 块切得太碎,关键句被切散。加大 chunk 或加大 overlap 重切重建。 调试习惯:每次只改一个变量,并把检索命中块打印出来对比。改完立刻知道是哪次改动起的作用。## 五、RAG vs 微调 vs 长上下文:先选路线再动手三条路线经常被混在一起,其实分工不同:"让它知道新事实"用 RAG,知识更新快、需要引用来源、冷启动项目都适合;"让它学会新本事"用微调,风格、格式、专业推理这类"怎么答"的问题微调更合适;两者可叠加,微调把基座调好,RAG 负责喂私有知识。 长上下文场景也有讲究:文档总量在几万字以内、且每次提问都高度相关(比如就一份合同反复问),直接整份塞进上下文可行,省掉检索失真的风险;文档总量超过上下文预算、或每次提问只跟一小部分相关,必须走 RAG。折中做法是"直接塞 + 检索"各留一部分。 生产环境里 RAG 答案最好带出处:解析时把文件名、页码存进 metadata,检索后把来源编号附在答案末尾。引用对不上时,问题多半出在切块边界——这既是用户体验,也是快速核对通道。把这条链路吃透,任何工具里的切块大小、top-k、嵌入模型这些参数,你调起来都有依据,而不是盲猜。
RELATED READING

延伸阅读

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