Backend

标题里数字紧跟汉字,论坛搜索就搜不到的排查

1266 字约 5 分钟

摘要论坛用 PostgreSQL 全文检索,中文在应用里按两个字一组切分。一个正则让数字开头的一整串汉字没有被切开,标题里有“2026年夏令营”的帖子,搜“夏令营”却搜不到。

我做的论坛要发布一批新功能,发布之前,我让助手专门造了一批极端数据,在手机和桌面上把所有页面都检查了一遍。这批数据里有 80 个字的标题、整串链接当标题的帖子、40 个字的用户名、20 个选项的投票。它们大多暴露出来的是排版问题,只有一个发现得很意外,搜索出了问题。

标题里有“2026年夏令营预推免全程辅导”的帖子,搜“夏令营”,搜不到。

搜索是怎么做的

论坛的搜索用的是 PostgreSQL 自带的全文检索。它按空格和标点把文本切成一个个词,再建索引,对英文很好用,但中文词与词之间没有空格,而我用的这个数据库实例里又没有装中文分词扩展。

我的做法是不装任何扩展,在应用里自己切。英文和数字按单词切,汉字则每两个字一组,相邻的组互相重叠,这叫二元切分。比如“保研夏令营”会变成“保研、研夏、夏令、令营”四个词,再存进索引。

二元切分是什么

不认识词典里的词,也能让汉字被搜到的一个朴素办法。把每相邻的两个字都当成一个词放进索引,一定会产生一些没有意义的组合,比如“研夏”,但没关系。

搜索时也把查询词用同样的方式切开,再要求这些二元词按顺序紧挨着出现。比如搜“夏令营”,实际查的是“夏令”紧接着“令营”。文档和查询用同一套规则,所以不用知道真正的词在哪里。

看一眼索引里存了什么

怎么把“搜不到”的原因找出来?比起猜,直接看索引里存了什么更快。助手把那条标题对应的 tsvector 打印了出来,片段是这样的。

'2026年夏令营预推免全程辅导':2A  '万元':6A  '元不':7A  '入过':4A  ...

同一个标题里,纯汉字的部分被正确切成了“万元、元不、入过”这样的二元词,可是以数字开头的这一段,“2026年夏令营预推免全程辅导”,整串被当成了一个词。搜“夏令营”会被切成“夏令”和“令营”,当然对不上。

一个正则的盲区

问题出在切词用的正则表达式。它大致是这样的,意思是先尝试匹配一串连续的汉字,匹配不上,再匹配一串字母或数字。

/\p{Script=Han}+|[\p{L}\p{N}]+/gu

漏洞在第二个分支。\p{L} 表示所有字母,而汉字在 Unicode 里也属于字母。一个词只要从数字开头,第一个分支匹配不上,就会落到第二个分支,而第二个分支会毫不客气地把后面所有的汉字一并吃进去。数字后面紧跟着汉字时,整串都成了一个词,根本没有进入二元切分。

通俗比喻:只认了一半规矩的切菜机

切菜机的规矩是,遇到整根的黄瓜就切成薄片,遇到土豆块就原样放进去。有一天来了一根头上粘着一块土豆的黄瓜,机器把它整根当成土豆放进去了,黄瓜没被切开,顾客想要黄瓜片,当然找不到。

这个盲区在升学类的标题里特别常见,“2026年”“26届”“CS保研”这样的写法比比皆是。所以实际影响的不是少数帖子,而是一大类标题。

把汉字从第二个分支里剔除

修法是让第二个分支不再认汉字。JavaScript 的正则在 v 标志下支持集合运算,可以从“字母或数字”里减去汉字。

/\p{Script=Han}+|[[\p{L}\p{N}]--\p{Script=Han}]+/gv

现在,字母和数字的词会在汉字处终止,汉字部分照常二元切分。“2026年夏令营CS”会被切成“2026”、“年夏、夏令、令营”、“cs”三段。“年夏”这样跨出数字的二元词也会出现,但同样没关系,查询也走同一套规则。

我补了一条测试,断言这段文本的切分结果,以及搜“夏令营”时生成的查询是“夏令紧接着令营”。

规则变了,索引得重建

分词规则一变,已经存进数据库的索引就是旧规则切出来的,不重建的话,旧帖子依然搜不到。重建的做法是,用一个迁移清空存索引的表,服务启动时,本来就有一段逻辑会把还没进索引的帖子补上,于是清空之后它会按新规则全部重来一遍。线上的索引在发布之后重建完成,一共 291 条。

这件事给我的提醒是,分词、归一化这类逻辑,只看代码很难发现边界问题,直接去看它在真实数据上产出的结果最有效。如果当时只用英文和纯汉字的标题测试,这个漏洞可能还会躲一阵子。

评论