ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

本地RAG问答系统搭建全指南:从切片到检索的工程实践

本地RAG问答系统搭建全指南:从切片到检索的工程实践

前段时间搭了一套完全离线运转的本地 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, hits

Prompt 模板一定要把“只依据资料回答”写死,否则模型会忍不住开脑洞。我用下来比较稳的模板:

你是知识库问答助手。请仅依据以下资料片段回答用户问题。 硬性要求: 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 约束形同虚设,必须立刻补强指令。


最后分享一个踩过不少坑之后固定下来的习惯。拿到新语料,我不会急着把整套系统调到最优,而是先建一个三五十个真实问题的评测集,用最小链路跑一遍,把每次检索到的片段、分数、答案全部打日志。哪一步不对,直接从日志里就能看出是召回问题还是生成问题。先让链路可观测,再去谈准确率,这大概比盲目堆参数更重要。上面这套链路每一步都可以替换,环境变了、语料变了、模型升级了,只要你能快速定位问题出在哪一环,系统就能一直往下演进。

RELATED READING

延伸阅读

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