• 欢迎关注我的微信公众号“ Falost ” 右边扫描关注 --->>

多模态 RAG 实测(八):jieba 预分词把页面 top-10 从 0.8070 干到 0.6404——砍掉单字查询词才捞回 0.8246

AI / 大模型 神棍 145℃ 0评论

上篇(七)结尾我给自己挖了个坑:真实语料上 unicode61 把页面 top-10 抬到 0.8070,但我复盘时觉得它赢得很可疑——OCR 转写文本里恰好保留了换行,把中文断成一小段一小段,unicode61 才”切得动”。我说这是运气不是方案,下篇要正经做中文预分词:先用 jieba 切好、空格拼回去,再跟 unicode61、trigram、char+bigram 做四方对照,顺带试查询侧 df 截断。

这篇来结账,结论比预告更打脸:jieba 预分词不但没赢,还把页面 top-10 从 0.8070 干到 0.6404(掉 16.7 个百分点);真正把分数拉回 0.8246 的,是”查询时砍掉单字词”这一刀,不是分词器本身。中文分词在这套语料上的价值,不在”切得准”,而在”别把虚词放出来捣乱”。

一、实验口径:只动一个变量

语料、题目、字段、权重全部沿用(六)(七),只换分词器:247 页真实网页(80 篇我的博客 / 70 篇 arXiv / 60 页 MDN / 20 个 GitHub 仓库主页 / 17 页 DeepSeek 文档),正文是上一轮 OCR 转写;121 道题里 Gold 表可定位的 114 道,跟(四)到(七)同一批;四字段 body(转写正文)/ title(网页标题)/ headers(DOM 表头行拼接)/ urlpath(URL 切词),权重固定 (1,3,3,1),k1=1.5、b=0.75。

选手四个:我手写的 char+bigram(中文按单字加二元组切)、FTS5 自带的 unicode61、FTS5 的 trigram、以及新加的 jieba 预分词(jieba.cut 切词后用空格拼回去,交给 unicode61 索引)。查询侧用同一套规则对称处理。

二、四方对照:jieba 预分词倒数第二

分词器 R@1 R@5 R@10 MRR 平均查询词数
char+bigram(我手写) 0.4649 0.5351 0.5789 0.5131 42.63
jieba 预分词 + unicode61 0.4825 0.5702 0.6404 0.5387 15.82
FTS5 trigram 0.6228 0.7193 0.7456 0.6629 5.00
FTS5 unicode61 0.6930 0.7895 0.8070 0.7349 7.47

直接看数:char+bigram 0.5789、jieba 0.6404、trigram 0.7456、unicode61 0.8070。jieba 这套索引和查询都用 jieba 切、口径一致,却比不改分词器的 unicode61 低 16.7 个点。

索引规模也反直觉:unicode61 切出 91385 个 token、词表 13055;jieba 切出 115641 个 token、词表只有 11871。token 更多、词表更小,说明 jieba 把长串拆成了更短、更常见的词——这恰恰是问题所在。

三、探针:jieba 多切出来的,全是虚词

为什么加了中文分词反而更差?我把同一条题丢进两套索引,看它各自被切成了什么。题干:表格里,「版式」为「文档」的那一行,「页数」列的值是多少?

查询词 unicode61 索引里的 df jieba 索引里的 df
版式 1 6
页数 1 1
文档 4 28
的 17 80
是 6 64
为 0 32
值 0 15
里 1 20

关键在 df(含这个词的页数)。unicode61 把”的那一行””列的值是多少”当成整块长 token,df=0,压根匹配不上,等于白送一张天然停用词表;jieba 却把它们切成”的””那””是””多少””值””列”这些单字虚词——”的”df=80、”是”df=64、”为”df=32,几乎每页都有。IDF 低、噪声大,十三四个查询词里塞进六七个这种词,真正的信号”版式””页数”就被稀释了。jieba 输不是切错了,是切得太”诚实”。

四、”unicode61 靠换行赢”这个假说,我验了

顺着探针,我把(七)那个猜测做成对照实验:把 247 页 body 里的所有空白(换行、空格)全部抹掉,其余不变,再跑一次 unicode61。

配置 R@1 R@5 R@10 MRR token 总数
原始文本(有换行) 0.6930 0.7895 0.8070 0.7349 91385
抹掉全部空白 0.2193 0.3158 0.3421 0.2716 32742

token 从 91385 掉到 32742,top-10 从 0.8070 崩到 0.3421。假说成立:unicode61 赢的很大一部分,确实是转写文本里恰好有换行把中文断开了。一旦没有空白,中文变成整段一个长 token,词法检索直接失效。unicode61 不是中文分词器,它在你数据上表现好,很可能只是你的数据碰巧带分隔符。

五、两条补救路线:df 截断 vs 砍单字

既然病因是虚词噪声,那就治它。预告里提的是查询侧 df 截断(df 超过语料一定比例的词直接扔),我又加了更粗暴的一条:jieba 查询词里只保留长度大于等于 2 的词,把单字全砍了。

jieba 查询侧处理 R@1 R@5 R@10 MRR
不做过滤(基线) 0.4825 0.5702 0.6404 0.5387
df 截断 < 10% 0.5439 0.7105 0.7456 0.6226
df 截断 < 20% 0.5263 0.6842 0.7456 0.6058
df 截断 < 30% 0.4825 0.6316 0.6842 0.5532
只留长度 ≥ 2 的词 0.6667 0.7895 0.8246 0.7190

