Skip to content

中文全文搜索方案(pg_jieba / zhparser) ​

PostgreSQL 内置了三十多种全文搜索配置,覆盖了英语、德语、法语、俄语、阿拉伯语等——但没有一种能正确处理中文。原因不在于 PostgreSQL 偷懒,而在于中文的书写方式:英文靠空格分词,full text search 天然是三个词;中文词与词之间没有分隔符,数据库管理系统 究竟该切成 数据库 / 管理 / 系统 还是 数据 / 库 / 管理 / 系统,需要专门的算法加词典来判断。

内置解析器做不到这一点。它按「连续的非空白、非标点字符序列」切 token,一串汉字会被整段当成一个 token。结果是:搜 数据库 搜不到,搜 开源 也搜不到,只有把整句一字不差地贴进去才能命中——这样的全文搜索等于没有。

要解决中文搜索,必须在解析器这一层做替换:引入一个能把中文切成词、并给每个词打上词性标签的解析器。工程上可用的是 zhparser(基于 SCWS)与 pg_jieba(基于 cppjieba),把它们提供的解析器接上自定义配置后,tsvector、GIN 索引、ts_rank、ts_headline 这些上一节学过的技能全部照用不改。

本节讲清中文为什么必须先分词、两个扩展的安装与配置方式、各自的 GUC 参数与自定义词典机制、中文停用词与同义词的落地写法、中英混排处理、与 pg_trgm 的组合策略,以及升级重建索引这类容易踩的坑。

前置知识

阅读本节前,建议先阅读:GIN / GiST 全文索引

基础概念 ​

中文为什么不能靠空格分词 ​

语言特性英文中文
词边界空格 / 标点天然分隔无分隔符
分词方式按空白切分即可需要词典 + 算法(机械匹配 / HMM / CRF)
词形变化foxes → fox 需要词干算法无词形变化,但有 的、了、是 等虚词噪音
停用词the、is 等固定表需按分词结果过滤
同义词相对规范极丰富(电脑 = 计算机 = PC)
新词变化慢网络新词层出不穷,词库需持续更新

内置解析器(pg_catalog.default)是基于规则的:它识别空白、标点、字母、数字,然后把连续的非空白序列当成一个 token。对中文来说,一整串没有空格的汉字就是「一个 token」。

内置解析器实际会做什么 ​

sql
SELECT to_tsvector('simple', 'PostgreSQL 是世界上最先进的开源关系型数据库');
text
                              to_tsvector
------------------------------------------------------------------------
 'postgresql':1 '世界上最先进的开源关系型数据库':3 '是':2

注意 是 被单独切了出来——因为它两侧有空格,规则解析器正好在空格处切开了;而 世界上最先进的开源关系型数据库 整块成了一个词位。关键词一个都搜不到。

同理,\dF 里列出的配置只有 simple 和一批 Snowball 语言(arabic、danish、english … russian、swedish),没有 chinese:Snowball 提供的是「词干化(stemming)」,而中文需要的是「分词(word segmentation)」,这是两个不同的问题。

分词与词干化是两件事 ​

上一节讲的词典层做的是归一化:把 running 变成 run,把 手机 变成 移动电话。前提是「词」已经存在。

中文缺的恰恰是这一步:token 还没被切出来,词典层无从下手。所以中文方案必须落在解析器层——这也是 zhparser / pg_jieba 与普通词典扩展的本质区别:它们提供的是 TEXT SEARCH PARSER(解析器),不是 TEXT SEARCH DICTIONARY(词典)。

接入后仍然沿用上一节的三层模型,只是第一层被替换掉了:

text
原始中文文本
    │
    ▼
[zhparser / jieba 解析器]   ← 替换 pg_catalog.default,输出 n/v/a/... 等词性标签
    │
    ▼
[自定义配置 zh_cfg]          ← 把词性标签映射到词典列表
    │
    ▼
[simple / 自定义词典]        ← 小写归一、可选停用词、可选同义词
    │
    ▼
tsvector —— 与英文完全同构,可建 GIN 索引、可用 ts_rank / ts_headline

三条可选路线 ​

方案原理优点缺点适用
pg_trgm三字符组模糊匹配零依赖、PG 官方扩展、无需分词无词干化、无相关度排序、索引偏大短文本模糊搜索、姓名 / 编号
zhparser基于 SCWS 的中文分词解析器与 tsvector 体系完全兼容,可用 GIN / ts_rank / ts_headline需编译安装、词库相对陈旧中文全文检索首选
pg_jieba基于 cppjieba 的中文分词解析器词库现代、分词质量好需编译安装、需 C++11 工具链zhparser 的替代或补充
外部搜索引擎ES / OpenSearch / MeiliSearch分词最好、功能最全独立服务、数据同步复杂亿级文档、搜索是核心业务
text
需求是什么?
  │
  ├─ 只是"姓名/标题模糊匹配" ─────────► pg_trgm(零依赖)
  │
  ├─ 需要"中文全文检索 + 排序 + 高亮" ─► zhparser
  │
  ├─ 对分词质量要求更高、工具链允许 ───► pg_jieba
  │
  └─ 文档量巨大、需要同义词/纠错/聚合 ─► 外部搜索引擎

