不装分词插件,用 PostgreSQL 做中文商品搜索:bigram + 向量 + RRF,以及「单字搜不到」这个坑

不装分词插件,用 PostgreSQL 做中文商品搜索

做电商搜索,第一反应往往是上 Elasticsearch,或者给 PostgreSQL 装 zhparser / pg_jieba。 这两条路都能走,但都有代价:多一套集群要运维,或者要自己打一个带扩展的数据库镜像。

我在做一个开源电商项目 Keel 时选了第三条路: 分词放在应用层,数据库只用内置的 simple 配置按空格切,再叠一路 pgvector 向量召回, 两路结果在应用层用 RRF 融合。这篇讲这套方案的细节,以及上线后踩到的一个坑。

一、关键词这一路:应用层切二元组

思路很简单:中文不分词,直接切二元组(bigram)。

arduino 复制代码
"红色连衣裙" → "红色 色连 连衣 衣裙"

切完用空格拼起来,写进 products.search_text;数据库那边再用 to_tsvector('simple', search_text) 生成一列 search_vector(生成列),在它上面建 GIN 索引。 simple 配置什么语言知识都没有,只按空白切词、转小写,正好接住我们切好的串。

几条规则:

  • 汉字(以及假名、谚文)切二元组。一段 n 个字出 n−1 个二元组;只有一个字时出那个字本身。
  • 英文和数字不切二元组 ,按字符类边界正常切词。iPhone 15、205/55R16 这种型号做 bigram 只会制造噪声。
  • 标点、空白、emoji 一律当分隔符 。否则 "连衣裙,女装" 会切出一个不存在的 "裙女"。
  • 标题和副标题之间也要断开,不能拼成一整串再切,否则「连衣裙」+「夏季新款」会切出「裙夏」。
  • 大小写在切分函数里统一折叠。数据库自己也会折,但查询侧要拿同一个函数的输出手工拼 tsquery,那一步没人替你折。

核心代码(Go)大致是这样:

go 复制代码
func Bigram(s string) string {
	var out []string
	var cjk []rune  // 当前这一段连续的表意文字
	var word []rune // 当前这一段连续的字母数字

	flushCJK := func() {
		switch {
		case len(cjk) == 0:
		case len(cjk) == 1:
			out = append(out, string(cjk)) // 单字成段,丢掉它「裙」这种词就消失了
		default:
			for i := 0; i+1 < len(cjk); i++ {
				out = append(out, string(cjk[i:i+2]))
			}
		}
		cjk = cjk[:0]
	}
	// ......遍历 rune:表意文字进 cjk,字母数字进 word(转小写),其余字符两边都 flush
	return strings.Join(out, " ")
}

查询侧:用 OR,不用 AND

用户搜「红色连衣裙」,查询侧用同一个函数 切出 4 个二元组,然后用 | 拼成 tsquery:

sql 复制代码
to_tsquery('simple', '红色 | 色连 | 连衣 | 衣裙')

为什么是 OR?因为这一层是召回 ,不是排序。用 AND 的话,只有标题一字不差的商品能进来, 「碎花连衣裙」会被整个漏掉,而漏在召回层的东西,后面任何一层都救不回来。 区分度交给 ts_rank_cd:命中的二元组越多、挨得越近,分越高。

代价也很明确:OR 会捞回一批只命中一个二元组的噪声。召回层宁滥勿缺,收紧的地方应该在排序和精排。

还有一个小细节:to_tsquery('simple', '') 是语法错误 ,不是空结果。用户搜了一串标点时, 切分结果为空,调用方必须先判空,而不是塞一个 zzz_never_match 糊过去------ 「搜了一串标点」和「这家店真的没有」是两件事,前端的提示应该不同。

二、两侧必须切得一模一样

这套方案最大的风险不在算法,而在漂移:索引侧和查询侧只要有一处实现不同 (比如一边转了小写、一边没转),症状就是「某些词搜不到」,而且只对某一类字符串成立。 没有任何测试会因为「两处实现不同」而报错,只有召回率会悄悄往下掉。

所以两侧强制调用同一个包里的同一个函数,查询侧不允许自己再写一份。

更隐蔽的是第三份切分结果 :为了让没有推理服务的默认部署也能搜到东西, 种子数据里预先写好了一批商品的 search_text。这份 SQL 是人手维护的, 于是配了一条测试:读种子文件、把每一行的标题副标题重新喂给切分函数、逐字比对。 切分算法改了而种子没跟上,这条测试当场报错。

三、向量这一路 + RRF 融合

光靠二元组,「真丝吊带长裙」和查询「连衣裙」一个二元组都不共享,关键词这一路永远捞不到它。 这部分交给向量召回:标题、副标题、类目拼成一段文本,用本地部署的 embedding 模型算向量, 存进 pgvector,用 HNSW 索引按余弦距离取最近的 N 条。

两路各自出一个排好序的列表,在应用层用 RRF(Reciprocal Rank Fusion) 融合: 每件商品的得分是 Σ 1 / (k + 名次),k 取论文里常用的 60。RRF 只看名次、不看原始分数, 所以不需要把 ts_rank_cd 和余弦距离这两个尺度完全不同的数硬凑到一起。

有一条纪律值得提:两路查询的过滤条件必须逐字一致(上下架、类目、价格区间、门店可见性......)。 不一致不会报错,但会出现「一件商品在一路里可见、在另一路里不可见」, RRF 拿到两份对「哪些商品存在」意见不同的列表,融合出来的排序就没有意义了。

