本地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 建议 1664越大召回越准但内存越高建索引时的 efConstruction 建议 100200查询时的 ef 建议按延迟预期来调一般 40100。这些都是经验值不是拍脑袋——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 虚拟环境我不喜欢把所有东西装进系统全局 PythonRAG 链路涉及的依赖杂版本冲突是家常便饭。第一步永远是建虚拟环境。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 字切一段又容易把一个完整条款拦腰截断。我通常的起点是块大小 500800 字符、重叠 100150 字符。重叠的目的很简单——关键信息如果刚好落在两刀的接缝处至少还能在相邻两个块里各出现一次。如果文档本身带标题结构优先按标题切分标题作为元数据写入检索时能返回“这段来自哪一节”对后续引用溯源特别有用。def split_text_by_size(text, chunk_size600, overlap120): 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_nameyour_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( idf{doc_id}#{i}-{j}, vectorvector, 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_k5, min_score0.3): query_vector embedder.encode(query) hits store.search(query_vector, top_ktop_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_tokens512, temperature0.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.50.7先在测试集上扫一遍再定。别小看这个公式我调过几个知识库加入关键词召回后Top1 命中率普遍能提升 10 个百分点以上。4.2 重排与父文档块把 TopK 的上下文补完整第一轮向量检索的 Top 50 候选通常有较高的召回率但排序本身不够精细。这时候接一个重排模块用一个跨编码器模型对“问题-候选片段”逐对打分取前 5 名作为最终上下文。代价是多了几十次推理延迟会增加几百毫秒到一两秒但问答准确率会明显上升。对延迟不是特别敏感的本地知识库场景这笔交换很划算。另一个容易忽视的问题是切片太碎导致上下文不完整。比如一个操作步骤被切成两半检索只命中了后半段模型就看到一个没头没尾的流程。解决办法是父子分块策略父块写完整段落或整节检索时用更细的子块去命中命中后把整段父块文本返回给模型。具体实现不复杂写入索引时每条子块记录“父块ID”和“父块文本”检索到子块时直接用 payload 里的父块文本替换子块文本作为上下文。这样既保留了细粒度检索的精准性又保证了上下文完整性。4.3 提示词约束与引用溯源让回答可核查优化完召回和重排别忘了把输出阶段的约束也立起来。我在实际使用中发现一个规律模型“自由发挥”很多时候不是模型的问题而是 Prompt 没把边界说清楚。几个有效做法在 Prompt 里写明“资料中没提到就说不知道”这句话能让幻觉率肉眼可见地下降。要求模型在每个回答句子的末尾标注来源序号比如 [来源0]。虽然做不到百分百精确但能逼模型对着资料来组织语言而不是凭感觉瞎编。上下文里把与问题最相关的片段放最前面。有些模型对长上下文的注意力会衰减前面的内容通常更受重视。去重很关键。同一个信息出现在多个切片里不处理的话模型会像复读机一样重复。评估也不能靠感觉。建议先建一个 3050 条问题的小评测集每条人工标注“期望命中的文档片段”。跑完后统计两个指标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 约束形同虚设必须立刻补强指令。最后分享一个踩过不少坑之后固定下来的习惯。拿到新语料我不会急着把整套系统调到最优而是先建一个三五十个真实问题的评测集用最小链路跑一遍把每次检索到的片段、分数、答案全部打日志。哪一步不对直接从日志里就能看出是召回问题还是生成问题。先让链路可观测再去谈准确率这大概比盲目堆参数更重要。上面这套链路每一步都可以替换环境变了、语料变了、模型升级了只要你能快速定位问题出在哪一环系统就能一直往下演进。