关键前提:两者都是「解析器」 ​

无论选哪个扩展,最终产出的都是标准 tsvector。这意味着:

  • 索引写法与英文完全一致:USING GIN (fts)。
  • 排序函数与英文完全一致:ts_rank / ts_rank_cd。
  • 高亮函数与英文完全一致:ts_headline。
  • 权重标记与英文完全一致:setweight(..., 'A')。

唯一的差异是配置名:查询与索引必须使用同一个中文配置,例如 public.zh_cfg 或 jiebacfg。

语法与用法 ​

安装 zhparser(编译) ​

zhparser 不在 PostgreSQL 官方发行包里,必须从源码编译。它依赖 SCWS,所以要先装 SCWS:

bash
# 1. 安装 SCWS(编译并安装到 /usr/local)
wget -q -O - http://www.xunsearch.com/scws/down/scws-1.2.3.tar.bz2 | tar xf -
cd scws-1.2.3 && ./configure && make install

# 2. 拉取 zhparser 源码
git clone https://github.com/amutu/zhparser.git
cd zhparser

# 3. 编译安装(SCWS 不在 /usr/local 时需指定 SCWS_HOME)
make && sudo make install
# SCWS_HOME=/usr make && sudo make install

# 4. 多版本 PostgreSQL 共存时,显式指定目标版本的 pg_config
# PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config make && sudo make install

装完后可以用 pg_config --pkglibdir / pg_config --sharedir 确认扩展文件是否落到了 PostgreSQL 的安装目录。

创建扩展与中文配置 ​

扩展由超级用户创建,每个数据库装一次:CREATE EXTENSION zhparser;(成功时返回 CREATE EXTENSION)。

再基于它建一个中文配置。注意 ADD MAPPING 后面要写词性标签(n、v、a…),而不是英文配置里的 asciiword / word:

sql
CREATE TEXT SEARCH CONFIGURATION public.zh_cfg (PARSER = zhparser);

ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ADD MAPPING FOR n, v, a, i, e, l WITH simple;

这一步的含义是:名词、动词、形容词、成语、感叹词、习用语这六类 token 交给 simple 词典处理(小写归一,不做词干化)。其他类型(如标点、虚词)没有映射,会被直接丢弃——这正是我们想要的。

验证分词:ts_parse 与 ts_debug ​

ts_parse() 看解析器的原始切分结果:

sql
SELECT * FROM ts_parse('zhparser', 'hello world! 2010年保障房建设在全国范围内获全面启动');
text
 tokid | token
-------+-------
   101 | hello
   101 | world
   117 | !
   101 | 2010
   113 | 年
   118 | 保障
   110 | 房建
   118 | 设在
   110 | 全国
   110 | 范围
   102 | 内
   118 | 获
    97 | 全面
   118 | 启动
(14 rows)

ts_debug() 看「token → 词典 → 词位」的全链路:

sql
SELECT alias, token, dictionary, lexemes
FROM ts_debug('public.zh_cfg', 'PostgreSQL 是世界上最先进的开源关系型数据库')
WHERE alias <> 'blank';
text
 alias |   token    | dictionary |  lexemes
-------+------------+------------+------------
 x     | PostgreSQL | simple     | {postgresql}
 v     | 是         | simple     | {是}
 n     | 世界       | simple     | {世界}
 f     | 上         | simple     | {上}
 a     | 最         | simple     | {最}
 a     | 先进       | simple     | {先进}
 u     | 的         |            |
 j     | 开源       |            |
 n     | 关系型     | simple     | {关系型}
 n     | 数据库     | simple     | {数据库}
(10 rows)

不同 zhparser / SCWS 版本的分词结果与词性标注可能略有差异,实际以 ts_debug() 输出为准。

判断三点:

  • lexemes 非空 → 该词会进索引,可被搜到。
  • lexemes 为空且 dictionary 有值 → 该 token 命中了停用词,占位但不进索引。
  • dictionary 为 NULL → 该 token 类型在配置里没有映射,直接被丢弃(上面 u、j 两行就是)。

看 tsvector 长什么样 ​

沿用上面的配置(只映射 n,v,a,i,e,l):

