内部有一个技术文档问答,语料是两千多篇混在一起的 Markdown、HTML 导出页和 PDF。上线后有用户反馈:问「某某参数的默认值是多少」,要么答不上来,要么给出一个看着很像、其实对不上的数。
我们的第一反应和大多数人一样,认定是检索不够准。于是把常见的一套全上了一遍:换中文 Embedding、加 BM25 做混合检索、上 RRF 融合、再加一层 Rerank 精排。Recall@5 从 68% 挪到了 81%,看起来有进步,但那批「答不上来」的问题纹丝不动,一条都没被救回来。
真正让事情转向的,是一个很笨的动作:把命中 chunk 的原文逐条打印出来看。
答案根本不在任何一个 chunk 里。有的被压成了一行乱码式的数字串,有的前半句在这个 chunk、后半句在另一个 chunk,还有的 chunk 里塞满了「第 3 页」「版权所有」「返回顶部」。
这件事给我一个挺深的印象:检索层只能在你给它的候选集合里挑。如果解析阶段就把内容弄丢了,后面所有调参都是在空集合上做优化,指标会动,但那只是把剩下的部分排得更好看。
多数人排查 RAG 时盯的是最终回答,这个指标被生成模型污染得太厉害——答错了,你分不清是没检索到、检索到了没用好,还是模型自己发挥。
我的做法是退一步,做一个很小的标注集:80 条真实 query,人工标出「答案所在的那段原文,在哪个文件的哪个字符区间」。然后只算一件事:这段区间,有没有被完整包含在某一个 chunk 里。我叫它可召回率(Answer-in-Corpus)。
def answer_in_corpus(labeled, chunks):
"""
labeled: list[dict],人工标注的答案区间,字段为 source / start / end
chunks: list[dict],解析切片,字段相同(source 为文件标识,start/end 为字符偏移)
返回:答案被完整包含在单一 chunk 里的比例
"""
def fully_cover(label, chunk):
return (label["source"] == chunk["source"]
and chunk["start"] <= label["start"]
and chunk["end"] >= label["end"])
hit = sum(
1 for lab in labeled
if any(fully_cover(lab, c) for c in chunks)
)
return hit / max(len(labeled), 1)这个指标的好处是它跟模型、跟 prompt、跟 top_k 全都无关,只反映「语料进库这一步有没有把东西弄坏」。跑出来是 71%。也就是说,有三成问题的答案压根没进索引,检索层再强也不可能找得到。
先把这两个指标分开看,后面的排查才不会走弯路:可召回率是地基,Recall@5 是上层建筑。
下面四类是我在这次排障里实际遇到的,按出现频率排。每一类都不难修,但如果不打印原文,几乎不可能想到。
1. 表格被压平成一行
HTML 表格和 PDF 表格转纯文本后,行列关系经常直接消失。「参数 / 默认值 / 说明」的表头和下面几行数值挤成一段连续文字,向量化之后语义基本归零,用户问「默认值」,命中的是一堆没有上下文的孤立数字。
修法:解析阶段就把表格整块转成 Markdown 表格或 JSON,显式保留行列对齐,并且整块不参与按字符切分。表格是结构化数据,不该被当散文处理。
2. 代码块被当正文切碎
这个最隐蔽。Markdown 里的配置片段、命令示例,如果先转纯文本再切,缩进会丢、行会被按句号或换行切开,一段完整的配置变成三块互不完整的 chunk。更糟的是它看起来仍然是「正常文本」,不报错、不报警。
修法:在解析阶段给 fenced code block 打结构标记,切片时整块保护、不拆不裁。宁可让这个 chunk 稍微超长,也不要把它切断。
3. 页眉页脚和导航栏重复污染
PDF 每页都带的页码、版权行、目录导航,HTML 导出页每页都带的侧边栏,会原样进入索引。危害有两个:这些高频重复词在 BM25 里分数虚高,容易挤掉真正相关的片段;同时它们白占 chunk 预算,把本来能装下的正文挤出去。
修法:按「跨页行频」自动去噪——出现在 60% 以上页面里的行,基本可以断定是模板噪声。
from collections import Counter
def drop_boilerplate(pages, threshold=0.6):
"""pages: list[list[str]],每页已拆成行;返回去噪后的页与噪声行集合"""
doc_freq = Counter()
for page in pages:
for line in set(page):
line = line.strip()
if line:
doc_freq[line] += 1
n = max(len(pages), 1)
noise = {ln for ln, df in doc_freq.items() if df / n >= threshold}
cleaned = [
[ln for ln in page if ln.strip() not in noise]
for page in pages
]
return cleaned, noise阈值别照抄,先打印出 noise 集合人工看一眼再定。我们那批语料里,它一次抓出了 11 行模板文本,其中 3 行是之前完全没注意到的。
4. 标题层级丢失,chunk 变成没头没尾的孤儿
Markdown 或 HTML 转纯文本时,标题符号常被抹掉。「超时重试」这一节的内容,和「限流配置」那一节的内容,切出来看着差不多,向量也近,模型根本分不清这段在讲哪个模块。
修法:给每个 chunk 注入面包屑前缀,把文档标题、章节、小节拼在正文前面。这是整篇里成本最低、收益最稳的一招,不用换模型、不用改索引结构。
def with_breadcrumb(text, path):
"""path 形如 ["网关接入指南", "超时与重试", "客户端配置"]"""
return " > ".join(path) + "\n\n" + text切片前先把结构化块「藏起来」,切完再还原,比写一堆特判分支干净得多。注意下面用变量拼出围栏符,避免在代码字符串里出现会被 Markdown 解析器误判的字面量。
import re
FENCE = "`" * 3
def protect_blocks(md_text):
"""
把代码块与 Markdown 表格替换成占位符,返回 (掩码文本, 原始块列表)
约定:切片逻辑只对掩码文本做,结束后用 restore_blocks 还原
"""
store = []
def stash(m):
store.append(m.group(0))
return "\n@@BLOCK" + str(len(store) - 1) + "@@\n"
pattern = (
r"(" + FENCE + r"[\s\S]*?" + FENCE + r")"
r"|((?:^\|.*\|\s*$\n?)+)"
)
masked = re.sub(pattern, stash, md_text, flags=re.MULTILINE)
return masked, store
def restore_blocks(text, store):
for i, block in enumerate(store):
text = text.replace("@@BLOCK" + str(i) + "@@", block)
return text配套加一个解析后的自检,比写万能解析器实用得多:统计每个 chunk 里被截断的表格行数、未闭合的代码块数、没有任何标题路径的 chunk 占比。这三项任何一项异常,就说明这批语料不能直接入库。
阶段 | 可召回率 | Recall@5 | 说明 |
|---|---|---|---|
基线(原始解析) | 71% | 68% | 地基就是漏的 |
只换 Embedding | 71% | 72% | 可召回率一动没动 |
混合检索 + RRF + Rerank | 71% | 81% | 上层涨了,缺失的那 29% 仍在 |
修解析 + 面包屑注入 | 96% | 88% | 上游补齐后,下游调参才真正兑现 |
上表是本地最小复现语料上的示例数据,绝对值随文档类型差异很大,请不要直接引用数字。 值得带走的是方法和那个趋势:前两轮在检索层花的功夫,可召回率全程是条水平线;直到修了解析,Recall@5 才跟着一起上来。
结论我写成一条可以直接贴到团队文档里的排查顺序:
第一,解析层必须留原文可追溯。 每个 chunk 记下 source 和字符偏移,出问题时能一眼定位回原文件的哪一段。没有这个,排障全靠猜——我们前两周就是这么浪费掉的。
第二,把解析当 ETL 管,要有版本。 改一次解析逻辑,所有指标必须重跑。我们踩过一次:悄悄改了表格处理,却拿旧基线对比,得出一个完全错误的「Rerank 无效」结论,差点把正确的组件下掉。
第三,别追求完美解析,先追求可检测。 文档类型永远在变,写不出一劳永逸的解析器。但能让系统自动告诉你「这批新入库的 PDF 有 30% 表格被压平了」,价值比任何一次性的精细调优都大。
RAG 讨论里,检索和生成拿走了几乎全部注意力,解析被当成「预处理」一带而过。但这次排障之后我的排序变了:解析决定上限,检索只决定你离上限有多近。
下一篇会写这套链路怎么做成可持续的回归评测,包括那 80 条标注集具体怎么造、怎么防止语料更新后指标悄悄退化。这个系列会持续更新在专栏里,也欢迎在评论区贴你踩过的解析坑,典型的我会补进后续文章一起分析。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。