df 截断有效:卡到 df 小于 10% 时 jieba 回到 0.7456,但仍是四个系统里的第三名,没超过 unicode61。而”砍单字”直接顶到 0.8246,反超 unicode61 一点八个点。df 截断的阈值要小心,卡到 30% 会把”文档””页数”这种有用的中等频词一起误杀,分数又掉回去。

核心代码就几行,索引侧和查询侧要对称:

import jieba, re

# 索引侧:切词后用空格拼回去,交给 FTS5 unicode61 当普通 token 处理
def preseg(text):
    return " ".join(w for w in jieba.cut(text) if w.strip())

# 查询侧:切词 + 只保留长度 >= 2 的词,砍掉单字虚词
SEG = re.compile(r"[0-9A-Za-z\u4e00-\u9fff]+")
def query_terms(q):
    return [w for w in jieba.cut(q)
            if SEG.fullmatch(w or "") and len(w) >= 2]

注意 FTS5 的 MATCH 默认把多个词按 AND 连接,词一多召回会塌成 0,所以我用 OR 显式连接每个词项,权重照旧走 bm25(ft, 1,3,3,1)。

六、拆到题型:涨的分和掉的分都在哪

题型 n unicode61 R@10 jieba 全量 R@10 jieba 去单字 R@10
flat(内容题) 41 0.976 0.659 0.976
struct_col_lookup(值→列) 37 0.973 0.757 0.973
struct_header(值→表头) 13 1.000 0.846 1.000
struct_cell(坐标题) 20 0.000 0.200 0.100
struct_colmax / rowmax 3 0.667 1.000 1.000

在 flat、struct_col_lookup、struct_header 上,jieba 去单字跟 unicode61 基本打平(R@10 都接近 1.0);jieba 全量之所以整体拉胯,就是被这几类里的虚词噪声拖的。

有意思的是 struct_cell(坐标题,20 道):unicode61 直接 0,jieba 全量 0.20,去单字反而降到 0.10。坐标题题干是”第 2 行、第 7 列的单元格内容是什么”,”行””列”这些单字恰恰有用——unicode61 把它们埋进长 token 里匹配不到,jieba 切出来刚好能用,我一刀砍掉单字又误伤。所以不该用一条统一规则同时伺候内容题和坐标题。

边缘题方面,unicode61 里 gold 排 6 到 20 名的只有 4 道,jieba 去单字把其中 2 道名次前移(p180 两道从第 9、第 10 提到第 7、第 6),一道没挤出去,改善是真的但不大。

七、我的落地建议

  1. 别一上来就换分词器。先跑 unicode61,你的数据只要有换行、标点、英文空格,它很可能已经是个很强的基线(这篇 0.8070)。
  2. 要用 jieba,就一定要过滤单字。纯 jieba 预分词在真实语料上是负收益;不加过滤直接用,等于往 BM25 里灌噪声。
  3. 过滤阈值别拍脑袋。df 截断卡在 10% 附近有效,卡到 30% 就开始误杀内容词,得按自己的语料实测。
  4. 内容题和坐标题分开伺候。“第几行第几列”根本不该走词法检索,混在一起调分词器只会在两边都留遗憾。
  5. 两套索引别丢,融一下更稳。我用 RRF 把 unicode61 和 jieba 去单字融合,R@1 0.6930 / R@5 0.8070 / R@10 0.8158 / MRR 0.7367,四项全都不低于 unicode61 单独跑。

一句话总结:中文分词器不是”切得越细越好”,而是”把没信息量的词挡在打分之外”——unicode61 靠换行赢一局,jieba 靠砍单字掰回来,两边其实在做同一件事。

下期预告

这篇拆到题型又暴露两个没解决的点:一是坐标题(struct_cell)在两套词法检索里都是 0 上下,二是 unicode61 和 jieba 去单字各有强弱,选谁都有损失。下篇我做两件事:先把两套索引用 RRF 正式融一遍,看能不能在不掉 R@1 的前提下继续抬 R@10;再单独啃 struct_cell——我怀疑”第 X 行第 Y 列”这类查询压根不该进 BM25,该走表格结构:用表头对齐拿到列、用行号直接定位单元格,绕开文本检索。

参考文献

  • 上一篇(七)四字段加权搬回真实语料(本文基线 0.5702 / 0.8070 的出处):https://www.fedte.cc/p/846.html
  • 上上篇(六)页面检索拆成四个字段:https://www.fedte.cc/p/845.html
  • jieba 中文分词(前缀词典 + 有向无环图 + 动态规划求最大概率路径,未登录词用 HMM):https://github.com/fxsjy/jieba
  • SQLite FTS5 官方文档(bm25()、列权重、tokenize=、fts5vocab):https://www.sqlite.org/fts5.html
  • SQLite FTS5:unicode61 分词器(CJK 按字母类别成 token):https://www.sqlite.org/fts5.html#unicode61_tokenizer
  • SQLite FTS5:trigram 分词器(最短 3 字符):https://www.sqlite.org/fts5.html#the_trigram_tokenizer
  • SQLite FTS5:fts5vocab 虚拟表(本文 df 统计来源):https://www.sqlite.org/fts5.html#the_fts5vocab_virtual_table_module
  • Robertson, Zaragoza:The Probabilistic Relevance Framework: BM25 and Beyond(k1/b 与 IDF 依据):https://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf
  • Cormack, Clarke, Buettcher:Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods(RRF 出处,K=60):https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf

转载请注明:Falost的小窝 » 多模态 RAG 实测(八):jieba 预分词把页面 top-10 从 0.8070 干到 0.6404——砍掉单字查询词才捞回 0.8246

如果你觉得这篇文章不错或者对你有帮助,想请我喝一杯咖啡,可以打赏
喜欢 (0)
发表我的评论
取消评论

表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址