sql
SELECT to_tsvector('public.zh_cfg', 'PostgreSQL 是世界上最先进的开源关系型数据库');
--                                          to_tsvector
-- ---------------------------------------------------------------------------------
--  '是':1 '世界':2 '最':3 '先进':4 '关系型':5 '数据库':6

对照前面的 ts_debug() 输出逐个看:

  • PostgreSQL(x)、上(f)、的(u)、开源(j)在映射里没有位置,被整段丢弃,位置编号里也不占号。
  • 是、世界、最、先进、关系型、数据库 进索引,位置连续。

原本搜不到的 数据库、关系型 现在都能搜到了。映射列表就是「哪些 token 值得进索引」的白名单:漏掉某类词性,表现就是「这个词怎么都搜不到」。比如想让 开源 可检索,就把它所属的 j 补进映射:

sql
ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ADD MAPPING FOR j WITH simple;

排查手段永远是同一个:用 ts_debug() 看这个 token 的 alias 是什么,再确认该 alias 是否出现在 \dF+ public.zh_cfg 的映射列表里。

查询:必须用同一个配置 ​

sql
SELECT to_tsquery('public.zh_cfg', '保障房资金压力');
-- '保障' <-> '房' <-> '资金' <-> '压力'

这里有个和英文不同的行为,必须点明:to_tsquery 在英文下要求你手写 & / |,而中文串里没有这些符号,整串会被当成一个 token 交给分词器。分词器切出的多个词属于同一个 token,PostgreSQL 会用短语操作符 <-> 把它们串起来——因为中文连写的多个词在原句中本来就是相邻的。

这带来一个容易踩的坑:to_tsquery 只认显式运算符,空格分隔的多个裸词会直接报语法错误:

sql
-- 多个词之间必须有 & | ! <->,否则整段被当成一个 token 之外的语法被拒绝
SELECT to_tsquery('public.zh_cfg', '数据库 性能');
-- ERROR:  syntax error in tsquery: "数据库 性能"

SELECT to_tsquery('public.zh_cfg', '数据库 & 性能');    -- '数据库' & '性能'
SELECT to_tsquery('public.zh_cfg', '数据库 <-> 性能');  -- '数据库' <-> '性能'

plainto_tsquery 与 phraseto_tsquery 没有这个限制:它们不解析输入里的运算符,把整段文本当作一个 morph,运算符分别固定为 & 与 <->:

sql
SELECT plainto_tsquery('public.zh_cfg', '数据库 性能');    -- '数据库' & '性能'
SELECT phraseto_tsquery('public.zh_cfg', '数据库 性能');  -- '数据库' <-> '性能'

生产环境直接处理用户输入时,推荐 websearch_to_tsquery():它同样不报语法错误,并额外支持双引号短语与 - 排除:

sql
SELECT websearch_to_tsquery('public.zh_cfg', '"数据库" -mysql');
-- '数据库' & !'mysql'

| 函数 | 运算符来源 | 用户输入含 &/|/!/括号 | 双引号 | - 排除 | | --- | --- | --- | --- | --- | | to_tsquery | 用户手写 | 会被解析(可能报错) | 不支持 | 不支持 | | plainto_tsquery | 固定 & | 全部当普通字符 | 不支持 | 不支持 | | phraseto_tsquery | 固定 <-> | 全部当普通字符 | 不支持 | 不支持 | | websearch_to_tsquery | 固定 &/<->/! | 全部当普通字符 | 支持 | 支持 |

结论:给用户输入的查询,一律用 websearch_to_tsquery;只有你自己拼 SQL 字面量时才用 to_tsquery。

安装 pg_jieba(编译) ​

pg_jieba 需要 C++11(gcc 4.8+),用 CMake 构建:

bash
git clone https://github.com/jaiminpan/pg_jieba
cd pg_jieba
git submodule update --init --recursive   # 拉取 cppjieba 子模块

mkdir build && cd build
cmake ..
make && sudo make install

# 自定义安装路径的 PostgreSQL
# cmake -DCMAKE_PREFIX_PATH=/PATH/TO/PGSQL_INSTALL_DIR ..

同样由超级用户建立扩展:CREATE EXTENSION pg_jieba;

pg_jieba 的四套预置配置 ​

CREATE EXTENSION pg_jieba 之后会自动出现四个配置,可按场景直接选用:

配置名算法特点建议
jiebamp最大概率(MP)切出的词偏长、偏整词语边界清晰的长词
jiebahmm隐马尔可夫(HMM)能发现未登录词,但可能切出怪词探索性场景
jiebacfgMP + HMM 混合兼顾两者,最通用生产默认首选
jiebaqry混合 + 全模式同一串文本尽量多切,召回高短查询词、搜索建议

用法与普通配置完全一致:

