
本地 RAG 问答系统跑通不难跑准很难。我见过太多这样的 demo文档切块入库、向量检索 top5 塞进提示词、大模型一答看起来什么都有了。可你在真实对话里追问一句“那它后来怎么样了”系统立刻懵住你翻到第 12 章问“这个方法的缺点呢”它把全书所有带“缺点”的句子都捞回来结果答非所问。上线给业务同事用对方冷冷一句“还不如直接搜文档”就把你打回工位。把本地 RAG 做准绕不开三件事多轮指代消解、语义向量质量、TXT 文本切分方式。这三件事分别回答三个问题模型能不能听懂“它”“这个”“上面提到的 X”语义匹配能不能在“意思相近但词完全不一样”时依然召回正确段落文本切分能不能把一整本 TXT 拆成互不干扰、又恰好顺应语义边界的知识块。这篇文章就是我在这三个方向上的完整落地方案包含代码、参数和踩坑记录适合正在做本地知识库问答、又想把效果从“能答”推到“能准”的工程同学参考。1. 为什么本地 RAG 问答总让人觉得“不够准”1.1 三个典型的“答错”现场先看我最常被问到的三个失败场景都是真实用户反馈不是编出来的。第一个是多轮追问翻车。用户先问“这本书里讲了哪些数据清洗方法”系统给了 5 个方法。用户接着问“第二种的适用场景是什么”系统没听懂“第二种”指代的是上一条回答里的某个方法直接全文检索“第二种适用场景”结果什么都没找到。这种问题本质上是对话状态没被利用起来。第二个是同义改写召回失效。文档里写的是“模型过拟合会导致泛化能力下降”用户问的是“为什么测试集效果比训练集差那么多”这两个句子在词面上几乎没有重叠如果向量模型不够强或者文本块切得太碎导致语义被割裂检索结果就是空的甚至把无关段落捞回来。第三个是章节语义被切块切没了。很多 TXT 文档的结构是“第一章 xxx”“1.1 xxx”这种分层的如果用固定窗口切分比如每 500 字一刀切经常把“第一章”的小标题和正文切断把“第三章”的内容和“第二章”的标题拼在一起。检索时看起来返回的是 top5实际上这些块的身份信息已经丢了。这三个场景其实指向同一个结论问题不在大模型本身在检索链路。生成环节的幻觉可以被提示词压下去但前提是正确的内容真的被检回来了。1.2 病根不在模型在检索链路RAG 的完整链路是文档解析 → 切分 → 向量化 → 入库 → 检索 → 重排 → 生成。很多人只盯着最后一步生成觉得模型好就万事大吉但前面任何一环出问题生成的回答就是“一本正经地胡说八道”。我觉得最容易被忽略的是两个点。一是召回质量的上限由切分和向量共同决定你得保证正确答案对应的片段确实存在于知识库里并且能通过某种匹配方式被找出来。二是多轮对话里用户的问题往往是残缺的如果不做指代消解或问题改写再强的检索也等于是在用残缺信息做全文搜索。我自己的经验是把这三块补齐之后系统从“偶尔答对”变成“稳定答对”的效果远比换个更大参数的生成模型明显。这也是为什么我要把标题里三个关键词当成一个整体工程来做而不是分别调优。1.3 “准”的定义我先定了一个可量化的目标做任何优化前都得先定义什么是“准”。我给自己定的目标是三个维度同时达标检索命中率针对一组测试问题正确答案对应的章节或文本块是否出现在召回 top5 中。目标 85% 以上。指代消解准确率多轮对话中改写后的问题是否保留了原始意图且指代关系正确。目标 90% 以上。回答可用率生成的答案是否忠于原文没有编造内容且确实回答了当前问题。目标 80% 以上。这三个指标分开测互不干扰这样排查问题时才不会一团浆糊。下面三个章节分别讲我怎么做到了这些指标。2. 多轮指代消解让系统理解“它”到底是谁2.1 指代消解在哪一步做先说结论指代消解要在检索之前做把用户当前轮的问题变成一条“不依赖上下文也能被检索”的完整问题再拿去查向量库。很多人喜欢直接把历史对话全部塞给检索器或者塞给生成模型让它自己理解。前者的问题是历史越长检索的输入越杂向量化之后和当前问题混在一起召回精度反而下降。后者的问题是生成模型确实能理解上下文但检索这一步已经因为“残缺问题”找错了片段你再让模型理解也没用它只能在错误片段上发挥。所以我的做法是在检索前面加一个“问题改写器”。它接收最近的对话历史和当前用户问题输出一条改写后的、独立完整的问题。改写器可以是本地小模型也可以直接复用你的生成模型区别只是成本和时间。2.2 轻量级实现规则替换 LLM 改写兜底这里我采用了一个“先规则、后模型”的两级方案既省钱又稳。第一级规则替换。用正则从当前问题里找出“它”“这个”“那个”“上述内容”“第二种”“前者”这类明显依赖上下文的表达再从对话历史里抽最近的实体名词做简单替换。这个方案适合指代非常明显的场景比如用户刚问完“BERT 和 GPT 有什么区别”接着问“它们的训练成本呢”规则就能把“它们”替换成“BERT 和 GPT”。import re def rule_based_rewrite(question, history): # 从历史最后两轮中提取候选实体 entities [] for turn in history[-2:]: # 这里可以接入NER/关键词抽取简单场景用名词短语正则 candidates re.findall(r[\u4e00-\u9fa5A-Za-z0-9]{2,20}, turn[answer]) entities.extend(candidates) # 给每个指代词替换成最近一次出现的实体 replacements { 它: entities[0] if entities else 上述内容, 这个: entities[0] if entities else 上述内容, 那个: entities[0] if entities else 上述内容, 这些: entities[0] if entities else 上述内容, 上面提到的: entities[0] if entities else 上述内容, } for pron, target in replacements.items(): question question.replace(pron, target) return question这种办法在测试集上能解决大概 30% 的简单指代但它不是万能的。“第二种”这种序数词指代“它”在句子中指代的对象需要结合语义判断规则就会失效。第二级LLM 改写兜底。规则没覆盖到的调用本地或者云端的 LLM让它把当前问题改写成独立问题。我给它的提示词模板是这样的你是对话改写助手。 输入{对话历史} 用户最新问题{question} 任务把用户最新问题改写成一个不依赖上下文的独立问题。 规则 1. 把“它”“这个”“那个”“这些”“第二种”“前者”等指代替换成上下文里明确提到的名词。 2. 不要改变原问题的意图。 3. 只输出改写后的问题不要输出任何解释。实测下来这一步能把指代消解的准确率从 30% 提升到 90% 以上。一个关键参数是历史轮数不要超过 4 轮。超过 4 轮LLM 改写时容易把旧话题混进来改写出来的问题和当前意图偏离得很离谱。我后来把历史窗口固定为最近 4 轮准确率最稳。2.3 会话缓存与改写时机的工程细节指代消解不是独立函数它涉及会话管理。我在工程上做了两个取舍。第一个取舍是历史答案的存储形式。我不仅存历史问题还存系统生成的回答片段。因为“第二种”往往指的是生成答案里的第二个方法而不是用户自己说过的内容。每条历史记录里我把“问题”“答案原文”“答案经过文本切块后的摘要”都存下来改写时优先用摘要降低 token 消耗。第二个取舍是改写时机。不是每一轮都要改写。我加了一个判断如果当前问题里存在“代词 无具体名词”或“序数词 名词”这种模式才触发改写器。如果用户问题本身就是完整句子直接跳过改写省一次 LLM 调用。在线问答场景里这个判断能把平均延迟降低 40% 左右。还有一个细节要注意改写结果要缓存。同一个用户、同一轮对话如果刷新页面重复请求不要让改写器再算一次。用 Redis 或者内存字典缓存改写结果key 可以用“会话 ID 问题哈希”TTL 设置成 30 分钟就够了。3. 云端语义向量选型和工程化3.1 为什么本地模型向量拼不过云端 API本地 RAG 的“本地”一般指的是生成模型部署在本地但 embedding 模型未必。我一开始图省事直接用本地的一个小模型做 embedding维度 384跑下来发现两个问题。一是同义词命中率很低。本地小模型在通用领域的语义理解上和云端大模型 embedding 差距明显。用户问题里的“为什么测试效果差”跟文档里“测试集泛化能力下降”在小模型眼里几乎是两个话题。二是领域术语处理不理想。我处理的文档大多是技术手册、操作规范、产品说明里面有很多“专有缩写 上下文语义”的组合本地小模型容易把缩写当成普通词导致向量空间里相近的词离得不够近。后来我把 embedding 环节换成云端 API效果立刻提升了一个档。这里的本质原因是云端语义向量的训练数据覆盖面和模型容量都更大能在更高维度的空间里把“不同表达、同一语义”拉近。当然代价是要付费、有网络开销、有速率限制。但对我这个场景来说准比省更重要。3.2 模型选型与参数细节现在国内主流的云端 embedding API 有阿里百炼、百度千帆、腾讯云、智谱等我经过测试选的是阿里百炼的 text-embedding-v3维度设成 1024原因有三它的中文长文本理解稳定、API 兼容性好、调用文档清晰。选型时我列过一个对比表实测下来综合效果最好的是 v3模型维度中文语义匹配批量接口文档支持综合评分text-embedding-v21536中等有一般7/10text-embedding-v31024较好有好9/10BGE-M3本地1024较好无一般8/10bge-large-zh本地1024中等无一般7/10这里有个特别重要的参数文本块长度。embedding API 一般有 512 token 或 1024 token 的长度限制超过会被截断。我用 800 字符的文本块做了实验发现 512 token 以内的块效果最稳超过之后信息密度反而下降。所以我的最终策略是文本块控制在 500-800 字符之间embedding 时如有必要就再截断到 512 token。另一个容易被轻视的坑是API 有 QPS 限制。大批量入库时比如一本书 300 个文本块如果 for 循环一次发一个请求速度慢不说还容易触发限流。我改成批量接口每次传 16 个文本块整体入库时间从十几分钟压缩到两分钟限流问题也基本消失。3.3 大批量入库与在线检索时的限流处理入库阶段和在线阶段对 embedding API 的使用模式完全不同。入库阶段是“短时间内有很多文本要向量化”在线阶段是“每次请求只向量化 1-2 个短文本”。我做了一套通用封装分两个函数处理。入库时用 batch 模式加指数退避重试def embed_texts_batch(texts, batch_size16, max_retries3): embeddings [] for i in range(0, len(texts), batch_size): batch texts[i:ibatch_size] for attempt in range(max_retries): try: resp client.embeddings.create(modeltext-embedding-v3, inputbatch) embeddings.extend([item.embedding for item in resp.data]) break except Exception as e: if attempt max_retries - 1: raise time.sleep(2 ** attempt) return embeddings在线检索时用单条模式同时把 embedding 结果放进 LRU 缓存。同一个问题如果被用户改写过多次或者多个用户问高度相似的问题缓存命中率很高能省下大量 API 调用。我在缓存里存的不是原始问题的 embedding而是改写后的问题的 embedding。因为改写器可能把两个不同的原始问题改写成同一句完整问题这样缓存直接命中的概率更大检索也更快。4. TXT 章节切分把半结构化文本榨出结构4.1 为什么要章节切分而不是固定窗口TXT 文件看起来是最简单的文本格式没有 PDF 的布局信息没有 Word 的样式标记但是它有另一种结构章节标题、序号、段落分隔。这些结构信息对 RAG 特别重要。固定窗口切分的缺点是它生成的知识块和人类阅读的语义单元不对应。比如一本技术书里“第二章 环境准备”下面可能讲了安装、配置、验证三个部分固定窗口切分可能把“安装”的结尾和“配置”的开头拼在一起检索时返回的块语义混杂生成模型没法直接引用。章节切分的好处是每个切出来的块天然拥有自己的主题检索命中时返回的片段更完整、更可引用也更容易在生成时给出“根据第 X 章第 Y 节”这样的出处。另外章节标题本身就是非常强的语义锚点“第二章”这几个字带有的语义信息远大于无意义的 100 个空格。4.2 章节识别正则与代码我处理 TXT 文档时先做一步预处理统一换行符、去除页眉页脚、去除多余空行。然后按行扫描用正则识别章节标题。下面是核心识别逻辑import re chapter_patterns [ # 中文常见章节标题第一章 xxx、第12章 xxx、第十二节 xxx r^第[一二三四五六七八九十百千0-9][章节卷回部篇][ \t]*[^\n]{1,30}$, # 数字层级标题1.2 环境准备、3.1.2 部署 r^\d(\.\d){1,3}[ \t][^\n]{2,30}$, # 标题行短文本且后跟空行比如前言、附录A r^[^\n]{2,20}$(?\n\s*\n), ]第三个正则用了“短文本 后跟空行”的启发式规则可以识别没有“第 X 章”前缀的标题。这个规则有误伤风险有些普通短句后面也恰好像空行所以我还会加一个排除列表比如“第一章 xxx”“前言”之外的“摘要”“引用”这种常见的章节词。识别到章节标题后把标题后面的正文累积到一个章节块里直到遇到下一个标题。每个章节块生成时我会保存它的“章节路径”比如“第三章 3.2”这样检索命中后可以追溯到具体位置。4.3 切块的间距、重叠和清洗规则识别完章节之后还要解决一个问题章节不小一个章节动辄一两万字不能直接作为一个块入库还要再切。这个切分必须在章节内部做切分的边界尽量落在段落之间而不是硬按字符数腰斩。我最终的切分规则是目标长度600 字符最小长度200 字符重叠50-100 字符切分点优先选择段落边界\n\n其次选择句号、问号、感叹号最后才是逗号重叠区间的考虑是如果用户问“为什么结果不理想”答案可能在上一个块的末尾和下一个块的开头没有重叠就会出现“差一口气”的召回失败。50-100 字符的重叠足以覆盖这种边界接缝。切完之后还要做清洗去掉块首尾的空白字符、去掉孤立标题、去掉只有一两个字的残句。这一步非常重要——一个以冒号结尾的块会让生成模型以为后面还有内容回答时会强行续写胡话。我吃过这个亏后来对所有切分结果做一次后置校验丢掉的块不足 1%但整体回答可读性提升非常明显。5. 端到端串联从文档入库到多轮问答5.1 整体流水线讲完三个独立模块现在把它们串起来。整个流水线分两条离线入库流水线和在线问答流水线。离线入库流水线读取原始 TXT 文件统一编码为 UTF-8。这里有个常见的坑早期网上下载的 TXT 很多是 GBK 编码读进来全部乱码。我写了一个自动探测编码的函数优先用 charset-normalizer 库失败就用 utf-8 硬解码。进行章节识别与切分生成带路径的文本块列表。对每个文本块做清洗过滤噪声。调用云端 embedding API 批量向量化。把向量和元数据文本块、章节路径、文档名存入库中。本地环境我用 SQLite numpy 数组存向量向量相似度检索用 faiss-cpu 或者 numpy 点积都行几十万字级别的文档量完全够用。在线问答流水线接收用户当前问题。和该会话最近 4 轮历史一起送入改写器判断是否需要指代消解。如果不需改写直接对原始问题做 embedding如果改写对改写后的问题做 embedding。在向量库里检索 top20。再用 BM25 关键词检索 top10和向量结果合并去重。做一次轻量重排把向量相似度得分和 BM25 得分做一个线性加权取 top5。把 top5 文本块拼接成上下文连同改写后的独立问题一起送给生成模型。生成回答。5.2 检索与重排多路召回 关键词融合很多 RAG 方案只做纯向量检索这在技术手册类文档上效果不够稳。为什么因为技术文档里经常出现“专有名词 缩写”比如“QPS”“TTL”“RAG”这些词的向量表示有时不稳定但关键词精确匹配却能精准命中。所以我选择了向量 BM25 双路召回。BM25 的实现可以直接用 rank_bm25 库也可以用 ES但本地环境没必要上 ES。我用的 repr 是 rank_bm25对切分后的文本块建立索引。合分逻辑是final_score 0.6 * vector_score_norm 0.4 * bm25_score_norm这个权重是我用测试集调出来的。向量语义为主、关键词为辅符合我们这个“中文技术文档问答”的场景。如果你处理的是偏口语化的对话文本可能要反过来向量权重调到 0.7 甚至 0.8。重排后取 top5我发现这个数量对生成模型来说是“信息量 token 消耗”的平衡点。top3 经常漏细节top10 又会因为信息重复让生成结果变得啰嗦。top5 是稳定方案。5.3 用 20 组对话验证“准”到哪种程度经过上面的链路搭建我构造了一个 20 组对话的测试集每组对话包含 3-5 轮追问模拟真实用户的使用方式。问题类型覆盖三种事实提取“这本书里说了哪几种方法”、对比分析“A 和 B 有什么区别”、指代追问“那它们的缺点呢”。我统计了三个指标的变化指代消解前检索命中率只有 58%改写到改写后命中率提升到 88%。纯向量检索的命中率是 78%加入 BM25 融合后命中率提升到 91%。章节切分 vs 固定窗口切分正确章节召回率从 64% 提升到 89%。所以最终结论很明确这三个优化不是可选项而是把 RAG 从“demo 可用”推到“生产可用”的必经之路。6. 踩坑实录与自查清单6.1 六个常见问题的排查方法这些坑都是我实际工作中踩过的整理成表格供排查参考现象可能原因排查与解法回答经常引用不相关章节切分时把章节标题和正文切断检查文本块是否包含章节路径元数据修正切分点多轮追问时答案前后矛盾指代消解只改写了问题没改写上下文检查改写器是否丢掉了上一轮答案的关键实体同样的表述换个说法就查不到embedding 模型太弱换云端 API 模型或者把同义词典加进检索前预处理入库耗时太长单条调 embedding API 且没做重试改成 batch 模式加退避重试用户问缩写词如“QPS”返回空向量无法匹配缩写BM25 权重不够提高 BM25 权重在切分阶段保留缩写全称回答凭空编造内容检索 top5 不包含答案模型只能瞎编先查召回命中率如果命中率低回头查切分和向量6.2 实用技巧总结最后分享几个我目前仍在用的实用技巧。第一给文本块编号。每个文本块入库前我给它一个全局 ID格式是“文档名_章节路径_块序号”。检索返回时生成模型可以引用这个 ID回答里出现“根据 2.3 节”这类出处用户体验会好很多。第二把改写器的输出回存。多轮对话里改写器输出的完整问题我会回存到会话上下文中。这样后续轮次的改写能基于“真正送进检索的完整问题”来展开而不是基于用户最初的碎片问题。这一步对多轮指代消解准确率的提升非常明显。第三定期重跑 embedding。如果你换了更高级的云端 embedding API旧向量不会自动更新。我当时升级模型时吃了个大亏——入库用的是 v2检索改成 v3两个模型的向量空间不完全一致导致相似度得分失真。后来我写了一个重向量化脚本每次换模型就把知识库全部重新向量化一遍才解决这个问题。6.3 成本与效果平衡整个方案跑下来云端 embedding 的成本大概是每 100 万字符几十元级别对于本地个人知识库来说一个月花费基本可以忽略。生成模型用的是本地 GPU 部署因此在线问答没有 API 费用。这套“本地生成 云端向量”的混合架构是我目前找到的成本与效果最佳平衡点。最后给一个单纯的个人体会做 RAG 别急着上大模型微调先把检索链路里“切分、指代、向量”这三块基础工作做扎实。我见过太多项目在微调上花了几万块最后发现效果还不如把文本块切分认真做一遍。知识库问答的准头七分在检索三分在生成。希望这份踩坑记录对你有用。