PostgreSQL自定义文本检索分词规则
PostgreSQL自定义文本检索分词规则
PostgreSQL的倒排索引十分强大,虽然为文本检索设计,但也可以移植到其他用途。
有如下数据表,其中
tagset
是一系列
tag
的集合:
id | tagset |
---|---|
1001 | "tag_01 tag_02 tag_05" |
1024 | "tag_02 tag_04 tag_06" |
1032 | "tag_03 tag_05 tag_06" |
要通过
id
找到
tagset
,可以直接使用普通B+树索引。但要是想高效地通过
tag
来找到所有的
id
呢?
例如,通过
tag_05
快速找到
id
1001和1032,通过
tag_02 AND tag_05
快速找到
id
1001,通过
tag_02 OR tag_04
快速找到
id
1001和1024。
B+树索引无法完成这个功能,直接写SQL查询(
LIKE
语句)将会特别复杂、特别慢。而文本搜索+倒排索引可以实现这个快速查找的功能。
主要思路为,先将
tagset
转化为
ts_vector
,然后将查询转化为
ts_query
,使用文本匹配的方式来查询对应的
id
,并建立倒排索引加速查询。
此时又遇到了问题,将
tagset
转化为
ts_vector
需要经过分词器转化为token,然后经过一系列字典过滤掉无关信息、转化为lexeme。由于是为文本检索而设计,所以默认的分词规则和字典都是按照自然语言的规则来实现。要实现自己的功能,必须自定义文本检索配置。
CREATE TEXT SEARCH [CONFIGURATION|DICTIONARY|PARSER|TEMPLATE]
PostgreSQL提供了丰富的字典自定义方法,可以很轻松地对字典的规则进行自定义。而对于分词规则,PostgreSQL只有一套分词逻辑,在源码
src/backend/tsearch/wparser_def.c
中实现,官方文档写明适用于绝大部分文本。但对于以上需求,已经不是文本检索,而是为了使用文本检索+倒排索引实现自己的查询需求,所以分词器也需要自定义。
对于
tagset
,分割为
tag
的时候,如果使用默认的分词规则,极有可能遇到将一个
tag
继续拆分、或者多个
tag
不拆分的错误,也就是没有按照我们想要的规则进行分词。如:
yuesong=# SELECT to_tsvector('token_01 tagset-02 tagset03 token:04');
to_tsvector
-----------------------------------------------------------
'-02':4 '01':2 '04':7 'tagset':3 'tagset03':5 'token':1,6
(1 row)
yuesong=# SELECT alias, description, token FROM ts_debug('token_01 tagset-02 tagset03 token:04');
alias | description | token
-----------+--------------------------+----------
asciiword | Word, all ASCII | token
blank | Space symbols | _
uint | Unsigned integer | 01
blank | Space symbols |
asciiword | Word, all ASCII | tagset
int | Signed integer | -02
blank | Space symbols |
numword | Word, letters and digits | tagset03
blank | Space symbols |
asciiword | Word, all ASCII | token
blank | Space symbols | :
uint | Unsigned integer | 04
(12 rows)
我们希望按空格拆分成4个
tag
,而默认的分词规则进行了胡乱拆分,将
_
、
:
当成了分隔符,将
-
处理成了负号。
接下来自定义一个分词器,实现自定义规则拆分token的功能。如果直接改动默认分词规则
src/backend/tsearch/wparser_def.c
,将会影响到正常文本检索的分词逻辑,所以使用插件的方式新增一个分词规则。
PostgreSQL自定义分词规则的语法为:
CREATE TEXT SEARCH PARSER name (
START = start_function ,
GETTOKEN = gettoken_function ,
END = end_function ,
LEXTYPES = lextypes_function
[, HEADLINE = headline_function ]
)
我们需要在插件中实现四个函数,分别为
START
,
GETTOKEN
,
END
,
LEXTYPES
。
首先在插件的
.sql
文件
pg_tokenparser--1.0.sql
里创建原型:
-- 开始解析函数
CREATE OR REPLACE FUNCTION parse_start(internal,int4)
RETURNS internal
AS 'MODULE_PATHNAME', 'parse_start'
LANGUAGE C STRICT;
-- 获取token函数
CREATE OR REPLACE FUNCTION parse_gettoken(internal,internal,internal)
RETURNS internal
AS 'MODULE_PATHNAME', 'parse_gettoken'
LANGUAGE C STRICT;
-- 结束解析函数
CREATE OR REPLACE FUNCTION parse_end(internal)
RETURNS void
AS 'MODULE_PATHNAME', 'parse_end'
LANGUAGE C STRICT;
-- token类型函数
CREATE OR REPLACE FUNCTION parse_tokentype(internal)
RETURNS internal
AS 'MODULE_PATHNAME', 'parse_tokentype'
LANGUAGE C STRICT;
-- 创建文本检索分词器
CREATE TEXT SEARCH PARSER tokenparser(
START = parse_start,
GETTOKEN = parse_gettoken,
END = parse_end,
LEXTYPES = parse_tokentype
-- 创建自定义文本检索配置,指定分词器为我们自定义的分词器
CREATE TEXT SEARCH CONFIGURATION my_cfg (
PARSER = tokenparser
-- 为word类型添加默认的英文字典,以确保转化为lexeme不会被过滤掉
ALTER TEXT SEARCH CONFIGURATION my_cfg ADD MAPPING FOR word WITH english_stem;
然后用C语言实现这四个分词规则函数,当前仅使用空格来分割,token只有一种类型:
typedef struct {
char *buffer; /* text to parse */
int len; /* length of the text in buffer */
int pos; /* position of the parser */
} ParserState;
/* copy-paste from wparser.h of tsearch2 */
typedef struct {
int lexid;
char *alias;
char *descr;
} LexDescr;
PG_FUNCTION_INFO_V1(parse_start);
Datum parse_start(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(parse_gettoken);
Datum parse_gettoken(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(parse_end);
Datum parse_end(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(parse_tokentype);
Datum parse_tokentype(PG_FUNCTION_ARGS);
Datum parse_start(PG_FUNCTION_ARGS) {
ParserState *pst = (ParserState *)palloc(sizeof(ParserState));
pst->buffer = (char *)PG_GETARG_POINTER(0);
pst->len = PG_GETARG_INT32(1);
pst->pos = 0;
PG_RETURN_POINTER(pst);
Datum parse_gettoken(PG_FUNCTION_ARGS) {
ParserState *pst = (ParserState *)PG_GETARG_POINTER(0);
char **t = (char **)PG_GETARG_POINTER(1);
int *tlen = (int *)PG_GETARG_POINTER(2);
int type;
*tlen = pst->pos;
*t = pst->buffer + pst->pos;
/* main process here */
if ((pst->buffer)[pst->pos] == ' ') {
/* blank type */
type = 12;
/* go to the next non-white-space character */
while (((pst->buffer)[pst->pos] == ' ') && (pst->pos < pst->len)) {
(pst->pos)++;
} else {
/* word type */
type = 3;
/* go to the next white-space character */
while (((pst->buffer)[pst->pos] != ' ') && (pst->pos < pst->len)) {
(pst->pos)++;
*tlen = pst->pos - *tlen;
/* we are finished if (*tlen == 0) */
if (*tlen == 0)
type = 0;
PG_RETURN_INT32(type);
Datum parse_end(PG_FUNCTION_ARGS) {
ParserState *pst = (ParserState *)PG_GETARG_POINTER(0);
pfree(pst);
PG_RETURN_VOID();
Datum parse_tokentype(PG_FUNCTION_ARGS) {
LexDescr *descr = (LexDescr *)palloc(sizeof(LexDescr) * (2 + 1));
/* there are only two types in this parser */
descr[0].lexid = 3;
descr[0].alias = pstrdup("word");
descr[0].descr = pstrdup("Word");
descr[1].lexid = 12;
descr[1].alias = pstrdup("blank");
descr[1].descr = pstrdup("Space symbols");
descr[2].lexid = 0;
PG_RETURN_POINTER(descr);
}
编译、安装插件后,我们就可以使用我们自定义的分词规则:
yuesong=# SELECT alias, description, token FROM ts_debug('my_cfg', 'token_01 tagset-02 tagset03 token:04');
alias | description | token
-------+---------------+-----------
word | Word | token_01
blank | Space symbols |
word | Word | tagset-02
blank | Space symbols |
word | Word | tagset03
blank | Space symbols |
word | Word | token:04
(7 rows)