sql
SELECT to_tsvector('jiebacfg', '是拖拉机学院手扶拖拉机专业的');
-- '专业':5 '会':13 '拖拉机':2 '学院':3 '手扶拖拉机':4 '档':1

SELECT to_tsquery('jiebacfg', '保障房资金压力');
-- '保障' & '房' & '资金' & '压力'

设为默认配置 ​

确认效果满意后,可以让 default_text_search_config 指向中文配置,省去每次都写配置名:

sql
ALTER DATABASE mydb SET default_text_search_config = 'public.zh_cfg';

也可以写进 postgresql.conf 的 default_text_search_config(影响整个实例的新连接)。生产 SQL 仍建议显式写配置名,理由与上一节相同:显式才能让「索引侧」和「查询侧」在代码里一眼比对,也避免不同环境行为漂移。

详细说明 ​

zhparser 的 GUC 参数 ​

zhparser 用一组 GUC 参数控制分词行为。默认值全部为 false(关):

参数作用域默认含义
zhparser.punctuation_ignoreUSERSEToff忽略标点等特殊符号,不输出标点 token
zhparser.seg_with_dualityUSERSEToff闲散单字自动以二字分词法聚合
zhparser.multi_shortUSERSEToff短词复合:同时输出短词与整词
zhparser.multi_dualityUSERSEToff散字二元复合
zhparser.multi_zmainUSERSEToff重要单字复合
zhparser.multi_zallUSERSEToff全部单字复合
zhparser.dict_in_memoryBACKENDoff词典全量载入内存(每后端一份私有副本)
zhparser.extra_dictsBACKEND空额外词典文件名列表(逗号分隔)

两个作用域区别很关键:

  • USERSET 参数可在会话里随时 SET,立即生效。
  • BACKEND 参数必须在 backend 启动前设置(写进 postgresql.conf 后 reload,新建连接才生效),会话内 SET 无效。

multi_short 是生产环境最该考虑打开的一个。 不开时,中华人民共和国 只切出一个长词;用户搜 中华 搜不到:

sql
SET zhparser.multi_short = on;
SELECT to_tsvector('public.zh_cfg', '中华人民共和国');
-- '中华':1 '人民':2 '共和国':3 '中华人民共和国':1

zhparser 自定义词典:表驱动方式 ​

zhparser 2.1 起提供了一张数据库级的自定义词表(\d zhparser.zhprs_custom_word 可看结构),需要超级用户权限,改动后要手动同步:

sql
-- 新增词(可指定 TF / IDF / 词性)
INSERT INTO zhparser.zhprs_custom_word (word, tf, idf, attr)
VALUES ('向量数据库', 1.0, 1.0, 'n');

-- 删除/屏蔽一个词:把词性设为 '!',即使核心词库里存在该词也视为无效
INSERT INTO zhparser.zhprs_custom_word (word, attr) VALUES ('某个不该出现的词', '!');

写完必须同步,并重新建立连接才能生效:

sql
SELECT sync_zhprs_custom_word();   -- 返回 1 row

-- 重连后再验证
SELECT * FROM ts_parse('zhparser', '保障房资金压力');
-- 118 | 保障
-- 110 | 房
-- 120 | 资金压力

zhparser 自定义词典:文件方式 ​

若词典内容较多,用文件方式更合适——zhparser.extra_dicts 指定 $SHAREDIR/tsearch_data/ 下的文件,逗号分隔,排在后面的优先级更高:

ini
# postgresql.conf(BACKEND 级,必须 reload + 新建连接)
zhparser.extra_dicts = 'dict_extra.txt,mydict.xdb'

文本词典格式为每行一条,字段以空格或制表符分隔,只有「词语」是必需的:

text
# 词语  TF   IDF  词性
新词条  12.0  2.2  n
向量数据库  1.0  1.0  n
  • .txt 为文本格式,.xdb 为二进制格式(用 SCWS 自带的 scws-gen-dict 从 txt 转换,效率更高,适合大词典)。
  • 以 # 或分号开头的行为注释。
  • 词性写 ! 表示该词作废。
  • 官方建议 txt 词典不要过大——它每次会监测文件修改时间并转成 xdb 放在临时目录。

pg_jieba 的 GUC 参数 ​

pg_jieba 的参数需要通过 shared_preload_libraries 预加载才会生效,且修改后需要重启:

ini
# postgresql.conf
shared_preload_libraries = 'pg_jieba.so'   # 修改需重启
参数修改方式含义
pg_jieba.hmm_model需重启HMM 模型文件
pg_jieba.base_dict需重启基础词典文件
pg_jieba.user_dict需重启用户词典文件名列表(逗号分隔,不含 .dict 后缀),文件须位于 tsearch_data 目录

pg_jieba 自定义词典:jieba_user.dict ​

