本地RAG问答提升三件事:章节切分、指代消解与语义向量

📅 发布时间:2026/10/7 12:58:20
本地RAG问答提升三件事:章节切分、指代消解与语义向量
1. 为什么你的本地 RAG 总在回答正确但没用的话先说我遇到的实际问题。本地部署了一套 RAG 知识库问答系统文档也切了、向量也建了、检索也能召回但用户真正问起来就是不对味。最典型的三类翻车现场用户接着上一轮问那它的部署方式呢系统不知道它是谁把无关文档捞了上来用户问第二章节里说的那个协议和第三章有什么关联系统因为章节被拦腰切断召回片段全是断章取义用户用口语问那个支持并发的东西能不能做主从关键词一个都对不上向量检索靠字面匹配直接跑偏。这些问题单看都不复杂但合在一起就会让一套 RAG 系统从能用跌到难用。我后来把方案拆成了三个支点TXT 章节切分解决文档结构丢失、多轮指代消解解决对话上下文断裂、云端语义向量解决字面匹配的局限。这篇文章就是把这三块怎么落地、怎么串起来、踩过哪些坑完整梳理一遍。这套方案适合谁参考本地或者半托管的 RAG 项目、知识库以 TXT 为主、需要支持多轮对话、以及被检索结果不精准折磨过的朋友。2. 先拆解问题RAG 答不准的根源不在模型在前置处理很多人在 RAG 不准的时候第一反应是换更大的模型、调 temperature、改 prompt其实大部分问题出在检索之前——文档怎么进库、对话怎么进查询这两个环节决定了模型能看到什么。模型再强喂进去的是残缺上下文输出上限就被锁死了。2.1 三个瓶颈的根因对照我把遇到的典型问题和根因做了个梳理这也是后面所有方案的出发点现象根因影响范围多轮对话中代词指代不清用户新 Query 未与历史上下文合并多轮问答、连续追问长文本召回片段语义断裂切分粒度太粗/太细未按章节边界技术文档、手册类问答同义改写后检索不到仅用关键词匹配或弱语义模型口语化提问、跨术语表述章节内容互相干扰相邻章节特征过于相似协议文档、规范类内容用大白话讲你给 RAG 的输入质量决定了它检索的地图精度。地图画错了导航再聪明也白搭。2.2 为什么顺序是章节切分 → 指代消解 → 语义向量这个顺序不是随便排的有依赖关系章节切分是地基。文档如果被胡乱切成碎片后面的向量化和检索都在残缺单位上做文章精度必然受限。指代消解是对话层的加工。它把它这个方案第二章说的内容这类模糊表达替换成可检索的具体实体生成新的查询词。云端语义向量是匹配层的升级。它负责让并发能力和支持高并发部署这类字面不同但语义相同的表述能够互相命中。三者是流水线关系文档先进库切分 → 向量化对话进查询消解 → 构造新查询 → 向量化 → 检索 → 重排 → 生成。每一步都影响下一步的输入质量。提示真正有效的做法是先保证前两步不出错再去考虑换更强的模型。很多项目把预算花在模型 API 上却忽略了进库前的数据处理。3. TXT 章节切分比想象中更影响检索准确率的一环TXT 是最常见的知识库格式也最容易被低估。没有内置的目录结构、没有标签标记纯文本要依靠标题层级和空行规律来恢复文档的骨架。切分做得好每个片段就是有完整语义的独立单元做得差就是一堆语义残缺的碎片。3.1 按标题层级切分而不是按固定字符数很多人的第一直觉是每 500 个字符切一段这在纯描述性文本上勉强能用但遇到有章节结构的文档就会出现两类问题固定长度把3.2 节的开头和3.1 节的结尾拼在同一个片段里语义归属混乱一个完整的小节超过长度阈值被折断后面的向量片段丢失了前面的上下文。我采用的方案是标题驱动 长度兜底两阶段策略。第一阶段用正则识别标题层级。import re def detect_heading(line: str) - tuple: # 匹配 第X章 X.Y X.Y.Z 数字空格标题 等常见模式 patterns [ r^第[一二三四五六七八九十百千0-9][章节部分篇].*$, r^\d(\.\d)*\s\S.*$, r^\d(\.\d)*[、.]\s*\S.*$, r^[一二三四五六七八九十][、.]\s*\S.*$, ] for idx, pattern in enumerate(patterns): if re.match(pattern, line.strip()): return (idx, line.strip()) return None这个函数返回当前行是否是标题、属于哪一类型。识别出标题后把标题到下一个同级或更高级标题出现之前的内容聚合为一个章节单元。具体流程逐行扫描 TXT记录每个标题的层级和行号从第一个标题开始遇到下一同级标题时把两者之间的正文归为前一章节如果一个章节超过设定的最大长度我常用 1500~2000 字符再按段落边界二次拆分但优先保证段落完整。第二阶段超长章节按段落边界切分并做重叠。def split_long_section(text: str, max_len: int 1800, overlap: int 200): paragraphs text.split(\n) chunks, current [], for para in paragraphs: if len(current) len(para) max_len and current: chunks.append(current) # 保留末尾部分作为 overlap避免完整语义被切断 current current[-overlap:] para else: current para \n if current: chunks.append(current) return chunks注意重叠的字符数不是越长越好。我试过 500 字符重叠结果是检索召回的片段大量重复生成时反而被冗余信息干扰。200 字左右足够维持语义连续性。3.2 章节切分粒度怎么定按问答单元思考切分的粒度本质上取决于用户提问的最小语义单元是什么。如果你是一个运维手册知识库问答单元通常是某个功能怎么配置某个报错怎么处理这类内容往往落在二级或三级标题下。因此我按如下规则定粒度一级章节第X章太大通常不直接作为一个检索单元二级标题X.Y是主要检索单元三级标题X.Y.Z如果内容过短少于 300 字符合并到二级标题中孤立正文没有标题的段落块按 500~800 字符切分。这样做的直接效果是用户问第二章里的备份策略检索系统能精准落到2.3 备份策略这个完整小节而不是某段被拦腰截断的文本。3.3 TXT 切分的脏数据防御TXT 文件没做过清洗就直接进切分流程会遇到很多意想不到的问题。我碰到过的典型情况表格被转成文本后行列关系丢失变成一行行孤立数字目录页和正文重复同一章节出现两次全角/半角符号混用导致标题正则识别失败页眉页脚混入正文多见于从 PDF 转换出的 TXT。我的处理策略切分前先做文档清洗去掉重复的目录页匹配目录关键词后若干行内全是章节标题的情况判断为目录段整段移除统一全角转半角用 unicodedata.normalize 处理对疑似表格区域连续多行以制表符或多空格分隔标记为表格块不参与普通切分整体作为一条检索片段入库。这些细节很琐碎但直接决定下游的召回效果。4. 多轮指代消解把它和这个方案变成可检索的实体多轮对话里最影响 RAG 准确率的就是用户用代词或省略句提问而检索系统拿到的还是那句含糊不清的话。比如用户第二章提到负载均衡策略我想了解下它的超时配置。第二轮那如果节点挂了呢节点挂了这四个字如果不结合上文检索系统根本不知道在问负载均衡的节点还是数据库的节点。4.1 两种实现路线对比路线原理优点缺点规则实体回填从历史消息中提取实体、关键词替换当前 Query 中的代词可解释、延迟低、不依赖外部模型依赖语言规则复杂表达可能漏处理LLM 重写用大模型把当前 Query 历史消息重写为独立可检索的 Query泛化能力强能处理复杂指代有额外 API 开销延迟增加prompt 要精心设计我最终选择了混合方案理由后面细说。4.2 规则实体回填的具体实现核心思路维护一个最近 N 轮实体列表当前 Query 中出现指代词时从列表中回填最有可能的候选实体。class CoreferenceResolver: def __init__(self, max_history: int 6): self.history [] self.entity_buffer [] def extract_entities(self, text: str) - list: # 这里可以接 NER 模型或用正则提取带书名号的章节名、专有名词等 pattern r第[一二三四五六七八九十百千0-9][章节部分篇]|负载均衡|超时配置|主从|并发 return re.findall(pattern, text) def resolve(self, current_query: str) - str: tokens [它, 它们, 这个方案, 该方案, 上述, 其, 这样] resolved current_query for ent in self.entity_buffer: for tok in tokens: if tok in resolved: resolved resolved.replace(tok, ent) return resolved实际使用中发现一个关键点实体的时效性排序很重要。对话自然流转时用户刚提到的实体最可能是当前指代对象。我维护实体缓冲区的顺序最新的实体排最前回填时优先替换。但当用户表达过于复杂时比如刚才说的那个方案和第二章的方案对比一下这句话里有两个候选实体刚才说的那个方案第二章的方案规则就不好处理了。这种场景我用 LLM 兜底。4.3 LLM 兜底重写查询改写而不是简单翻译我用 LLM 做查询重写时prompt 核心要求是把当前问题和历史对话合并成一段可以被搜索引擎检索的独立描述只输出改写结果不输出解释。我的一份可用 prompt 模板你是一个信息检索助手。下面是一段多轮对话记录。 请把当前问题改写成一句独立的、信息完整的检索查询 要求 1. 把代词、省略部分还原为明确的名词或实体 2. 保留原问题的核心意图 3. 只输出改写后的查询语句不要任何解释 历史对话 用户第二章介绍了负载均衡策略。 助手第二章主要讲了负载均衡的工作原理和配置参数。 当前问题 那它的超时时间默认是多少改写结果示例第二章中负载均衡策略的超时时间默认是多少这条改写后的 Query无论是做关键词召回还是向量检索效果都远好于那它的超时时间默认是多少。经验LLM 改写的关键不是把它翻译得通顺而是让它包含足够多的检索锚点——章节名、实体名、属性名都得留下痕迹。我最初让 LLM 自由发挥结果它把问题改写得很流畅但实体全丢检索照样拉胯。4.4 什么时候用规则什么时候用 LLM我最终跑了小样本评估定了一个简单原则当前 Query 含明确指代词它/该/其/上述 实体缓冲区命中 → 规则回填零延迟当前 Query 含多个实体或比较句式 → 走 LLM 重写当前 Query 本身就是完整陈述句实体明确、无语义省略→ 不进消解流程直接检索。这套分流逻辑让我在 API 成本和准确率之间找到了平衡点。实测粗测数据规则分流处理了约 60% 的多轮场景剩下 40% 走 LLM整体多轮场景的检索命中率从 50% 左右提升到 80% 上下。5. 云端语义向量为什么本地小模型容易拖后腿向量化是 RAG 检索召回的核心环节文本被转成向量之后靠余弦相似度找最相关的片段。这一步最大的坑是模型选型——本地跑一个轻量 embedding 模型确实省事省钱但语义表达能力不够时检索精度会肉眼可见地下降。5.1 本地 vs 云端 embedding 模型对比对比项本地小模型如 300 维以下云端语义向量模型部署成本低离线可用需要网络调用有 API 费用语义理解能力中低同义词/长文本效果一般强对段落级语义理解更好维度通常 384~768通常 768~1536隐私完全本地数据出网需评估合规性同义改写泛化弱强我实测过几个场景比如用户问高并发下的稳定性本地小模型对高并发稳定性这类抽象词召回效果尚可但遇到扛得住流量高峰吗这种口语化表达本地小模型命中率显著下降云端模型能通过语义泛化召回弹性伸缩限流降级等相关段落。5.2 用云端 API 的正确姿势我当前的方案是接云端语义向量 API但有几个细节值得提批量提交一篇 TXT 切分后可能有几百个片段逐条调用 API 会慢到不可接受。按每批 16~32 条合并请求实测吞吐提升约 3 倍左右。缓存机制文档重复入库的情况很常见用内容的 hash 值做向量缓存二次入库直接复用已有向量省掉大量重复调用。降级策略API 偶发超时不能直接让流程挂掉。我在缓存之外保留一个本地 fallback embedding 模型API 不可用时自动降级为本地向量确保服务不中断。向量存储方面我用的是支持余弦距离的向量数据库。索引构建时每个检索片段需要同时保存原始文本、章节路径如3.2 负载均衡策略、向量。章节路径在结果展示时很有用能告诉用户答案来自文档的哪个部分这也是可解释性的一部分。5.3 混合检索不能只靠向量语义向量不是万能的。我实际操作中发现有些检索场景是字面匹配更有效比如用户明确问3.2 节的内容——按章节号检索向量匹配反而容易跑偏用户问的实体是精确型号、ID、错误码——这些字符型信息关键词精确匹配更可靠。所以我最终采用的是关键词 BM25 语义向量的混合检索策略BM25 负责字面精确匹配语义向量负责模糊语义匹配两者结果用 RRFReciprocal Rank Fusion做融合排序。def rrf_fusion(bm25_hits: list, vector_hits: list, k: int 60): scores {} for rank, doc_id in enumerate(bm25_hits): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) for rank, doc_id in enumerate(vector_hits): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) return sorted(scores.items(), keylambda x: x[1], reverseTrue)融合后取 TopK 片段再喂给生成模型。提醒RRF 里的 k 值直接影响融合效果默认 60 是通用值但如果你向量检索质量更高可以把 k 调大一些如 80~100让向量排名有更大权重反之如果关键词命中更关键k 调小如 30~40。5.4 重排的必要性TopK 检索出来的 20 个片段里真正和问题强相关的可能只有 3 个。如果全部塞进 prompt模型容易被干扰信息带偏。我加了重排环节方案有两种用 LLM 做排序把问题和候选片段发给模型让它输出相关性打分效果最好但成本高用轻量交叉编码器cross-encoder重排速度和效果折中单次推理毫秒级。我实际选择的是交叉编码器重排只对 Top 20 片段打分取 Top 5。这个环节对最终回答质量的提升很明显算是性价比最高的一个步骤。6. 三块拼装从文档进库到多轮问答的完整链路前面三块是独立模块但它们必须串成一个 pipeline 才能真正发挥作用。下面是我的完整实现流程以及每一环的关键代码/配置。6.1 文档入库流水线TXT 原始文件 ↓ 文档清洗去目录、统一符号、识别表格块 ↓ 标题层级识别 ↓ 章节聚合与超长拆分 ↓ 生成多个检索片段含章节路径元信息 ↓ 向量化批量提交云端 API / hash 缓存 ↓ 写入向量库文本、章节路径、向量关键点在于每个检索片段都保留了元信息后面检索结果展示时才能告诉用户来源。这对实际使用体验提升很大——用户能确认答案是从这个章节来的不是瞎编的。6.2 多轮问答查询流水线用户输入 Query ↓ 是否含指代/省略 ├─ 否 → 原 Query 直接进入检索 └─ 是 → 规则回填 or LLM 重写 ↓ 生成可检索 Query ↓ 拼接章节范围限定可选如指定第二章只在该范围检索 ↓ 并行执行 BM25 语义向量检索 ↓ RRF 融合 Top 20 ↓ 交叉编码器重排取 Top 5 ↓ 构造 Prompt 上下文片段 → 生成回答6.3 一个完整的效果对比我用一份约 200 页的运维手册做了测试对比未接入三个模块和完整接入后的检索效果。测试问题分三类单轮事实型、多轮指代型、口语化同义改写型。测试类型未接入方案接入三模块方案单轮事实型如XX 默认端口是多少85%92%多轮指代型如第二轮问它超时时间呢35%82%口语化同义改写型如扛得住高峰吗45%78%多轮和口语化场景提升最明显单轮场景因为本身基线不低提升有限但也没有副作用。7. 实际落地中的几个关键教训和调优建议最后把我反复踩坑踩出来的几条经验列一下都是代码和文档里不会告诉你的。7.1 章节切分宁可多切不可少切我一度为了让片段更大、上下文更完整把切分上限设到 3000 字符结果向量化后片段间语义高度相似检索 TopK 里经常出现 4~5 个来自同一章节的重复片段。后来把默认上限降到 1800 字符同一章节内的重复召回明显下降整体回答的信息密度提升。经验公式如果你的文档问答测试中一个章节的 3 个片段能完整覆盖一类问题这个粒度就是合适的。如果一个问题需要 5 个以上片段才能拼出答案切分粒度太细了。7.2 指代消解的实体缓冲区要定时清理不只是存最近 N 轮还要考虑主题切换。用户聊完负载均衡突然转向日志清理此时实体缓冲区里还留着旧主题实体一旦用户说它的配置呢系统很容易用错实体。我加了主题漂移检测新 Query 与历史 Query 的语义相似度低于阈值时清空实体缓冲区。7.3 云端向量的 API 调用不宜逐条串行批量提交是效率关键。我之前逐条调用一篇 200 页手册的文本切片量接近 400 条串行跑完需要十几分钟纯属浪费时间。批量 32 条一批后整体入库时间压缩到两分钟以内。7.4 系统设计的降级顺序云端 API 不是永远稳定。我的降级链是正常模式云端向量 交叉编码器 → 弱化模式本地 embedding fallback BM25 → 兜底模式纯 BM25 关键词检索。每一级都保证系统还能返回结果只是精度递减。生产环境一定要有这个兜底否则一次 API 故障就是全线瘫痪。7.5 评估集要覆盖检索难场景不要只拿简单问题做测试。我给自己的项目固定了一套评估集里面刻意加入了歧义代词、跨章节比较、口语化同义改写、带编号的精确查询。这套评估集能真正反映系统在难 Query上的表现调优时才有方向。8. 一句话总结这套方案的精髓本地 RAG 问答做的准不准其实是在拼三件事文档有没有按语义结构切好、对话有没有还原成可检索的完整表达、匹配有没有从字面升级到语义。章节切分解决文档层面的语义完整性指代消解解决对话层面的语义完整性云端语义向量解决表达层面的语义等价性。我自己的感受是很多人卡在瓶颈期的原因不是模型不够强而是前两环的粗糙处理让模型根本没机会看到正确的检索片段。先把这三层做好再去追求更大的模型路径会顺很多。