PostgreSQL 中文全文检索:自己构建一个带 zhparser 的镜像
从 simple 分词器搜不到「部署」说起,记录自建 PostgreSQL 17 中文检索镜像的完整过程,含词性映射取舍、索引表达式陷阱与端到端验证脚本。
起因:索引建好了,搜索却没生效
博客内容库的权威源是 PostgreSQL,V2 迁移里我就把全文检索索引建好了。当时脚本里用的是 simple 配置,理由写在注释里:官方 postgres:17-alpine 不带 zhparser,V1 里装扩展的那段逻辑装不上会降级,先用 simple 兜底。
索引建完当天我随手试了一条查询,然后发现「兜底」这个词用得太客气了:
SELECT to_tsvector('simple', '今天天气很好,我们去部署博客服务');结果是两个 token:今天天气很好、我们去部署博客服务。simple 分词器只在标点处切一刀,它根本没有中文词的概念。于是:
SELECT to_tsvector('simple','今天天气很好,我们去部署博客服务')
@@ to_tsquery('simple','部署'); -- false这不是「中文检索效果弱」,是功能不可用。任何一篇中文文章里的任何一个词,只要它不恰好等于某个标点之间的整段文本,就搜不到。
试过的错路
第一反应是上 Meilisearch 或 Elasticsearch。想了两分钟就否了:这是一个个人站,检索的并发量大概是一天几十次,为了这个养一个常驻服务,附带一套索引同步逻辑和一套「数据库和搜索引擎不一致怎么办」的运维题,不划算。数据库里已经有词法分析器这套基础设施,我需要的只是一个能对中文切词的 parser。
第二反应是用官方 postgres:17-alpine,在初始化脚本里装扩展。这条路走不通:扩展要编译,而运行时镜像里没有完整的编译环境;就算凑齐工具链,每次 docker compose up 都编译一遍也不合理。
第三个选项是上游的 zhparser/zhparser 镜像。它是存在的,但只发布到 PG16,最新 tag 停在 2023-12-24,没有 17 版。我的库是 PG 17.11。
所以结论是自建:postgres-zhparser:17。
构建配方:一个刻意不联网的 Dockerfile
镜像本身不复杂,FROM postgres:17-alpine,加上两个东西——SCWS 1.2.3(zhparser 底层的分词库)和 zhparser 2.4(扩展本体)。核心就是两段 make:
RUN set -eux; \
apk add --no-cache --virtual .build-deps /tmp/apks/*.apk; \
cd /tmp/scws-src; \
./configure --prefix=/usr/local; \
make -j"$(nproc)" install V=0; \
cd /tmp/zhparser-src; \
make USE_PGXS=1 with_llvm=no -j"$(nproc)"; \
make USE_PGXS=1 with_llvm=no install; \
apk del .build-deps; \
rm -rf /tmp/apks /tmp/scws-src /tmp/zhparser-srcwith_llvm=no 是有意的。官方 PG 镜像由 clang-21 编译,扩展构建默认会尝试生成 JIT bitcode,于是镜像里得塞一整套 clang/llvm。关掉它,代价仅仅是这个扩展的函数不会被 JIT 内联——对一个每天几十次查询的个人站没有实际影响,但镜像能小一大截。
真正的坑不在编译,在网络。构建期我原本是 apk add 联网装依赖的,结果卡死。实测数据很干脆:宿主机 curl 拉一个 60MB 的 gcc 包用 3.7 秒(约 16MB/s),同一个 URL 在容器里 90 秒都拿不下来;小文件正常。我没去深挖根因,直接改成离线构建:
- 构建依赖在宿主机按
pkgs.txt清单下载成.apk文件(20 个包,Alpine 3.24 / x86_64,版本号全部钉死),COPY apks/ /tmp/apks/之后离线安装 - 源码也在宿主机下载解压,统一展开成目录再
COPY。这么做还有个附带好处:SCWS 官方站给的是.tar.bz2,GitHub 的 tag 归档是.tar.gz,在宿主机用tar xf自动识别格式,就不必把压缩参数写死在 Dockerfile 里
清单不是手写的,./build.sh --refresh-deps 会用基础镜像 apk add --simulate 重新生成,避免抄版本号抄错。首次构建约 2 分钟。
镜像构建期还有一段自检:扩展的 zhparser.so、zhparser.control、词典 dict.utf8.xdb、规则 rules.utf8.ini 和 libscws.so 五个文件必须全部存在,缺一个就直接构建失败。理由是运行期才发现扩展装不上,比构建期失败贵得多——那时你已经在启动数据库、跑迁移了。
词性映射:一个必须理解「静默」的地方
扩展装好只是开始。检索配置我命名为 chinese_zh:
CREATE TEXT SEARCH CONFIGURATION chinese_zh (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese_zh ADD MAPPING FOR
a, b, c, d, e, f, g, h, i, j, k, l, m,
n, o, p, q, r, s, t, v, w, x, y, z
WITH simple;注意这里映射了除 u 以外的全部 25 个词性。常规做法是只映射 n,v,a,i,e,l,t(名词、动词、形容词之类),我一开始也是这么写的,然后发现文章里明明写了的词搜不到。
原因是 tsvector 对未在映射表里的词性直接丢弃,而且不报错。代词 我(r)、数词 一个(m)、方位词 反向(f)、介词 在(p) 都会被悄悄扔掉,表现就是「这个词文章里有一堆,搜出来是空的」,而且你去看 to_tsvector 的输出也看不出错——它确实没报错。
另一半原因是 SCWS 的词性判定本身不太可靠。实测里 SpringBoot 被判成 e(感叹词),中文逗号和句号被判成 u(助词)而不是 w(标点)。所以「按语言学常识挑词性」在这条链路上行不通。
最后的结论是映射除 u 外的全部词性。u 刚好承担了过滤器的角色:标点和「的/了」这类虚词都被判成 u,滤掉它们正是想要的效果,而内容词一个不丢。这个取舍没法靠推理得到,只能一条一条试。
后续问题也顺带解决了:新框架名进不了词表怎么办?zhparser.extra_dicts 可以挂一个纯文本补充词表,在 postgresql.conf 里设好 reload 一下就行,不需要重建镜像。
两个让我多花半小时的细节
索引表达式要套双层括号。 索引按标题(A) > 摘要(B) > 正文(C) 加权,表达式是三个 setweight(...) 用 || 拼起来。直接这么写会失败:
CREATE INDEX idx_post_fts ON post USING gin (
setweight(to_tsvector('chinese_zh', coalesce(title,'')), 'A') || ...
);
-- ERROR: syntax error at or near "||"CREATE INDEX 的 index_elem 只接受裸列名或裸函数调用,|| 拼出来的是通用表达式,必须再套一层括号写成 (( ... ))。这个报错信息不会告诉你缺括号。
诊断迁移失败时不要重跑。 psql 默认逐条自动提交,重跑会在前面已提交成功的语句上撞 duplicate key,把真正的失败点盖掉。所以验证脚本里我捕获首次执行的输出,绝不重跑。
验证:不接受「看起来能用」
verify.sh 做端到端验证:起一个临时实例(另起一个端口,不动已有实例的那个),按 V1 → V2 → V3 顺序建库,然后断言检索语义。两个设计细节值得说。
第一,插数据时要插 1 篇命中文章加 1000 篇填充。为什么是 1000 行?因为我最早只插 1 行就去看查询计划,什么问题都测不出来——表里只有一行时任何索引的代价都差不多,规划器挑哪个都「合理」。填充到 1000 行之后,GIN 索引的选择性才体现得出来,测试才有判别力。
第二,SET enable_seqscan=off 和 EXPLAIN 必须在同一个 psql 会话里,否则 SET 不生效。我用一次 psql -c 把两条塞进同一次调用,就是为了保证这一点。
断言里比较有意思的几条:
-- 「反向」(方位词 f)与「我」(代词 r):窄映射会丢,期望各命中 1 行
-- 真正执行的是与索引完全一致的表达式加 plainto_tsquery,断言命中数相等
-- 标点不该进索引(,被 u 类滤除)
SELECT to_tsvector('chinese_zh','测试,结束')::text LIKE '%,%'; -- false
-- 对照组:simple 配置在同一句上搜「部署」
SELECT to_tsvector('simple','今天部署到服务器上') @@ to_tsquery('simple','部署'); -- false最后一条是刻意留的对照组。以后有人(可能是我自己)想「simple 是不是也够用」,这条断言会告诉他不够。
代价与遗留问题
自建镜像不是没有成本:
- 镜像不在任何 registry 里,只存在于构建它的那台机器上。换机器或重装时
docker compose up会直接报找不到镜像,必须先跑一次build.sh。这一条我写进了 compose 文件和部署文档。 - V3 迁移会因为缺扩展而失败。这看起来是缺陷,其实是有意设计:宁可迁移报错,也不要静默产出一个搜不到中文的索引。被「静默失效」坑过一次之后,我对这两个字很敏感。
pkgs.txt钉死了 Alpine 3.24 / x86_64 的 20 个包版本,可复现性好,但基础镜像一升级(比如跟着换到 Alpine 3.25),清单就要重新生成。- 容器内大文件下载挂死这件事我没找到根因,只是绕过了。所以 Dockerfile 头部写着:换机器构建前先复现这个问题,再决定要不要改回构建期联网。
小结
中文全文检索在 PostgreSQL 上不是「配个参数」的事:你需要一个自带 zhparser 的镜像(因为上游没有 PG17 版),需要理解词性映射的静默丢弃(所以映射除 u 外全部),还需要一套能证伪的验证脚本(1000 行填充数据加同会话 EXPLAIN)。
值得吗?对个人站点来说值得——方案里没有引入任何新的常驻服务,检索和内容处在同一个事务边界内,备份依然只是一份 pg_dump 的事。