pg_jieba 的用户词典是一个固定文件名的文件,放进 PostgreSQL 的 tsearch_data 目录即可:

bash
cd $(pg_config --sharedir)/tsearch_data
cp /path/to/your_dict jieba_user.dict

文件格式为每行一个词,可选带词频与词性:

text
云计算
韩玉鉴赏
蓝翔 nz
区块链 10 nz

多个词典文件时,用 pg_jieba.user_dict 列出名字(不含 .dict):

ini
# postgresql.conf
pg_jieba.user_dict = 'jieba_user,my_dict'

中文停用词 ​

中文里 的、了、是、在、和 这类虚词出现频率极高却几乎无区分度,应该过滤掉。做法与英文完全一致:建一个带 STOPWORDS 的 simple 词典。

第一步:在 $SHAREDIR/tsearch_data/ 下创建停用词文件(必须是 UTF-8 编码):

text
# zh_stop.stop
的
了
是
在
和
就
也
都
而
及
与
着
或
把
被

第二步:创建词典,并把它放在词典列表的最前面:

sql
CREATE TEXT SEARCH DICTIONARY public.zh_stop (
    TEMPLATE  = simple,
    STOPWORDS = zh_stop
);

ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ALTER MAPPING FOR n, v, a, i, e, l WITH zh_stop, simple;

验证:用 ts_debug() 看没被过滤掉的词,或直接看 tsvector 里 是、最 这类词是否消失:

sql
SELECT to_tsvector('public.zh_cfg', '数据库 是 世界上 最 先进 的 开源 数据库');
-- '世界':3 '先进':6 '开源':7 '数据库':1,8

是、最、上 等噪音词已被过滤。

注意:停用词不是「不存在」,它会占掉位置编号。依赖位置的查询(<N> 短语跨度、ts_rank_cd)会受影响,这与英文配置的行为一致。vl 类词一旦被过滤,否定查询(!是)也就失去意义——停用词表要克制。

中文同义词 ​

synonym 词典同样支持中文。$SHAREDIR/tsearch_data/zh_syn.syn:

text
# 第一个词是"规范词",后面全部是它的同义词(含它自己)
计算机  电脑  微机
数据库  db  database
人工智能  ai

创建词典并加入映射:

sql
CREATE TEXT SEARCH DICTIONARY public.zh_syn (
    TEMPLATE = synonym,
    SYNONYMS = zh_syn
);

ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ALTER MAPPING FOR n, v, a, i, e, l WITH zh_stop, zh_syn, simple;
sql
SELECT to_tsvector('public.zh_cfg', '我买了一台电脑');
text
                to_tsvector
-------------------------------------------
 '买':3 '一台':4 '计算机':5

电脑 被归一成了 计算机,搜 计算机 也能命中。

短语级替换(机器学习 → ML)要用 thesaurus 词典与 .ths 文件。注意 thesaurus 会改变词元位置,改动后必须重建索引。

中英混排 ​

中文技术文档里混着英文术语与数字是常态(PostgreSQL 支持 JSONB)。处理要点:

  1. 解析器本身是能切英文的。ts_parse('zhparser', 'hello world!') 能把 hello、world 切成独立 token(见前文示例)。
  2. 但配置里必须有对应映射,否则英文 token 会被丢弃。查你安装版本的解析器定义了哪些 token 类型:
sql
-- 部分输出(共 26 行)
SELECT * FROM ts_token_type('zhparser');
--  tokid | alias |        description
-- -------+-------+----------------------------
--     97 | a     | adjective,形容词
--     99 | c     | conjunction,连词
--    118 | v     | verb,动词
--    119 | w     | punctuation,标点符号
--    120 | x     | unknown,未知词
-- ...
  1. 用 ts_debug() 确认英文 token 的 alias,再补上映射。若英文词被归为 x(unknown),就把它加进映射:
sql
ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ALTER MAPPING FOR n, v, a, i, e, l, x WITH zh_stop, zh_syn, simple;
  1. 英文词干化可以追加 english_stem,但要注意 Snowball 词典「识别一切」,放在列表末尾做兜底正好:
sql
-- 先查已有映射,再逐个 token 类型追加,不要覆盖中文映射
ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ALTER MAPPING FOR x WITH simple, english_stem;

与 pg_trgm 组合 ​

pg_trgm 与全文搜索是两套互不相通的操作符:一套用 @@ + tsquery,另一套用 %、LIKE + similarity()。但它们可以在同一张表上共存,各解决一类子问题。

pg_trgm 按字符(不是词)切三元组,所以中文天然可用:

sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;

SELECT show_trgm('数据库');
text
                  show_trgm
----------------------------------------------
 {"  数", " 数据", "数据", "据库", "据库 ", "库  "}

