搜索召回率上不去?先看看你是不是把中文词切成了两半
Photo: Tore Danielsson (WMSE), CC0, via Wikimedia Commons
一个看起来无害的 substr
后端同学大概都写过这样的代码:字段太长,截取前 255 个字符入库。英文环境下它确实无害。可一旦字符串里是中文,而你的语言用的是「按字节截断」(比如某些老版本的字符串函数,或 Go 里对 []byte 的切片),问题就来了——一个汉字在 UTF-8 里占 3 个字节,你截到第 254 个字节,最后那个字就被劈成了两半,剩下一半是无效字节序列。
更隐蔽的是:即使你按「字符」截断,也可能出错。因为 Python、Java 里的「字符」在历史上是 UTF-16 code unit,而一个生僻字或 Emoji 需要两个 code unit 表示。截在代理对中间,同样得到一个坏字符。
码点、字节、字形簇:三层容易混淆的概念
处理多语言文本前,先把三个层次分清楚:
- 字节(Byte):存储层面,UTF-8 下一个汉字 1–4 字节不等。
- 码点(Code Point):Unicode 逻辑字符,一个汉字通常是一个码点,但 Emoji 可能由多个码点组合。
- 字形簇(Grapheme Cluster):用户「看到的一个字」,比如「é」可以是一个码点,也可以是
e+ 组合重音符两个码点。
截断和搜索都必须在字形簇这一层操作,至少也要在码点层。在字节层操作,就是所有「搜不准」故事的起点。
为什么截断会毁掉搜索召回
中文没有空格分词,搜索引擎必须依赖分词器(如 IK、jieba)把「搜索引擎优化」切成词条。如果索引前某个字段被粗暴截断,可能出现两种灾难:
- 词被腰斩:「优化」只剩一个「优」,用户搜「优化」时这条文档永远召回不了。
- 坏字节污染分词器:无效的 UTF-8 序列会让部分分词器直接丢弃后续内容或抛异常,整条文档静默丢失。
这两种情况在监控里都不报错,只有用户投诉「搜不到」时才会暴露——非常典型的静默数据损坏。
Elasticsearch 中的正确姿势
ES 的 JVM 世界里字符串天然按 UTF-16 处理,所以 ES 本身不会截出半个字。真正的风险在写入链路:你的上游服务用某种语言的字节截断函数预处理过,或者 Logstash 的 truncate filter 配置成了按字节。检查三点:
- mapping 里
ignore_above的单位是字符数不是字节,中文场景按字符算没问题,但别和上游的字节限制混着配。 - 中文文本字段务必配置
ik_max_word作为 index-time analyzer,ik_smart作为 search-time analyzer,否则粗粒度分词会进一步压低召回。 - 用
_analyzeAPI 实测你截断后的样本文本,确认没有 U+FFFD(替换字符)出现。
PostgreSQL 全文检索的坑
PG 的 substring() 和 left() 都是按字符操作,这点对中文友好。但默认的全文检索配置基本不支持中文——to_tsvector('simple', ...) 会把整句中文当一个 token,等于搜什么都不命中。
实用方案:
- 安装
pg_jieba或zhparser扩展,建tsvector生成列做 GIN 索引。 - 轻量场景可以直接上
pg_trgm+ GIN 索引做模糊匹配,对短查询词效果意外地好。 - 检查数据库编码:
SHOW server_encoding;必须是 UTF8。如果你在维护一个从 GBK 时代迁移过来的库,先做编码转换再谈索引。
陷阱清单
- 任何语言的「按字节截断」函数处理中文前,先确认字符边界。
- 截断后校验:
text.encode('utf-8').decode('utf-8')能否无损往返。 - 分词器版本和 ES 版本要匹配,IK 升级后记得重建索引。
- 别用
VARCHAR(n)的字节数语义去套中文,PostgreSQL 的 n 是字符数,MySQL 的 n 在不同版本语义不同。 - 搜索召回异常时,先用
_analyze或ts_debug看分词结果,别急着调 BM25 参数。
一句话总结:中文搜索不准,八成不是排序算法的问题,而是某个环节把字切坏了。先把字符边界守住,再谈调优。