不依赖任何数据库扩展的全文搜索

中文全文搜索绕不开分词。这篇讲为什么最后选了「Go 侧二元组分词 + tsvector」这条路,以及它换来了什么、放弃了什么。

搜索是 CMS 里最容易被低估的一块。它看起来只是个输入框,但中文让它变成一个架构问题。

中文没有「词」

PostgreSQL 的全文搜索很好用,前提是文本能用空格切成词。英文里 full text search 天然是三个词元,tsvector 拿来就能用。

中文不是这样。「全文搜索」四个字连在一起,中间没有空格。而 PostgreSQL 自带的分词器对它基本无能为力——to_tsvector('simple', '全文搜索') 得到的还是一个整串。

所以摆在面前的是三条路:

一、装分词扩展。 zhparserpg_jieba 这类扩展能把中文切成词,效果最好。代价是它们不属于 PostgreSQL 发行版,得自己编译或找包,还得维护一份词典。对一个宣称「装机即用」的项目来说,「先装个扩展」和「先装个 Node」在摩擦上是同一件事,只是发生得更早。

二、引一个外部搜索引擎。 Meilisearch 或 Elasticsearch。效果最好,也能扛更大规模。代价是部署清单上多一个要运维的服务、多一处要备份的数据、多一个会独立挂掉并且导致搜索不可用的组件。

三、自己在应用侧切词,把结果存进 tsvector 用 GIN 检索。 不依赖任何扩展,不引入第二个服务,代价是分词质量不如专业分词器。

选了第三条。理由不是它最好,而是这个项目的规模下它的短板不致命,而前两条的运维成本是实打实的。

二元组:不引词典的切词

切法是中日韩文字按二元组切分,拉丁字母与数字按连续串切分并转小写:

全文搜索  →  全文 / 文搜 / 搜索
Full text →  full / text

为什么不引词典?因为词典的失败方式是静默的

一份中文词典要么体积可观,要么需要持续更新。而无论怎么维护,它总会漏掉某些词——新造词、产品名、人名、行业黑话。一旦某个词没被收录,所有包含它的文章就再也搜不到,而且不会报错、不会警告,只会安静地少几个结果。

二元组没有这种失败方式。它可能多召回,但不会漏召回。多召回是可以在查询侧收窄的,漏召回不可修。

多召回的典型样子:「东京都」会被切成 东京 / 京都,于是查「京都」也会命中《东京都的交通》。解法是查询侧保留词元的相邻关系,用短语算子匹配,而不是把查询也摊平成散装二元组。这也是为什么切词器的原语按「段」返回而不是直接返回平铺的词元列表——查询侧需要知道哪些词元原本挨在一起。

一个只在真机上才会炸的细节

索引存在一张独立的表里:

CREATE TABLE post_search (
    post_id    bigint      PRIMARY KEY REFERENCES posts (id) ON DELETE CASCADE,
    tsv        tsvector    NOT NULL,   -- 标题 A、摘要 B、正文 D 三段加权
    indexed_at timestamptz NOT NULL    -- 建索引时依据的 posts.updated_at
);

CREATE INDEX post_search_tsv_idx ON post_search USING gin (tsv);

为什么不给 posts 加一列?因为内容写入走的是 bun 的 Returning("*")——posts 上多出一列,就会被映射回 Go 结构体,而结构体里没有这个字段,于是发文当场 500。

这个坑值得单独说,因为它演示了一类问题:ORM 的「返回所有列」和「结构体即表的镜像」这两个假设,会在你加一列的时候同时失效,而失效的位置在写入路径上。 独立表顺带把事情解决了,还让「以后换成 Meilisearch 就整张表删掉」成为一步操作。

文本配置固定用 simple

to_tsvector('simple', ...)

simple 只做小写化,不做词干还原、不去停用词。这看起来像是「用了最弱的配置」,但词元已经在 Go 侧切好了,此时正需要 PostgreSQL 什么都别做。用 english 配置会把切好的中文词元再折腾一遍,纯属添乱。

索引不在写路径上挂钩子

内容保存时不写索引。索引由后台对账维护:按 posts.updated_at 找出落后的行,重建 tsv

reconcileInterval  = 2 * time.Second   // 新内容进入搜索的最大延迟
reconcileBatch     = 200               // 单次取出的待索引条数
reconcileMaxBatches = 25               // 单轮最多处理的批数

不用钩子的理由:在写入路径上做重活,等于把「保存文章」的成功率绑在「搜索可用性」上。 搜索挂掉不该导致发不了文章。

这三个数字各有来源,不是拍出来的。2 秒是能接受的索引延迟上限;单轮最多 25 批是为了批量导入时一轮就把积压吃完(而不是每 2 秒才前进 200 条),同时在持续写入时不会把一轮变成无限循环——不然对账任务自己就成了一个一直在跑的负载。

上限都有算术依据

索引不是无界的,三个上限都能算出理由:

  • 单个字段最多 60000 个词元。 tsvector 单列上限是 1 MB,超了 PostgreSQL 会直接报错而不是截断。一篇六万汉字的长文约合六万个二元组、420 KB,这个上限留足了余量。
  • 单个拉丁词最多 64 个字符。 更长的多半是 base64 或哈希,进索引只是噪音。
  • 查询串最多 64 个词元。 不然有人把一整篇文章粘进搜索框,查询侧就要拼一个巨大的 tsquery。

留好了换引擎的位置

对外只暴露一个 Searcher 接口,前台和接口都只依赖它:

type Searcher interface { ... }

将来换 Meilisearch 一类外部引擎时,实现换掉、调用方不动。选第三条路是因为它现在的性价比最高,不是因为它永远够用——单机单库、几万篇内容以内它是舒服的;再往上就该换,而换的时候不该牵动业务代码。

后台有重建索引与查看状态两个端点。它们的权限复用 settings:manage,没有单开一条权限串——为一个维护端点新造一个权限,只会让角色编辑器里多一个没人看得懂的选项。

收藏

评论

还没有评论,来说两句。