前段时间搭了一套完全离线运转的本地 RAG 问答系统,把平时依赖云端接口的内部文档问答全部挪回本机。说白了就是先把文档切块、向量化、建成索引,每次有人提问时先把最相关的片段捞回来,再连同问题一起扔给本地模型生成答案。这个方案解决的核心矛盾很明确:私有资料不出局域网,回答又能给出可追溯的引用,顺带还省掉了按 Token 计费的成本。
RAG 这个词听起来唬人,但本质就是给大模型配一个“开卷考试”的资料库。适合谁参考?手上有几十上百份内部制度、产品文档、运维手册的团队,或者单纯不想把数据交给第三方 API 的开发者。下面这套内容,我不会给你一个装完就跑的黑盒,而是把链路拆开讲清楚,每一环为什么这么选、参数怎么定、遇到问题往哪里查,都尽量交代明白。
1. 方案设计:先把本地 RAG 的链路和边界想清楚
1.1 RAG 为什么能治“胡说八道”
大模型出现幻觉,本质原因是它只会根据训练时见过的统计规律来续写。你问它一份内部运维手册里的操作步骤,这手册根本不在它的训练语料里,它就只能靠“印象”拼一个看似合理的答案。就算模型很聪明,这也是闭卷考试遇到没见过题,硬答必然出错。
RAG 的做法相当于改成了开卷考试:先把资料拆成一个个片段,提前转成向量存进索引;用户提问时,先用问题在索引里检索出最相关的若干片段,再把“资料片段 + 问题”一起交给模型。模型此时的角色从“回忆者”变成了“摘抄员”,它不需要肚子里有这份资料,只需要把眼前这几段文字读懂、组织好。这个动作虽然简单,却把答案的可信度上限直接从“模型训练数据”拉到了“你提供的资料全集”。
我见过不少团队上来就微调模型,想把私有知识灌进权重里。微调成本高、周期长,而且知识更新一次就要重训一次。RAG 的优势恰恰在于知识是外置的、可随时替换的:更新一份文档,重建对应的索引就行,模型完全不用动。对于知识更新频繁的团队来说,这个特性远比微调值钱。
1.2 一条本地 RAG 问答系统的完整链路
整个系统从构建到服务,大致分成两条链路。
离线构建链路:文档接入 → 格式解析 → 清洗去噪 → 切片 → 嵌入向量 → 写入向量库 → 提交索引。这一步可以定时跑、文档变更时增量跑,目的是把散落的文档整理成结构化的向量索引。
在线问答链路:用户提问 → 问题嵌入向量 → 向量库检索 TopK → 可选重排 → 拼接上下文 → 构造 Prompt → 本地模型生成 → 返回答案与引用。这一步追求的是低延迟和稳定输出。
两条链路里最难调的不是模型,而是中间那几道“不起眼”的环节。切片粒度直接决定召回命中率,嵌入模型决定语义匹配的上限,检索策略决定最终喂给模型的内容质量。生成模型反而是最容易被替换的一环——它只需要做到“看得懂资料、说得出人话”。
这里也要说清楚边界:RAG 解决的是“知识不足”和“知识过时”的问题,它不解决“模型推理能力弱”的问题。如果资料里明明白白写了一段结论,小模型可能读不懂、也不会归纳,这时候换更大的模型或者做推理增强才有意义。别指望 RAG 一个机制包治百病。
2. 技术选型:嵌入模型、向量库、生成模型怎么搭
2.1 嵌入模型怎么选才不拖后腿
嵌入模型负责把文本变成一串浮点数向量,语义越接近的文本,向量在空间里距离越近。它是检索质量的基石。如果嵌入模型对中文理解差,后面检索到的片段大概率是驴唇不对马嘴。
选嵌入模型主要看四件事:模型规模、向量维度、语言能力、运行资源。
模型规模决定了它能不能扛住语义复杂的内容。轻量级模型在 CPU 上也能跑,适合几万条以内的切块索引;中等规模模型需要一块入门级 GPU,语义效果明显上一个台阶;再往上就是超大 batch 模型,适合百万级甚至千万级语料的离线批处理。
向量维度也有讲究。高维度能表达更细的语义区别,但内存和计算开销成倍增加。举个例子,一个 1000 维的向量库存 50 万条切片,光向量本身就要占掉 50 万 × 1000 × 4 字节,也就是约 2GB 内存,还没算索引结构本身。语料量不大时用低维度模型完全够,别盲目追高。
部署方式上,我建议把嵌入模型封装成一个独立服务,而不是在问答主进程里直接调库。原因很简单:离线构建索引用到的是嵌入模型,在线问答也要用嵌入模型,两边共享同一个服务,加载一次权重、复用一套内存,还能避免不同模块各自加载模型导致的内存翻倍。
2.2 向量库和检索策略:不是存进去就完事
向量库负责存储向量并提供近邻检索。实现方案大致分三类,选型的核心依据是数据规模和并发要求。
| 方案类型 | 适合规模 | 持久化 | 运维成本 | 典型场景 |
|---|---|---|---|---|
| 嵌入式轻量方案 | 百万片段以内 | 落盘为单文件 | 极低 | 单机项目、Demo、个人知识库 |
| 独立向量服务 | 千万片段以上 | 独立数据目录 | 一般 | 多客户端并发、权限隔离 |
| 关系库向量扩展 | 混合使用 | 与业务数据同库 | 中等 | 已有关系库基础,想少一套组件 |
我的习惯是:前期直接用嵌入式轻量方案起步,先跑通链路,等数据量真涨上去了再迁移到独立服务。换后端时只需要改向量库客户端接口,上游的切片、嵌入逻辑完全不用动。
索引参数别用默认值就完事。主流向量库大多基于 HNSW 图索引,几个关键参数直接影响“召回质量”和“查询速度”:图节点连接数 M 建议 16~64,越大召回越准但内存越高;建索引时的 efConstruction 建议 100~200;查询时的 ef 建议按延迟预期来调,一般 40~100。这些都是经验值,不是拍脑袋——HNSW 本身就是“多一点索引时间,换更快的查询速度”的算法,参数就是那个交换器。
另外,生产环境一定不要只做纯向量检索。向量检索擅长语义匹配,但对“工单编号 TM-2024-001”“报错码 0x8004001F”这类精确词汇反而不敏感。专业名词、编号、缩写这些场景,传统的关键词匹配如 BM25 反而更稳。所以生产级方案通常是做混合检索:向量召回的候选集和关键词召回的候选集合并,再统一排序。这一步会在第 4 章详细展开。
2.3 本地生成模型:显存、量化与上下文怎么权衡
生成模型负责把检索到的片段组织成流畅答案。很多人的第一反应是“越大越好”,但在本地部署环境里,资源约束才是第一优先级。
量化是最有效的降本手段。大模型权重通常用 16 位浮点数存储,量化到 8 位或 4 位后,体积直接砍到一半甚至四分之一。一个简单估算公式:显存占用约等于“参数量 × 量化位数 ÷ 8”,再加上上下文缓存的开销。比如 70 亿参数模型,4 位量化后的权重约 3.5GB,再加上几 K 上下文的缓存,6GB 显存的显卡就能勉强跑;如果上 8 位量化,权重就要 7GB,显存就得至少 10GB。
上下文长度同样关键。RAG 场景里,每个候选片段可能是几百字,一次问答拼接 5 个片段再加上问题,几千个 Token 是很常见的。如果模型只支持 2K 上下文,装下两三段就满了,根本没法玩。建议至少选支持 4K 以上的模型,能支持 8K 或 32K 就更从容,这样还能兼容多轮对话场景。
生成速度上,问答场景通常不追求高并发,但用户等不了 1 分钟。本地部署时如果感觉慢,优先看两件事:一是量化是否到位,二是有没有开启流式输出。流式输出让首个字尽快出现,哪怕整段生成要十几秒,用户体感也会好很多。
3. 完整实战:手写一条最小可用的 RAG 问答链路
3.1 环境准备:一个干净的 Python 虚拟环境
我不喜欢把所有东西装进系统全局 Python,RAG 链路涉及的依赖杂,版本冲突是家常便饭。第一步永远是建虚拟环境。
python -m venv rag_env source rag_env/bin/activate pip install 文档解析库 向量库客户端 嵌入模型服务SDK 本地推理服务SDK上面的包名是占位符,你按自己选型对应替换就行。核心就四类依赖:文档解析、向量库操作、嵌入模型调用、LLM 调用。
目录结构建议固定下来:
data/ docs/ # 原始文档 index/ # 向量索引导出目录 logs/ # 问答日志与评测日志 scripts/ build_index.py # 离线构建索引 query.py # 在线问答 eval.py # 简单评测脚本我习惯把数据目录和脚本目录分开,这样重建索引时不会误删代码,备份数据也更方便。
3.2 文档清洗与切片:决定检索质量的第一步
很多人直接把 PDF 里的文字抽出来就进切片,结果检索效果一塌糊涂,问题往往出在清洗环节而不是模型。PDF 解析经常产生页眉页脚、分栏乱序、表格错位这些脏数据。这些噪声不处理,检索时会把“第 2 页中的页码”当成正文送入上下文,模型自然被带偏。
我的清洗清单:
- 抽取正文时过滤页眉页脚、水印、页码行。
- 分栏文档尽量用支持版面分析的解析器,避免左右栏文字串行。
- 表格单独处理,建议转成 Markdown 表格或每行一个条目,而不是原样抽成整段乱文本。
- 去掉重复段落,很多制度文档会大段引用同一个政策原文。
切片策略是最影响召回效果的手工活。切片太粗,比如整章只切一刀,上下文太长容易稀释关键信息;切片太细,比如每 100 字切一段,又容易把一个完整条款拦腰截断。我通常的起点是块大小 500~800 字符、重叠 100~150 字符。重叠的目的很简单——关键信息如果刚好落在两刀的接缝处,至少还能在相邻两个块里各出现一次。
如果文档本身带标题结构,优先按标题切分,标题作为元数据写入,检索时能返回“这段来自哪一节”,对后续引用溯源特别有用。
def split_text_by_size(text, chunk_size=600, overlap=120): chunks = [] start = 0 while start < len(text): end = start + chunk_size chunk = text[start:end] if chunk.strip(): chunks.append(chunk) if end >= len(text): break start = end - overlap return chunks这段代码是入门级切法。你要是追求更好效果,切的时候尽量避开句子中间——先按段落粗切,再在段落内部按字符细切补足大小,这样至少保证片段开头是一个完整句子。
3.3 向量索引构建:把切片变成可搜索的向量
文档切片之后,接下来要把每个切片变成向量并写入索引。核心代码骨架大概长这样:
from embed_client import EmbeddingClient from vector_store import VectorStore embedder = EmbeddingClient(model_name="your_embedding_model") store = VectorStore(index_dir="./data/index") for doc_path in doc_list: doc_id = path_to_doc_id(doc_path) sections = parse_document(doc_path) # [(title, text)] for i, (title, text) in enumerate(sections): sub_chunks = split_text_by_size(text) for j, chunk in enumerate(sub_chunks): vector = embedder.encode(chunk) store.add( id=f"{doc_id}#{i}-{j}", vector=vector, payload={ "doc_id": doc_id, "file_name": doc_path.name, "section": title, "text": chunk, } ) # 建议每 500 条批量提交一次,加快写入速度 store.commit()payload 里存的这些元数据不是可选项。没有 doc_id,后面引用溯源就是一句空话;没有 section 标题,用户看到回答都不知道来自哪一章。我第一版偷懒没存,后来做引用展示时只能重建索引,吃了大亏。
增量更新也要提前设计。同一份文档更新后,最好先用 doc_id 把旧索引条目删掉再写入新切片,否则向量库里会同时存在新旧两版内容,检索时新旧交叉出现。
3.4 检索问答主流程:把问题变成带引用的答案
在线问答主流程比构建索引更短,但细节决定成败。
def ask(query, top_k=5, min_score=0.3): query_vector = embedder.encode(query) hits = store.search(query_vector, top_k=top_k) # 低于分数阈值的结果宁可不要 hits = [h for h in hits if h.score >= min_score] if not hits: return "资料中未找到相关信息。", [] context = "\n\n".join( f"[来源{i}] {h.payload['text']}" for i, h in enumerate(hits) ) prompt = build_prompt(context, query) answer = llm_client.complete( prompt, max_tokens=512, temperature=0.1, ) return answer, hitsPrompt 模板一定要把“只依据资料回答”写死,否则模型会忍不住开脑洞。我用下来比较稳的模板:
你是知识库问答助手。请仅依据以下资料片段回答用户问题。 硬性要求: 1. 如果资料片段中没有答案,只回复“资料中未找到相关信息”,不要编造。 2. 回答末尾用 [来源0] 这类标记标注引用了哪个片段。 3. 答案要基于原文,不要添加资料外的信息。 资料片段: [来源0] 员工申请年假需提前三个工作日提交审批。 [来源1] 请假流程:登录系统,选择休假类型,提交后由直属领导审批。 问题:员工请假流程是什么? 回答:温度参数调到 0.1 上下,减少无谓的创造性发挥。问答场景不是创作场景,稳定和克制比文采重要。
最后返回答案时,把命中的 hits 一起带回去,前端就能渲染“引用来源文件”。这一步对用户建立信任极其重要,能够直接看到答案出处,系统可信度完全不一样。
4. 检索效果优化:从“能跑”到“好用”的三板斧
4.1 召回不准?先调切片、再做混合检索
系统跑通之后,第一个遇到的问题通常是“我明明知道答案在某某文档里,它就是检索不到”。不要急着调模型,先看召回。
建议先做个对照实验:把同一个文档分别按 300、500、800、1200 字符的切片各建一份索引,用二三十个真实问题跑一遍,看哪个块大小下命中率最高。这种实验看着笨,实际最有效。块大小没有放之四海皆准的数,它跟文档写作风格强相关——条款式文档适合大块,FAQ 式文档适合小块。
如果切片调完还是不理想,上混合检索。向量检索找语义相关,关键词检索找字面精确。两者合并后,把相关分数归一化到一起再排序:
score = alpha * 向量相似度 + (1 - alpha) * 关键词匹配归一化分alpha 一般取 0.5~0.7,先在测试集上扫一遍再定。别小看这个公式,我调过几个知识库,加入关键词召回后,Top1 命中率普遍能提升 10 个百分点以上。
4.2 重排与父文档块:把 TopK 的上下文补完整
第一轮向量检索的 Top 50 候选通常有较高的召回率,但排序本身不够精细。这时候接一个重排模块,用一个跨编码器模型对“问题-候选片段”逐对打分,取前 5 名作为最终上下文。代价是多了几十次推理,延迟会增加几百毫秒到一两秒,但问答准确率会明显上升。对延迟不是特别敏感的本地知识库场景,这笔交换很划算。
另一个容易忽视的问题是切片太碎导致上下文不完整。比如一个操作步骤被切成两半,检索只命中了后半段,模型就看到一个没头没尾的流程。解决办法是父子分块策略:父块写完整段落或整节,检索时用更细的子块去命中,命中后把整段父块文本返回给模型。
具体实现不复杂:写入索引时,每条子块记录“父块ID”和“父块文本”;检索到子块时,直接用 payload 里的父块文本替换子块文本作为上下文。这样既保留了细粒度检索的精准性,又保证了上下文完整性。
4.3 提示词约束与引用溯源:让回答可核查
优化完召回和重排,别忘了把输出阶段的约束也立起来。我在实际使用中发现一个规律:模型“自由发挥”很多时候不是模型的问题,而是 Prompt 没把边界说清楚。
几个有效做法:
- 在 Prompt 里写明“资料中没提到就说不知道”,这句话能让幻觉率肉眼可见地下降。
- 要求模型在每个回答句子的末尾标注来源序号,比如 [来源0]。虽然做不到百分百精确,但能逼模型对着资料来组织语言,而不是凭感觉瞎编。
- 上下文里把与问题最相关的片段放最前面。有些模型对长上下文的注意力会衰减,前面的内容通常更受重视。
- 去重很关键。同一个信息出现在多个切片里,不处理的话模型会像复读机一样重复。
评估也不能靠感觉。建议先建一个 30~50 条问题的小评测集,每条人工标注“期望命中的文档片段”。跑完后统计两个指标:Top5 召回命中率——看检索环节是否找到正确片段;回答准确率——看生成环节是否正确组织答案。评测脚本输出一份简单日志:
时间, 问题, 召回Top5命中文档, 各候选分数, 模型回答, 是否引用正确片段这样每次优化后跑一遍评测集,效果是涨是跌一目了然。没有评测集,所谓优化很可能只是自我感觉良好。
5. 常见问题排查与避坑清单
5.1 检索为空、答非所问怎么定位
按顺序排查,多数问题集中在下面几张表里:
| 现象 | 排查点 | 典型原因 |
|---|---|---|
| 什么都查不到 | 检查向量库条数 | 文档没写进索引里 |
| 查到了但完全无关 | 检查嵌入模型 | 向量模型与检索语种不匹配 |
| 相关片段分数很低 | 检查切片大小 | 切块太细,关键词密度不足 |
| 频繁触发阈值 | 检查 min_score | 阈值设太高,建议先用 0 观察分布 |
| 答案答非所问 | 打印实际召回上下文 | 上下文本身就没包含正确答案 |
我最推荐的一招是:把 min_score 暂时改成 0,检索系统把 Top10 候选全部打出来,仔细看每个片段的分数。分数分布会告诉你很多信息——如果全在 0.1 以下徘徊,说明嵌入模型或切片方向有问题;如果候选里有正确答案但排得很靠后,说明检索排序需要优化。先看数据,再做判断,不要瞎调参数。
5.2 资源不足、显存爆掉的实用降级方案
本地部署最常见的问题是“模型跑不起来”。处理顺序按成本从低到高排列:
- 生成模型量化降一级,8 位量化不行就换 4 位量化,效果损失在可接受范围内。
- 限制上下文长度。RAG 场景不一定每次都塞 5 个片段,降到 3 个,缓存占用立刻减少。
- 把嵌入模型放到 CPU 上跑。嵌入模型相比生成模型小得多,用 CPU 跑一条查询也就是几十毫秒的事,把 GPU 让给生成模型是性价比很高的分配。
- 开启向量库的磁盘映射。大批量数据别一次性全部加载进内存,改成按需读盘。
- 限制并发。本地知识库大多是团队内部使用,并发个位数很常见,别为了理论上的高并发白白牺牲响应速度。
5.3 回答出现原文没有的事实,问题出在哪一环
遇到模型“说漏嘴”,别急着怪模型。按下面这条线逐个排查:
先检查 Prompt 约束够不够硬。如果 Prompt 里没有“只依据资料回答”这种明确指令,模型自由发挥的倾向会大幅增加,这是最容易犯的错误。
再检查检索上下文。很可能检索到的片段本身就残缺或错误,模型只是把错误信息重新组织了一遍,看起来像幻觉实际上是忠实转述了坏输入。把每次问答的上下文打出来人工看一眼,立刻就能确认问题出在检索还是生成。
最后检查生成模型能力。小模型阅读理解能力有限,给它一段正反信息混杂的资料,它可能搞不清立场,答出模棱两可的话。这种场景只能换更强的模型,或者把资料切得更干净。
我自己排查时通常会做一个“零样本对照测试”:把检索到的资料从 Prompt 里抽掉,直接问模型同样的问题。如果没了资料模型反而答得差不多,说明它根本没依赖检索结果,Prompt 约束形同虚设,必须立刻补强指令。
最后分享一个踩过不少坑之后固定下来的习惯。拿到新语料,我不会急着把整套系统调到最优,而是先建一个三五十个真实问题的评测集,用最小链路跑一遍,把每次检索到的片段、分数、答案全部打日志。哪一步不对,直接从日志里就能看出是召回问题还是生成问题。先让链路可观测,再去谈准确率,这大概比盲目堆参数更重要。上面这套链路每一步都可以替换,环境变了、语料变了、模型升级了,只要你能快速定位问题出在哪一环,系统就能一直往下演进。