首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >召回率上不去,别急着换 Embedding:一次文档解析链路的排障复盘

召回率上不去,别急着换 Embedding:一次文档解析链路的排障复盘

原创
作者头像
用户12868525
发布于 2026-11-04 15:57:15
发布于 2026-11-04 15:57:15
1320
举报

一、现象:检索层怎么调都救不回来

内部有一个技术文档问答,语料是两千多篇混在一起的 Markdown、HTML 导出页和 PDF。上线后有用户反馈:问「某某参数的默认值是多少」,要么答不上来,要么给出一个看着很像、其实对不上的数。

我们的第一反应和大多数人一样,认定是检索不够准。于是把常见的一套全上了一遍:换中文 Embedding、加 BM25 做混合检索、上 RRF 融合、再加一层 Rerank 精排。Recall@5 从 68% 挪到了 81%,看起来有进步,但那批「答不上来」的问题纹丝不动,一条都没被救回来。

真正让事情转向的,是一个很笨的动作:把命中 chunk 的原文逐条打印出来看。

答案根本不在任何一个 chunk 里。有的被压成了一行乱码式的数字串,有的前半句在这个 chunk、后半句在另一个 chunk,还有的 chunk 里塞满了「第 3 页」「版权所有」「返回顶部」。

这件事给我一个挺深的印象:检索层只能在你给它的候选集合里挑。如果解析阶段就把内容弄丢了,后面所有调参都是在空集合上做优化,指标会动,但那只是把剩下的部分排得更好看。

二、先把评测拆开:别用「回答准不准」判断解析好坏

多数人排查 RAG 时盯的是最终回答,这个指标被生成模型污染得太厉害——答错了,你分不清是没检索到、检索到了没用好,还是模型自己发挥。

我的做法是退一步,做一个很小的标注集:80 条真实 query,人工标出「答案所在的那段原文,在哪个文件的哪个字符区间」。然后只算一件事:这段区间,有没有被完整包含在某一个 chunk 里。我叫它可召回率(Answer-in-Corpus)。

代码语言:javascript
复制
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% 以上页面里的行,基本可以断定是模板噪声。

代码语言:javascript
复制
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 注入面包屑前缀,把文档标题、章节、小节拼在正文前面。这是整篇里成本最低、收益最稳的一招,不用换模型、不用改索引结构。

代码语言:javascript
复制
def with_breadcrumb(text, path):
    """path 形如 ["网关接入指南", "超时与重试", "客户端配置"]"""
    return " > ".join(path) + "\n\n" + text

四、一个最小可复现的保护脚本

切片前先把结构化块「藏起来」,切完再还原,比写一堆特判分支干净得多。注意下面用变量拼出围栏符,避免在代码字符串里出现会被 Markdown 解析器误判的字面量。

代码语言:javascript
复制
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 才跟着一起上来。

结论我写成一条可以直接贴到团队文档里的排查顺序:

  1. 先算可召回率,不到 90% 不要在检索层继续投入
  2. 打印命中的 chunk 原文,别只看分数和指标
  3. 解析层加自检,让「解析坏了」这件事能被自动发现
  4. 最后才轮到 Embedding 选型、混合检索和 Rerank

六、三条工程上的个人体会

第一,解析层必须留原文可追溯。 每个 chunk 记下 source 和字符偏移,出问题时能一眼定位回原文件的哪一段。没有这个,排障全靠猜——我们前两周就是这么浪费掉的。

第二,把解析当 ETL 管,要有版本。 改一次解析逻辑,所有指标必须重跑。我们踩过一次:悄悄改了表格处理,却拿旧基线对比,得出一个完全错误的「Rerank 无效」结论,差点把正确的组件下掉。

第三,别追求完美解析,先追求可检测。 文档类型永远在变,写不出一劳永逸的解析器。但能让系统自动告诉你「这批新入库的 PDF 有 30% 表格被压平了」,价值比任何一次性的精细调优都大。

七、小结

RAG 讨论里,检索和生成拿走了几乎全部注意力,解析被当成「预处理」一带而过。但这次排障之后我的排序变了:解析决定上限,检索只决定你离上限有多近。

下一篇会写这套链路怎么做成可持续的回归评测,包括那 80 条标注集具体怎么造、怎么防止语料更新后指标悄悄退化。这个系列会持续更新在专栏里,也欢迎在评论区贴你踩过的解析坑,典型的我会补进后续文章一起分析。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、现象:检索层怎么调都救不回来
  • 二、先把评测拆开:别用「回答准不准」判断解析好坏
  • 三、四类真实的解析事故
  • 四、一个最小可复现的保护脚本
  • 五、修完之后的数据,以及一个反常识结论
  • 六、三条工程上的个人体会
  • 七、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档