组合策略:

sql
-- 1. 主检索:分词 + GIN(精确、可排序、可高亮)
SELECT id, title
FROM articles
WHERE fts @@ websearch_to_tsquery('public.zh_cfg', $1)
ORDER BY ts_rank(fts, websearch_to_tsquery('public.zh_cfg', $1)) DESC
LIMIT 20;

-- 2. 兜底:子串模糊匹配,解决"分词切错导致搜不到"与拼写容错
SELECT id, title, similarity(title, $1) AS sim
FROM articles
WHERE title % $1           -- 依赖 gin_trgm_ops 索引
ORDER BY sim DESC
LIMIT 20;
维度分词方案(zhparser / pg_jieba)pg_trgm
匹配粒度词位精确匹配三字符模糊匹配
停用词 / 同义词支持不支持
相关度排序ts_rank / ts_rank_cdsimilarity()(较粗糙)
结果高亮ts_headline需自行实现
1~2 字短查询正常退化,索引几乎无效
索引体积小大(中文三元组基数高)
需编译扩展是否(官方扩展)

结论:pg_trgm 解决「能用」,分词方案解决「好用」。两者不是替代关系,是分层关系。

中文高亮 ​

ts_headline() 处理中文时,必须显式传入中文配置,否则它内部会用默认配置重新分词,导致高亮位置错乱:

sql
SELECT ts_headline(
    'public.zh_cfg',
    'PostgreSQL 是一个功能强大的开源关系型数据库管理系统,支持丰富的数据类型。',
    websearch_to_tsquery('public.zh_cfg', '数据库'),
    'StartSel=<em>, StopSel=</em>, MaxWords=100, MinWords=20'
);
text
                                     ts_headline
--------------------------------------------------------------------------------------
 PostgreSQL 是一个功能强大的开源关系型<em>数据库</em>管理系统,支持丰富的数据类型。

中文要调大 MaxWords:中文一个字就是一个「词」,默认 35 太短(只相当于 35 个字),片段容易被截断得莫名其妙。建议 MaxWords 设到 80~150 并保持 MaxFragments=0(单片段)。

实战示例 ​

数据准备 ​

sql
DROP TABLE IF EXISTS articles;

CREATE TABLE articles (
    id         bigserial PRIMARY KEY,
    title      text NOT NULL,
    body       text NOT NULL,
    author     text,
    created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO articles (title, body, author) VALUES
('PostgreSQL 数据库入门指南',
 'PostgreSQL 是一个功能强大的开源关系型数据库管理系统,支持丰富的数据类型和高级索引技术。',
 '张三'),
('如何优化数据库查询性能',
 '数据库查询性能优化是后端开发的重要课题,合理使用索引可以显著提升查询速度,避免全表扫描。',
 '李四'),
('全文搜索的实现原理',
 '全文搜索依赖倒排索引和分词技术,中文分词需要专门的词典和算法支持,否则搜索结果会很不准确。',
 '王五'),
('数据库事务与并发控制',
 '事务的 ACID 特性是数据库可靠性的基石,PostgreSQL 使用多版本并发控制实现高并发下的数据一致性。',
 '赵六'),
('开源数据库对比分析',
 '常见的开源关系型数据库包括 PostgreSQL 和 MySQL,两者在扩展能力和性能表现上各有优劣。',
 '李四');
-- 输出:INSERT 0 5

示例一:建配置并验证分词 ​

sql
CREATE EXTENSION IF NOT EXISTS zhparser;

CREATE TEXT SEARCH CONFIGURATION public.zh_cfg (PARSER = zhparser);

ALTER TEXT SEARCH CONFIGURATION public.zh_cfg
    ADD MAPPING FOR n, v, a, i, e, l WITH simple;
-- 依次输出:CREATE EXTENSION / CREATE TEXT SEARCH CONFIGURATION / ALTER TEXT SEARCH CONFIGURATION

开短词复合切分(建议写进 postgresql.conf 或 ALTER DATABASE,保证所有会话一致):

sql
-- 错误写法:数据库名不能是函数调用
ALTER DATABASE current_database() SET zhparser.multi_short = on;
-- ERROR:  syntax error at or near "("

-- 正确写法:先确认库名再执行
ALTER DATABASE mydb SET zhparser.multi_short = on;

验证:

sql
SELECT token, alias, dictionary, lexemes
FROM ts_debug('public.zh_cfg', '数据库查询性能优化')
WHERE alias <> 'blank';
--   token   | alias | dictionary |  lexemes
-- ----------+-------+------------+-----------
--  数据库   | n     | simple     | {数据库}
--  查询     | v     | simple     | {查询}
--  性能     | n     | simple     | {性能}
--  优化     | v     | simple     | {优化}
-- (4 rows)

示例二:生成列 + GIN 索引 ​

强烈建议用「存储生成列」而不是表达式索引:查询侧只写 fts @@ q,从根上消除「查询表达式与索引表达式不一致」这类静默失效。

sql
ALTER TABLE articles
    ADD COLUMN fts tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('public.zh_cfg', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('public.zh_cfg', coalesce(body,  '')), 'B')
    ) STORED;

