RAG 这个词这两年在圈子里被聊得很多,但真正动手在自己机器上跑通一套完整链路的人其实没想象中那么多。大部分人停留在“知道是检索增强生成”的层面,一旦要落地,就会卡在文档切分怎么切、向量库选哪个、检索召回不准怎么办这些具体问题上。我前后折腾过几套不同规模的本地 RAG 方案,从个人笔记检索到团队内部知识库都试过,踩的坑不算少。这篇就把 RAG 的原理拆开讲清楚,再把本地化落地的完整流程、参数选择、排查技巧一次性摊开来说,适合想自己动手搭一套、又不想被各种框架绕晕的朋友参考。核心关键词 RAG、向量检索、文档切分、本地部署、召回优化会贯穿全文,读完你至少能独立跑通一套能用的本地知识问答系统。
1. 先想清楚 RAG 到底在解决什么问题
1.1 大模型的两个硬伤与 RAG 的定位
要理解 RAG,得先理解它为什么存在。大语言模型有两个绕不过去的硬伤:一是知识有截止时间,训练数据之后发生的事情它不知道;二是它不知道自己不知道,遇到没见过的内容会一本正经地编。这两个问题在业务场景里是致命的,你不可能让一个客服机器人对着用户胡编产品参数。
常见的补救思路有两条:微调和 RAG。微调是把新知识灌进模型参数里,成本高、周期长,而且知识一更新就得重训,非常笨重。RAG 走的是另一条路——不改模型,而是在模型回答之前,先从外部知识库里把相关资料检索出来,塞进提示词里,让模型“看着资料回答”。打个比方,微调像是让学生把整本书背下来,RAG 像是开卷考试,考前把可能用到的章节翻到桌上。开卷的好处显而易见:资料随时能换、能增、能删,模型本身不用动。
所以 RAG 的本质是一个“检索 + 生成”的两段式架构。检索负责从海量文档里找到和问题最相关的片段,生成负责把这些片段组织成通顺、准确的回答。它的价值不在于让模型变聪明,而在于让模型的回答有据可查、可溯源、可更新。这也是为什么企业知识库、文档问答、客服助手这类场景特别偏爱 RAG——答案能指回原文,用户信任度完全不一样。
1.2 一套 RAG 系统的完整数据流
很多人一上来就写代码,结果跑到一半发现不知道哪一环出了问题。我建议先把整条链路在脑子里过一遍。一套标准的 RAG 系统分两个阶段:离线索引和在线查询。
离线索引阶段做三件事:把原始文档(PDF、Word、网页、Markdown 都行)解析成纯文本;把长文本切成一个个语义相对完整的小块(chunk);把每个小块通过嵌入模型转成向量,存进向量数据库。这个阶段是一次性的,文档更新时增量做就行。
在线查询阶段也做三件事:用户提问后,把问题用同一个嵌入模型转成向量;在向量库里做相似度搜索,召回 Top-K 个最相关的 chunk;把这些 chunk 拼进提示词模板,连同问题一起发给大模型,生成最终答案。
这条链路里,任何一个环节出问题都会导致最终回答拉胯。文档解析烂了,后面全白搭;切分切得稀碎,检索出来的片段没有上下文;嵌入模型选错,语义相似度算不准;召回数量 K 设得不合理,要么漏信息要么塞一堆噪声。后面几节我会逐个环节拆开讲,把每个环节的取舍逻辑和实操细节说透。
1.3 本地化部署的动机与边界
为什么强调“本地化”?原因无非几个:数据敏感,文档不能出内网;成本可控,不想按 token 付费;可控性强,模型、嵌入、向量库全在自己手里,出问题能查。本地化意味着你要自己扛起整条链路的算力,模型得能跑在自己的机器上。
这里要先泼盆冷水:本地化不是万能的。如果你的机器只有集成显卡、内存 16G,跑 7B 级别的模型做生成还行,但嵌入模型和向量检索也会吃资源,整体体验会比较勉强。我的经验是,生成模型至少要有 8G 以上显存(量化后 7B 模型能跑),嵌入模型用小尺寸的(比如 300M 以内的),向量库用轻量的本地方案,这样一套下来在消费级机器上能跑得动。如果硬件实在有限,也可以把生成环节放到有算力的地方,检索和索引留在本地,做成混合架构,这个后面会提。
2. 文档解析与切分:决定成败的隐形环节
2.1 文档解析的坑比想象中多
大部分人把注意力放在模型和向量库上,结果栽在最不起眼的文档解析。PDF 是最典型的例子:扫描版 PDF 没有文字层,直接提取出来是空的,得先做 OCR;双栏排版的 PDF 提取出来文字顺序会乱,左右栏内容交错在一起;表格提取更是灾难,行列结构丢失后变成一堆数字,语义完全变了。
我的处理原则是分类型对待。纯文本类(Markdown、TXT、网页正文)直接读,最省心。Word 用对应的解析库提取段落,注意保留标题层级,因为标题对后续切分的语义完整性很重要。PDF 要先用工具判断有没有文字层,没有就走 OCR,有的话提取后还要做一次版面清理,把页眉页脚、页码这些噪声去掉。表格单独处理,能转成 Markdown 表格就转,转不了就整块保留并加一句说明,别让它被切碎。
提示:解析完的文本一定要人工抽查几段。我见过提取出来全是乱码还硬着头皮往下跑的,后面检索效果差到怀疑人生,回头才发现是解析阶段就烂了。
2.2 切分策略:固定长度还是语义切分
切分是 RAG 里最容易被低估的环节。切太大,一个 chunk 里混了好几个主题,检索出来噪声多,还浪费上下文窗口;切太小,一句话被拦腰截断,语义不完整,模型拿到手也拼不出意思。
最朴素的做法是固定长度切分,比如每 500 个字符一刀,块之间留 50 字符重叠。重叠的作用是防止关键信息正好卡在切口上被切断,前后两块都能沾到一点。这个方案简单粗暴,对格式规整的文档够用,但对结构复杂的文档就不行了。
更讲究的是按语义结构切。Markdown 按标题层级切,每个二级标题下的内容作为一个块,超长再按段落细分;代码文档按函数或类切;问答类文档按一问一答切。这种切法能保证每个 chunk 语义自洽,检索质量明显更好。我实测下来,结构化切分比固定长度切分的召回准确率能高出一截,代价是要针对不同文档类型写不同的切分逻辑。
具体参数上,chunk 大小我一般控制在 300 到 800 字符之间,中文文档偏小值,因为中文信息密度高。重叠比例取 chunk 大小的 10% 到 20%。这些不是死规定,得根据你的文档特点和检索效果调。有个简单的判断方法:随便抽几个 chunk 读一遍,如果读起来像一段完整的话,说明切得合适;如果读起来莫名其妙,那就是切坏了。
2.3 元数据:被忽视的检索增强利器
每个 chunk 除了文本内容,还应该带上元数据:来源文件名、所属章节标题、页码、更新时间。这些信息在检索时能派上大用场。比如用户问“某产品的退款政策”,你可以先用元数据过滤出产品文档,再在里面做向量检索,召回精度立刻提升。生成阶段把来源信息一起给模型,模型回答时能注明出处,用户一看就知道答案从哪来,信任感完全不同。
元数据的另一个用处是做增量更新。文档改了,你通过元数据里的文件路径和更新时间就能定位到哪些 chunk 需要重建索引,不用全量重跑。文档量大的时候,这个优化能省下大量时间。
3. 嵌入模型与向量库的选型逻辑
3.1 嵌入模型怎么选才不踩雷
嵌入模型负责把文本转成向量,它的质量直接决定检索准不准。选型时看几个维度:语言支持(中文场景必须选中文或多语言效果好的)、向量维度(维度高表达能力强但存储和计算成本高)、模型大小(本地跑要考虑显存和速度)、是否支持长文本(有些模型对超过一定长度的输入会截断)。
本地化场景下,我倾向于选参数量在 1 亿到 3 亿之间的多语言嵌入模型,量化后几百兆,CPU 也能跑,速度可以接受。太大的模型虽然效果好一点,但推理慢,索引几万条文档时等待时间会让人抓狂。这里有个容易忽略的点:嵌入模型和生成模型是两回事,别想着用生成模型兼职做嵌入,效果通常不如专门的嵌入模型,而且慢。
还有一个实操细节:查询向量和文档向量必须用同一个嵌入模型生成。我见过有人索引时用一个模型、查询时换了另一个,结果相似度算出来全是乱的,排查了半天才发现是模型不一致。这个错误很低级但真的常见,务必在代码里把模型路径写死,别让两处配置漂移。
3.2 向量库的本地化选择
向量库的选择取决于数据规模和部署环境。数据量小(几万条以内)、单机部署,用轻量级的本地向量库就够了,它把向量存成文件,加载进内存做暴力搜索,简单可靠,不需要额外服务。数据量上到几十万条,暴力搜索会变慢,这时候需要支持近似最近邻(ANN)索引的库,用 HNSW 之类的算法把搜索复杂度降下来。
选型时别只看性能,还要看运维成本。有些向量库要单独起服务、配集群,对个人项目来说太重了。我的建议是:起步阶段用嵌入式方案,数据量涨上来再迁移。迁移成本主要在重建索引,只要你的索引脚本写得好,换个库重跑一遍就行,不算大事。
存储方面,向量本身占的空间不小。一个 768 维的向量,用 float32 存是 3KB 左右,十万条 chunk 就是 300MB,加上原文和元数据,整体在 GB 级别。规划磁盘时要把这部分算进去,别到时候空间不够。
3.3 相似度度量与 Top-K 的取舍
向量检索的核心是算相似度,常用的是余弦相似度和内积。余弦相似度只看方向不看长度,对文本向量更合适,因为文本长度差异不该影响语义相似判断。大部分嵌入模型训练时就是按余弦相似度优化的,所以检索时保持一致就行。
Top-K 是召回多少个 chunk 给生成模型。K 太小,可能漏掉关键信息;K 太大,塞进一堆不相关的内容,既浪费上下文窗口又干扰模型判断。我的经验是 K 取 3 到 5 起步,根据效果调。如果发现答案经常缺信息,适当加大 K;如果答案经常被无关内容带偏,就减小 K 或者加一个相似度阈值,低于阈值的直接丢掉。
这里有个进阶技巧:召回之后加一步重排序。先用向量检索粗召回 20 条,再用一个小的重排序模型对这 20 条精排,取前 5 条给生成模型。重排序模型能捕捉查询和文档之间更细粒度的相关性,实测对最终答案质量提升明显,代价是多一次模型推理。本地化场景下,重排序模型选小尺寸的,几百兆,跑起来不费劲。
4. 本地化实操:从零跑通一条链路
4.1 环境准备与依赖安装
先把环境搭起来。Python 是主流选择,建议用虚拟环境隔离依赖,避免版本冲突。核心依赖包括:文档解析库(处理 PDF、Word 等)、嵌入模型推理库、向量库、以及调用本地生成模型的客户端。
python -m venv rag_env source rag_env/bin/activate pip install pypdf python-docx sentence-transformers faiss-cpu上面这组是 CPU 环境的最小依赖。如果你有 GPU,把 faiss-cpu 换成对应的 GPU 版本,sentence-transformers 会自动用 GPU 加速嵌入计算。生成模型这边,本地跑一般用支持量化推理的框架,把模型权重下载到本地目录,通过本地接口调用,不依赖任何外部服务。
注意:依赖版本要锁死,尤其是嵌入模型库和向量库,版本不匹配时向量格式可能对不上,报错信息还很隐晦。建议用 requirements.txt 固定版本号。
4.2 索引构建的完整脚本
索引构建是整个流程的地基,我把它拆成解析、切分、嵌入、入库四步,写成一条流水线。下面是一个可参考的骨架,实际使用时按你的文档类型调整解析部分。
from sentence_transformers import SentenceTransformer import faiss import numpy as np import json # 1. 加载嵌入模型(本地路径) model = SentenceTransformer("./models/embedding-model") # 2. 读取并切分文档(这里用简化示例,实际按文档类型处理) def load_and_split(path, chunk_size=500, overlap=80): with open(path, "r", encoding="utf-8") as f: text = f.read() chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) start = end - overlap return chunks chunks = load_and_split("./docs/sample.md") # 3. 生成向量 embeddings = model.encode(chunks, normalize_embeddings=True) embeddings = np.array(embeddings).astype("float32") # 4. 建索引并保存 index = faiss.IndexFlatIP(embeddings.shape[1]) # 内积,配合归一化等价于余弦 index.add(embeddings) faiss.write_index(index, "./index/vector.index") # 5. 原文和元数据单独存 with open("./index/chunks.json", "w", encoding="utf-8") as f: json.dump([{"id": i, "text": c} for i, c in enumerate(chunks)], f, ensure_ascii=False)这段代码里有两个关键点值得说。一是normalize_embeddings=True,把向量归一化后,内积就等于余弦相似度,检索时用 IndexFlatIP 即可,省去额外转换。二是原文和向量分开存,向量库只负责相似度搜索,返回的是 id,再通过 id 去 chunks.json 里取原文。这样设计的好处是向量库可以随时换,原文存储不受影响。
4.3 查询与生成的串联
查询阶段把用户问题转成向量,检索出 Top-K,拼提示词,调本地生成模型。提示词模板的设计很关键,直接影响回答质量。
def retrieve(query, top_k=4): q_vec = model.encode([query], normalize_embeddings=True).astype("float32") scores, ids = index.search(q_vec, top_k) with open("./index/chunks.json", "r", encoding="utf-8") as f: chunks = json.load(f) return [(chunks[i]["text"], float(s)) for i, s in zip(ids[0], scores[0])] def build_prompt(query, contexts): ctx_text = "\n\n".join([f"[片段{i+1}] {c}" for i, (c, _) in enumerate(contexts)]) return f"""你是一个严谨的问答助手。请仅根据下面提供的资料回答问题。 如果资料中没有相关信息,直接回答"资料中未提及",不要编造。 资料: {ctx_text} 问题:{query} 回答:"""提示词里那句“仅根据资料回答、没有就说未提及”非常重要。不加这句,模型很容易用自己的先验知识补充,导致答案和资料对不上。加了之后,回答的可控性明显提升。生成模型通过本地接口调用,把 build_prompt 的结果传进去,拿到返回文本即可。
4.4 参数调优的实测记录
参数不是拍脑袋定的,我记录了一组实测数据供参考。测试集是 200 条内部文档问答,评估指标是答案是否包含正确信息。
| chunk 大小 | 重叠 | Top-K | 召回命中率 | 备注 |
|---|---|---|---|---|
| 300 | 50 | 3 | 72% | 切太碎,上下文不足 |
| 500 | 80 | 4 | 85% | 综合表现最稳 |
| 800 | 120 | 4 | 81% | 部分块混入无关内容 |
| 500 | 80 | 8 | 83% | 噪声增多,提升有限 |
从数据看,chunk 500、重叠 80、Top-K 4 是这组文档的甜点区。但这不是通用答案,你的文档结构、问题类型不同,最优参数会漂移。建议自己搭一个小测试集,几十条就够,把参数网格跑一遍,用数据说话比凭感觉靠谱得多。
5. 检索效果差?按这个顺序排查
5.1 常见问题速查表
RAG 系统跑起来容易,跑好难。下面这张表是我踩坑后整理的排查顺序,从高频到低频排列。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 答案完全跑题 | 检索没召回相关内容 | 打印召回的 chunk 看是否相关 |
| 答案缺关键信息 | Top-K 太小或切分切断信息 | 加大 K,检查切分边界 |
| 答案夹带无关内容 | 召回噪声多 | 加相似度阈值或重排序 |
| 回答编造资料外内容 | 提示词约束不够 | 强化“仅根据资料”指令 |
| 检索结果时好时坏 | 嵌入模型与查询不一致 | 核对两处模型路径 |
| 中文检索效果差 | 嵌入模型中文能力弱 | 换多语言或中文优化模型 |
排查的核心方法是把中间结果打出来看。很多人只看最终答案,猜来猜去。正确做法是在检索后打印召回的 chunk 原文和相似度分数,一眼就能看出是检索的问题还是生成的问题。检索召回的相关 chunk 里明明有答案,模型却没答对,那是提示词或生成模型的问题;召回的 chunk 本身就不相关,那是切分或嵌入的问题。定位到环节,再对症下药。
5.2 几个容易被忽略的实操心得
第一个心得:查询改写能显著提升召回。用户的问题往往口语化、指代多,直接拿去检索效果一般。可以先用生成模型把问题改写成更完整、更书面化的检索式,再去做向量检索。比如“那个退款咋弄”改写成“产品的退款流程和申请条件是什么”,召回质量立刻不一样。这一步多一次模型调用,但收益很大。
第二个心得:混合检索比纯向量检索稳。向量检索擅长语义匹配,但对精确的关键词、编号、专有名词不敏感。加一路关键词检索(比如 BM25),两路结果融合,能覆盖向量检索漏掉的情况。实测在包含大量产品型号、条款编号的文档上,混合检索的召回率比纯向量高出一截。
第三个心得:定期重建索引。文档会更新,嵌入模型也可能升级,索引放久了会和实际文档脱节。我一般设一个周期任务,检测文档变更后增量重建,保证检索库和源文档一致。这个习惯能避免很多“明明文档里有却搜不到”的诡异问题。
5.3 硬件吃紧时的降级方案
不是每个人都有大显存的机器。硬件有限时,可以这样降级:嵌入模型选最小的多语言版本,量化后几百兆,CPU 推理也能接受;向量库用内存版,数据量控制在几万条;生成模型用量化版本,7B 模型量化到 4bit 后 8G 显存能跑,再不行就用更小的模型或者把生成环节放到有算力的地方,检索和索引留在本地。这种混合架构在数据敏感场景里很实用——敏感文档不出本地,只有脱敏后的检索片段参与生成。
降级的代价是效果和速度,要有心理预期。我的建议是先把链路跑通,再根据实际瓶颈逐步升级硬件或换更大的模型,别一上来就追求顶配,容易在环境配置上耗光热情。
6. 从能跑到好用:几个进阶方向
链路跑通只是起点,真正拉开差距的是细节打磨。第一个方向是查询理解,除了前面说的查询改写,还可以做意图分类,不同类型的问题走不同的检索策略,比如事实型问题走精确检索,开放型问题走宽召回。第二个方向是上下文压缩,召回的 chunk 里往往只有一两句话真正相关,把无关部分裁掉再给模型,既省窗口又减噪声。第三个方向是答案溯源,把生成答案里引用的片段标出来,用户点击能跳回原文,这个功能在知识库场景里体验提升非常明显。
还有一个值得投入的方向是评估体系。没有评估就没有优化方向。搭一个小规模的测试集,覆盖典型问题和边界情况,每次改动后跑一遍,看指标是涨是跌。这套东西前期花点时间,后期调参、换模型、改切分策略时能帮你省下大量试错成本。我见过太多人凭感觉调 RAG,改了半天不知道到底有没有变好,有了评估集,一切用数据说话。
这套本地 RAG 方案我在几台不同配置的机器上都部署过,最深的体会是:模型和框架的选择固然重要,但真正决定效果的往往是文档解析、切分策略、提示词约束这些“脏活累活”。把这些基础环节做扎实,哪怕用中等尺寸的模型,效果也能打得过配置豪华但细节粗糙的方案。后续如果文档量继续涨,我会优先考虑把向量库换成支持 ANN 索引的方案,再引入重排序,一步步来,不追求一步到位。