接口里还带了一个 recall_source 字段(keyword / vector / both),标明每条结果是哪一路捞到的。 它不只是调试用:某类查询长期只由单路命中,就说明另一路在这类查询上失效了。

四、上线后的坑:搜一个字,没有结果

做完一轮深度审查时发现:搜「杯」「咖」,关键词这一路一条都召不回来。

原因回头看很直白。查询侧「杯」只有一个字,切出来就是「杯」本身; 而索引里「陶瓷马克杯」存的是 陶瓷 瓷马 马克 克杯,全是二元组。 tsvector 按整词匹配,「杯」和「克杯」永远对不上。

修法:只改索引侧,单字追加在最后

最直接的想法是查询侧也拆单字,但那样「连衣裙」会 OR 上「连」「衣」「裙」,召回层会被单字噪声淹没。 所以反过来:只在索引侧,在二元组后面追加每个汉字的单字(去重):

arduino 复制代码
"陶瓷马克杯" → "陶瓷 瓷马 马克 克杯 陶 瓷 马 克 杯"

两字以上的查询只会切出二元组,从来不会碰到索引里的单字,召回结果和原来完全一样。

单字追加在最后 而不是插在二元组中间,也是有意的:ts_rank_cd 按词的位置算覆盖密度, 插在中间会把相邻二元组的位置拉开,多字查询的排序就跟着变了。追加在末尾, 二元组的位置和原来逐个相同,排序不受影响。

两侧的约定也从「切得一模一样」放宽成「查询切出来的每个词,索引里一定有」, 并为这句话补了一条测试。

更隐蔽的坑:光改版本号,一件商品都不会重算

search_text 是由后台索引任务维护的派生数据。为了判断「要不要重算」, 每件商品存了一份输入指纹,指纹里编进了模板版本号------改算法时升一下版本号, 指纹就变了,理论上全库都会被判为过期。

这里有两个细节:

1. 版本号要分开。 原来 embedding 和 search_text 共用一个模板版本号。 这次只改了切分,如果升那个共用版本号,全库的向量也会被判为过期,全部重新算一遍 embedding------ 向量的输入一个字都没变,这笔算力完全白花。所以给 search_text 单独拆了一个版本号 bigram-v2。

2. 版本号只管「判定」,不管「触发」。 索引任务并不会定期扫全库比对指纹, 它的触发条件是「商品的 updated_at 晚于上次判定的时间」。改了代码、升了版本号, 已有的商品一件都没变,一件都不会被重新判定。

最后用一个迁移把「上次判定时间」统一拨回 1970 年,让每件商品都被重新判定一次; 因为 embedding 那一格的指纹没变,只有 search_text 会被重写。表上有一个 BEFORE UPDATE 触发器会把 updated_at 覆写成 now(),所以迁移里要临时关掉它:

sql 复制代码
ALTER TABLE product_understanding DISABLE TRIGGER touch_product_understanding_updated_at;
UPDATE product_understanding SET updated_at = 'epoch'::timestamptz;
ALTER TABLE product_understanding ENABLE TRIGGER touch_product_understanding_updated_at;

也考虑过把 search_text 直接置 NULL 来触发,但没有配推理服务的部署里索引任务根本不启动, 那样关键词搜索会整条断掉,比「单字搜不到」糟糕得多。拨时间戳在那种部署里什么都不改变。

修完之后在线上演示站实测:

查询 关键词这一路命中
杯 陶瓷马克杯
咖 挂耳咖啡、意式浓缩咖啡机、手冲咖啡壶、冷萃咖啡液
连衣裙 与修复前一致

五、这套方案的边界

说清楚它不擅长什么:

  • 没有同义词。「T恤」和「短袖」在关键词这一路是两个东西,只能靠向量那一路补。
  • 二元组会有误命中。搜「茶」会命中副标题里有「茶轴」的机械键盘------字面上确实包含,但未必是用户想要的。这要靠后面的精排来压。
  • 离线评测集还没建,所以现在的效果是「看着对」,还没有量化的召回率数字。这是下一步要补的。

如果你的场景是中小规模商品库、不想多维护一套搜索集群,这套方案值得一试。


文中的代码都来自开源项目 Keel :一个多租户电商系统,PostgreSQL 单库、 内置 dtmrs 分布式事务协调器、检索用本地模型不调外部 AI 接口,Apache-2.0 协议。

切分、查询、RRF 分别在 internal/search/ 下的 bigram.go、query.go、rrf.go, 注释写得比较细,欢迎来提 issue 或者拍砖。

相关推荐
云浪3 小时前
通过 Goroutine 搞懂 Go 并发与常见的 9 个坑
后端·go
柒和远方8 小时前
NestJS + TypeORM 学习笔记:语法和类型不绕了
postgresql·orm·nestjs
用户7783366132118 小时前
写个浏览器插件查 SERP:选中关键词右键看前 10(Chrome MV3 实战)
搜索引擎·api
Flynt8 小时前
"MySQL搜不动就上ES"?我先在50万行数据上测了它自带的ngram全文索引
mysql·elasticsearch·搜索引擎
福兮说8 小时前
singleflight 防缓存击穿,这四个坑它不会替你挡
go
Y008 小时前
PostgreSQL 的锁:为什么你的 ALTER TABLE 会卡住,以及怎么查
数据库·postgresql
我的div丢了肿么办8 小时前
自定义类型和类型别名以及实例化结构体的5种方式
后端·go
小满zs8 小时前
Go语言第十三章(互斥锁,读写锁)
后端·go
福兮说8 小时前
errgroup 的六个坑:Wait 之后 ctx 已取消、SetLimit 嵌套死锁,以及另外四个
后端·go