CREATE INDEX idx_articles_fts ON articles USING GIN (fts);
-- 输出:ALTER TABLE / CREATE INDEX

注意配置名必须带 schema 前缀且两边完全一致。 regconfig 的解析受 search_path 影响,索引里固化的是 OID,写 'zh_cfg' 与 'public.zh_cfg' 在某些 search_path 下会解析成不同对象。

示例三:中文检索 + 相关度排序 ​

sql
SELECT id, title, ts_rank(fts, q) AS rank
FROM articles, websearch_to_tsquery('public.zh_cfg', '数据库 性能') AS q
WHERE fts @@ q
ORDER BY rank DESC
LIMIT 10;
--  id |          title          |   rank
-- ----+-------------------------+----------
--   2 | 如何优化数据库查询性能  | 0.607927
--   1 | PostgreSQL 数据库入门指南| 0.243491
--   4 | 数据库事务与并发控制     | 0.243491
--   5 | 开源数据库对比分析       | 0.243491
-- (4 rows)

确认走了索引,而不是全表扫描:

sql
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM articles
WHERE fts @@ websearch_to_tsquery('public.zh_cfg', '数据库');
--                           QUERY PLAN
-- --------------------------------------------------------------
--  Limit
--    ->  Bitmap Heap Scan on articles
--          Recheck Cond: (fts @@ websearch_to_tsquery('public.zh_cfg'::regconfig, '数据库'::text))
--          ->  Bitmap Index Scan on idx_articles_fts
--                Index Cond: (fts @@ websearch_to_tsquery('public.zh_cfg'::regconfig, '数据库'::text))
-- (5 rows)

示例四:新增专有名词并让索引吃到 ​

业务词(产品名、内部术语)必须进词典,否则会被切碎:

sql
-- 1. 加词
INSERT INTO zhparser.zhprs_custom_word (word, tf, idf, attr)
VALUES ('向量数据库', 1.0, 1.0, 'n');

-- 2. 同步(返回 1 row)
SELECT sync_zhprs_custom_word();

3. 重新建立连接(同步结果通过词典文件加载,老连接仍用旧词典)。

4. 重建索引——这一步不能省。 生成列的表达式会在写入时求值,表里已存在的 fts 值不会因为词典变化而自动更新:

sql
-- 先刷新已存在的生成列值
UPDATE articles SET title = title;      -- UPDATE 5

-- 再重建索引(并发重建,避免长时间阻塞写入)
REINDEX INDEX CONCURRENTLY idx_articles_fts;

验证新词生效:

sql
SELECT to_tsvector('public.zh_cfg', '我们正在评估向量数据库方案');
--                    to_tsvector
-- ------------------------------------------------
--  '向量数据库':4 '我们':1 '正在':2 '评估':3 '方案':5

向量数据库 作为整体出现,不再被切成 向量 + 数据库。

示例五:pg_trgm 兜底 ​

sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX idx_articles_title_trgm ON articles USING GIN (title gin_trgm_ops);

-- 用户输入"数据裤"(错别字),分词方案搜不到,trgm 兜底
SELECT id, title, similarity(title, '数据裤') AS sim
FROM articles
WHERE title % '数据裤'
ORDER BY sim DESC
LIMIT 5;
--  id |           title           |   sim
-- ----+---------------------------+----------
--   5 | 开源数据库对比分析        | 0.230769
--   1 | PostgreSQL 数据库入门指南 | 0.187500
-- (2 rows)

注意事项 ​

编译安装是硬门槛。 zhparser 需要先装 SCWS(纯 C,无外部依赖,但要从源码编译),pg_jieba 需要 C++11 工具链与 CMake 子模块。托管数据库(如部分云厂商的 RDS)通常不允许自定义扩展,此时只能退回 pg_trgm,或在应用层做分词后拼接 tsquery。

升级 PostgreSQL 大版本后必须重建相关索引。 扩展的 .so 需针对新版本重新编译;即使能加载,索引内部结构也可能不兼容。标准动作是 REINDEX:

sql
-- 大版本升级后
REINDEX INDEX CONCURRENTLY idx_articles_fts;

改词典后索引不会自动更新。 这是中文场景最常见的「改了没效果」原因。链条是:改词典文件 / zhprs_custom_word 表 → sync_zhprs_custom_word()(zhparser)→ 重连 → 刷新生成列 → REINDEX。少任何一步都得不到预期结果。

