☰
本地RAG问答系统优化:指代消解、语义向量与TXT切分实战
2026/10/7 18:33:13 网站建设 项目流程

本地 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/10
text-embedding-v31024较好有好9/10
BGE-M3(本地)1024较好无一般8/10
bge-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_size=16, max_retries=3): embeddings = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] for attempt in range(max_retries): try: resp = client.embeddings.create(model="text-embedding-v3", input=batch) 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 整体流水线

讲完三个独立模块,现在把它们串起来。整个流水线分两条:离线入库流水线和在线问答流水线。

离线入库流水线:

  1. 读取原始 TXT 文件,统一编码为 UTF-8。这里有个常见的坑:早期网上下载的 TXT 很多是 GBK 编码,读进来全部乱码。我写了一个自动探测编码的函数,优先用 charset-normalizer 库,失败就用 utf-8 硬解码。
  2. 进行章节识别与切分,生成带路径的文本块列表。
  3. 对每个文本块做清洗,过滤噪声。
  4. 调用云端 embedding API 批量向量化。
  5. 把向量和元数据(文本块、章节路径、文档名)存入库中。本地环境我用 SQLite + numpy 数组存向量,向量相似度检索用 faiss-cpu 或者 numpy 点积都行,几十万字级别的文档量完全够用。

在线问答流水线:

  1. 接收用户当前问题。
  2. 和该会话最近 4 轮历史一起送入改写器,判断是否需要指代消解。
  3. 如果不需改写,直接对原始问题做 embedding;如果改写,对改写后的问题做 embedding。
  4. 在向量库里检索 top20。
  5. 再用 BM25 关键词检索 top10,和向量结果合并去重。
  6. 做一次轻量重排:把向量相似度得分和 BM25 得分做一个线性加权,取 top5。
  7. 把 top5 文本块拼接成上下文,连同改写后的独立问题一起送给生成模型。
  8. 生成回答。

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 别急着上大模型微调,先把检索链路里“切分、指代、向量”这三块基础工作做扎实。我见过太多项目在微调上花了几万块,最后发现效果还不如把文本块切分认真做一遍。知识库问答的准头,七分在检索,三分在生成。希望这份踩坑记录对你有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询