不要把 zhparser.extra_dicts / dict_in_memory 写在会话里。 它们是 BACKEND 级参数,必须写进 postgresql.conf 并 reload 后新建连接才生效:

sql
-- 无效!BACKEND 级参数不能在会话里 SET
SET zhparser.dict_in_memory = on;

停用词文件必须是 UTF-8。 用 GBK / GB18030 保存的中文停用词文件在加载时会失败或产生乱码。这与上一节对词典文件的约束是同一条。

thesaurus(.ths)改动后一定要重建索引。 它影响词元位置信息,与其他词典不同——其他词典改动往往只影响新写入的数据,thesaurus 改动会让已有索引与新规则对不上。

两种方案不要在同一个数据库里混用同一配置名。 zhparser 的 PARSER = zhparser 与 pg_jieba 的 jiebacfg 解析器不同,token 类型集合也不同。混用会导致「部分词有时搜到、有时搜不到」的诡异现象。

中文配置的 ADD MAPPING 列表要按业务裁剪。 把 w(标点)、u(助词)、m(数词)等全都映射进去,只会让索引变大、噪音变多。只映射真正有检索价值的类型。

DANGER

升级扩展版本后必须重建索引,且要覆盖全部分区/子表。 扩展升级会改变分词结果,旧的 tsvector 与新词典不再一致,表现为「同一个查询,部分行能搜到、部分行搜不到」。分区表用 REINDEX TABLE 时代价很大,务必安排在维护窗口,或对每个分区单独 REINDEX INDEX CONCURRENTLY。

最佳实践 ​

  1. 先定方案再建模,不要上线后再改。 索引表达式一旦确定(用 public.zh_cfg 还是 jiebacfg),改动意味着全表重建索引。建模阶段就确认:装 zhparser / pg_jieba 还是用 pg_trgm 兜底。

  2. 优先用「存储生成列 + GIN」,而不是表达式索引。 生成列把 tsvector 物化成表的一部分,查询侧只写 fts @@ q;任何词典变动只需 UPDATE 刷新生成列 + REINDEX,逻辑集中在一处。

  3. 用 setweight 合并多字段,只建一个 GIN 列。 标题给 A、正文给 B,一个 fts 列同时承载检索与排序:

sql
ALTER TABLE articles ADD COLUMN fts tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('public.zh_cfg', coalesce(title,'')), 'A') ||
        setweight(to_tsvector('public.zh_cfg', coalesce(body, '')), 'B')
    ) STORED;
  1. 打开 zhparser.multi_short,并写进 postgresql.conf 或 ALTER DATABASE。 短词复合能显著提升「用户搜短词」的召回;用会话级 SET 会造成不同连接行为不一致。

  2. 把配置名写成带 schema 的完整形式。 'public.zh_cfg' 而不是 'zh_cfg',避免 search_path 变化导致索引静默失配。

  3. 建立「词典回归测试」。 用 ts_debug() / ts_parse() 固化一批「输入串 → 期望词元」的断言,覆盖:分词是否正确、停用词是否过滤、同义词是否归一、专有名词是否整体保留。改词典后跑一遍。

  4. 专有名词必须进词典,且进词典后要刷新生成列并重建索引。 顺序记为:加词 → sync_zhprs_custom_word() → 重连 → UPDATE 刷生成列 → REINDEX CONCURRENTLY。

  5. pg_trgm 作为兜底,不作为主检索。 主链路用分词 + GIN(可排序、可高亮、索引小),trgm 索引只用于「短串模糊匹配」与「分词切错时的补救」。两个索引并存是可接受的,但不要在 WHERE 里混用两套操作符去指望走同一个索引。

  6. ts_rank 的调用放在 WHERE 之后、LIMIT 之前,并且不要试图给它建索引。 排名函数只做重排序,不参与索引查找;排名慢说明候选集太大,应优化 WHERE 条件。

  7. ts_headline 只对最终页调用,并且必须显式传中文配置。 它读原始文档、不走索引;MaxWords 在中文下要显著调大(80~150),否则片段会被截断得没有可读性。

  8. 停用词表保持精简,宁少勿多。 过度停用会损失精度——不、无、非 这类否定词被过滤后,否定查询彻底失效。建议先用统计找出「确实高频且无区分度」的词再加。

  9. 为扩展升级准备维护窗口与回滚方案。 大版本升级或扩展升级后,标准动作是「重编译扩展 → 刷新生成列 → REINDEX CONCURRENTLY 全部分区」。把这套流程脚本化,并预先评估索引体积与重建耗时。

下一节

继续学习:FDW 概述

参考链接 ​