PostgreSQL 提供了必要的模块,可以组合和创建自己的全文搜索搜索引擎。让我们尝试一下。
这是系列文章的第 1 部分,将要在其中探索 PostgreSQL 中的全文搜索功能,并研究我们可以完成多少典型的搜索引擎功能。在第 2 部分中,我们将比较 PostgreSQL 的全文搜索和 Elasticsearch。
如果您想跟着并尝试示例查询(推荐这样做,这样会更有趣),代码示例将使用来自 Kaggle 的 Wikipedia Movie Plots 数据集。要导入它,请下载 CSV 文件,然后创建此表:
sql
CREATE TABLE movies(
ReleaseYear int,
Title text,
Origin text,
Director text,
Casting text,
Genre text,
WikiPage text,
Plot text);
并像这样导入 CSV 文件:
sql
\COPY movies(ReleaseYear, Title, Origin, Director, Casting, Genre, WikiPage, Plot)
FROM 'wiki_movie_plots_deduped.csv' DELIMITER ',' CSV HEADER;
该数据集包含 34,000 个电影标题,CSV 格式大小约为 81 MB。
1. PostgreSQL 全文搜索原语(full-text search primitives)
Postgres 为全文搜索方法提供了构建块,我们可以组合这些构建块来创建自己的搜索引擎。这非常灵活,但也意味着与 Elasticsearch
、Typesense
或 Mellisearch
等专业搜索引擎相比,它通常感觉比较低级。
我们将通过示例介绍的主要构建块是:
- 数据类型
tsvector
和tsquery
- 用匹配运算符
@@
检查tsquery
是否与tsvector
匹配 - 对每个匹配进行排名的函数(
ts_rank
、ts_rank_cd
) - GIN索引类型,一种用于提高查询
tsvector
效率 的 倒排索引
我们将从查看这些构建块开始,然后将进入更高级的主题,包括相关性增强器(relevancy boosters)、拼写错误容忍(typo-tolerance)和多面搜索(faceted search)。
1.1 tsvector
tsvector
数据类型存储词素(lexemes)的排序列表。词素(lexemes)是一个字符串,就像一个标记(token)一样,但它已经被标准化,以便可以将同一单词的不同形式标准化为同一个形式。例如,规范化(normalization )几乎总是包括将大写字母转为小写,并且通常涉及删除后缀(例如英语中的 s
或 ing
)。下面是一个示例,使用 to_tsvector
函数 将英语短语解析为 tsvector
。
sql
SELECT * FROM unnest(to_tsvector('english',
'I''m going to make him an offer he can''t refuse. Refusing is not an option.'));
lexeme | positions | weights
--------+-----------+---------
go | {3} | {D}
m | {2} | {D}
make | {5} | {D}
offer | {8} | {D}
option | {17} | {D}
refus | {12,13} | {D,D}
(6 rows)
如上,"I"、"to"或"an"等停用词被删除,因为它们太常见而无法用于搜索。(例如"refuse"和"Refusing"都转换为"refus")这些单词被规范化并简化为其词根。标点符号将被忽略。对于每个单词,记录其在原始短语中的位置(positions)(例如"refus"是文本中的第 12 个和第 13 个单词)和权重(weights)(这对于排名很有用,我们将在稍后讨论)。
在上面的示例中,从单词到词素(lexemes)的转换规则基于 english
搜索配置。使用 simple
搜索配置运行相同的查询会生成 tsvector
,将包含在文本中找到的所有单词:
sql
SELECT * FROM unnest(to_tsvector('simple',
'I''m going to make him an offer he can''t refuse. Refusing is not an option.'));
lexeme | positions | weights
----------+-----------+---------
an | {7,16} | {D,D}
can | {10} | {D}
going | {3} | {D}
he | {9} | {D}
him | {6} | {D}
i | {1} | {D}
is | {14} | {D}
m | {2} | {D}
make | {5} | {D}
not | {15} | {D}
offer | {8} | {D}
option | {17} | {D}
refuse | {12} | {D}
refusing | {13} | {D}
t | {11} | {D}
to | {4} | {D}
(16 rows)
正如您所看到的,"refuse"和"refusing"现在会生成不同的词素(lexemes)。当您的列包含标签(labels)或标记(tags)时, simple
配置特别有用。
PostgreSQL 具有针对一组相当不错的语言的内置配置。您可以通过运行以下命令查看该列表:
sql
SELECT cfgname FROM pg_ts_config;
但值得注意的是,没有 CJK(中文-日语-韩语)的配置,如果您需要使用这些语言创建搜索查询,则值得记住这一点。虽然 simple
配置在实践中对于不受支持的语言应该可以很好地工作,但我不确定这对于 CJK 是否足够。
1.2 tsquery
tsquery
数据类型用于表示规范化查询。 tsquery
包含搜索词,它必须是已经规范化的词素(lexemes),并且可以使用 AND、OR、NOT 和 FOLLOWED BY 运算符组合多个词。像 to_tsquery
、 plainto_tsquery
和 websearch_to_tsquery
这样的函数有助于将用户编写的文本转换为正确的 tsquery
,主要是通过规范文本中出现的单词。
为了了解 tsquery
,现在看一些使用 websearch_to_tsquery
的示例:
sql
SELECT websearch_to_tsquery('english', 'the darth vader');
websearch_to_tsquery
----------------------
'darth' & 'vader'
这是一个逻辑 AND,意味着文档需要同时包含"darth"和"vader"才能匹配。您也可以进行逻辑或:
sql
SELECT websearch_to_tsquery('english', 'darth OR vader');
websearch_to_tsquery
----------------------
'darth' | 'vader'
并且您可以排除单词:
sql
SELECT websearch_to_tsquery('english', 'darth vader -wars');
websearch_to_tsquery
---------------------------
'darth' & 'vader' & !'war'
此外,您还可以表示短语搜索:
sql
SELECT websearch_to_tsquery('english', '"the darth vader son"');
websearch_to_tsquery
------------------------------
'darth' <-> 'vader' <-> 'son'
这意味着:"darth" 后边跟着的是"vader",然后跟着"son"。
但请注意,"the"一词将被忽略,因为根据 english
搜索配置,它是一个停止词。对于这样的短语,这可能是一个问题:
sql
SELECT websearch_to_tsquery('english', '"do or do not, there is no try"');
websearch_to_tsquery
----------------------
'tri'
(1 row)
哎,几乎整个短语都丢失了。使用 simple
配置给出了预期的结果:
sql
SELECT websearch_to_tsquery('simple', '"do or do not, there is no try"');
websearch_to_tsquery
--------------------------------------------------------------------------
'do' <-> 'or' <-> 'do' <-> 'not' <-> 'there' <-> 'is' <-> 'no' <-> 'try'
您可以使用匹配运算符 @@
检查 tsquery
是否与 tsvector
匹配。
sql
SELECT websearch_to_tsquery('english', 'darth vader') @@
to_tsvector('english',
'Darth Vader is my father.');
?column?
----------
t
While the following example doesn't match:
虽然以下示例不匹配:
sql
SELECT websearch_to_tsquery('english', 'darth vader -father') @@
to_tsvector('english',
'Darth Vader is my father.');
?column?
----------
f
1.3 GIN
现在我们已经了解了 tsvector
和 tsquery
的工作原理,让我们看看另一个关键构建块:加快查询的GIN 索引。 GIN 代表广义倒排索引(Generalized Inverted Index )。 GIN 设计用于处理要索引的项是复合值的情况,并且索引要处理的查询需要搜索复合项中出现的元素值。这意味着 GIN 不仅仅可以用于文本搜索,还可以用于索引 JSON 查询。
您可以在一组列上创建 GIN 索引,也可以首先创建 tsvector
类型的列,以包含所有可搜索的列。像这样:
sql
ALTER TABLE movies ADD search tsvector GENERATED ALWAYS AS
(to_tsvector('english', Title) || ' ' ||
to_tsvector('english', Plot) || ' ' ||
to_tsvector('simple', Director) || ' ' ||
to_tsvector('simple', Genre) || ' ' ||
to_tsvector('simple', Origin) || ' ' ||
to_tsvector('simple', Casting)
) STORED;
然后创建实际GIN索引:
sql
CREATE INDEX idx_search ON movies USING GIN(search);
您现在可以执行简单的测试搜索,如下所示:
sql
SELECT title FROM movies WHERE search @@ websearch_to_tsquery('english','darth vader');
title
--------------------------------------------------
Star Wars Episode IV: A New Hope (aka Star Wars)
Return of the Jedi
Star Wars: Episode III -- Revenge of the Sith
(3 rows)
要查看索引的效果,您可以比较使用和不使用索引的上述查询的时间。在我的计算机上,GIN 索引将其时间从 200 多毫秒缩短到了大约 4 毫秒。
1.4 ts_rank
到目前为止,我们已经了解了 ts_vector
和 ts_query
如何匹配搜索查询。然而,为了获得良好的搜索体验,首先显示最佳结果非常重要 - 这意味着结果需要按相关性(relevancy)排序。
在官方文档有如下内容:
PostgreSQL provides two predefined ranking functions, which take into account lexical, proximity, and structural information; that is, they consider how often the query terms appear in the document, how close together the terms are in the document, and how important is the part of the document where they occur. However, the concept of relevancy is vague and very application-specific. Different applications might require additional information for ranking, e.g., document modification time. The built-in ranking functions are only examples. You can write your own ranking functions and/or combine their results with additional factors to fit your specific needs.
PostgreSQL 提供了两个预定义的排名函数,它们考虑了词汇(lexical)、邻近度(proximity)和结构信息;也就是说,他们考虑查询术语在文档中出现的频率、这些术语在文档中的接近程度以及它们出现的文档部分的重要性。然而,相关性的概念是模糊的并且非常特定于应用程序。不同的应用程序可能需要额外的信息进行排名,例如文档修改时间。内置排名功能仅是示例。您可以编写自己的排名函数和/或将其结果与其他因素结合起来以满足您的特定需求。
提到的两个排名函数是 ts_rank
和 ts_rank_cd
。它们之间的区别在于,虽然它们都考虑了术语的频率,但 ts_rank_cd
还考虑了匹配词素(lexemes )彼此的接近度。
要在查询中使用它们,您可以执行以下操作:
sql
SELECT title,
ts_rank(search, websearch_to_tsquery('english', 'darth vader')) rank
FROM movies
WHERE search @@ websearch_to_tsquery('english','darth vader')
ORDER BY rank DESC
LIMIT 10;
title | rank
--------------------------------------------------+-------------
The Empire Strikes Back | 0.26263964
Star Wars Episode IV: A New Hope (aka Star Wars) | 0.18902963
Star Wars: Episode III -- Revenge of the Sith | 0.10292397
Rogue One: A Star Wars Story (film) | 0.10049681
Return of the Jedi | 0.09910346
American Honey | 0.09910322
关于 ts_rank
需要注意的一件事是它需要访问每个结果的 search
列。这意味着如果 WHERE
条件匹配很多行,PostgreSQL 需要访问所有行才能进行排名,这可能会很慢。举例来说,上述查询在我的计算机上会在 5-7 毫秒内返回。如果我修改查询来搜索 darth OR vader
,它会在大约 80 毫秒内返回,因为现在有超过 1000 个匹配结果需要排名和排序。
2. 相关性调整(Relevancy tuning)
虽然基于词频的相关性是搜索排序的一个很好的默认值,但数据通常包含比简单的频率更相关的重要指标。
以下是电影数据集的一些示例:
- 标题中的匹配应比描述或情节中的匹配具有更高的重要性。
- 可以根据收视率和/或收到的票数来推广更受欢迎的电影。
- 考虑到用户的偏好,某些类别可以得到更多提升。例如,如果特定用户喜欢喜剧,则可以给予这些电影更高的优先级。
- 对搜索结果进行排名时,较新的标题可以被认为比非常旧的标题更相关。
这就是为什么专用搜索引擎通常提供使用不同列或字段来影响排名的方法。以下是来自 Elastic
、Typesense
和 Meilisearch
的示例调整指南。
如果您想要直观地演示相关性调整的影响,这里有一个 4 分钟的快速视频:
2.1 数字、日期和精确值的提升 (Numeric, date, and exact value boosters)
虽然 Postgres 不直接支持基于其他列的提升,但排名最终只是一个排序表达式,因此您可以向其中添加自己的排名因子。
例如,如果你想增加投票数,你可以这样做:
sql
SELECT title,
ts_rank(search, websearch_to_tsquery('english', 'jedi'))
-- numeric booster example
+ log(NumberOfVotes)*0.01
FROM movies
WHERE search @@ websearch_to_tsquery('english','jedi')
ORDER BY rank DESC LIMIT 10;
对数的作用是平滑影响,0.01 因子使提升(booster )达到与排名分数相当的规模。
您还可以设计更复杂的提升器,例如通过评级进行提升,但前提是排名具有一定的票数。为此,您可以创建一个如下函数:
sql
create function numericBooster(rating numeric, votes numeric, voteThreshold numeric)
returns numeric as $$
select case when votes < voteThreshold then 0 else rating end;
$$ language sql;
并像这样使用它:
sql
SELECT title,
ts_rank(search, websearch_to_tsquery('english', 'jedi'))
-- numeric booster example
+ numericBooster(Rating, NumberOfVotes, 100)*0.005
FROM movies
WHERE search @@ websearch_to_tsquery('english','jedi')
ORDER BY rank DESC LIMIT 10;
再举一个例子。假设我们想要提高喜剧的排名。您可以创建一个如下所示的 valueBooster
函数:
sql
create function valueBooster (col text, val text, factor integer)
returns integer as $$
select case when col = val then factor else 0 end;
$$ language sql;
如果列与特定值匹配,则该函数返回一个因子,不匹配则返回 0。在如下查询中使用它:
sql
SELECT title, genre,
ts_rank(search, websearch_to_tsquery('english', 'jedi'))
-- value booster example
+ valueBooster(Genre, 'comedy', 0.05) rank
FROM movies
WHERE search @@ websearch_to_tsquery('english','jedi') ORDER BY rank DESC LIMIT 10;
title | genre | rank
--------------------------------------------------+------------------------------------+---------------------
The Men Who Stare at Goats | comedy | 0.1107927106320858
Clerks | comedy | 0.1107927106320858
Star Wars: The Clone Wars | animation | 0.09513916820287704
Star Wars: Episode I -- The Phantom Menace 3D | sci-fi | 0.09471701085567474
Star Wars: Episode I -- The Phantom Menace | space opera | 0.09471701085567474
Star Wars: Episode II -- Attack of the Clones | science fiction | 0.09285612404346466
Star Wars: Episode III -- Revenge of the Sith | science fiction, action | 0.09285612404346466
Star Wars: The Last Jedi | action, adventure, fantasy, sci-fi | 0.0889768898487091
Return of the Jedi | science fiction | 0.07599088549613953
Star Wars Episode IV: A New Hope (aka Star Wars) | science fiction | 0.07599088549613953
(10 rows)
2.2 列权重(Column weights)
还记得我们讨论过 tsvector
词素以及它们可以附加权重吗? Postgres 支持 4 个权重,分别为 A、B、C 和 D。A 是最大的权重,D 是最小的默认权重。您可以通过 setweight
函数控制权重,在构建 tsvector
列时通常会调用该函数:
sql
ALTER TABLE movies ADD search tsvector GENERATED ALWAYS AS
(setweight(to_tsvector('english', Title), 'A') || ' ' ||
to_tsvector('english', Plot) || ' ' ||
to_tsvector('simple', Director) || ' ' ||
to_tsvector('simple', Genre) || ' ' ||
to_tsvector('simple', Origin) || ' ' ||
to_tsvector('simple', Casting)
) STORED;
让我们看看这样做的效果。如果没有 setweight
,搜索 jedi
将返回:
sql
SELECT title, ts_rank(search, websearch_to_tsquery('english', 'jedi')) rank
FROM movies
WHERE search @@ websearch_to_tsquery('english','jedi')
ORDER BY rank DESC;
title | rank
--------------------------------------------------+-------------
Star Wars: The Clone Wars | 0.09513917
Star Wars: Episode I -- The Phantom Menace | 0.09471701
Star Wars: Episode I -- The Phantom Menace 3D | 0.09471701
Star Wars: Episode III -- Revenge of the Sith | 0.092856124
Star Wars: Episode II -- Attack of the Clones | 0.092856124
Star Wars: The Last Jedi | 0.08897689
Return of the Jedi | 0.075990885
Star Wars Episode IV: A New Hope (aka Star Wars) | 0.075990885
Clerks | 0.06079271
The Empire Strikes Back | 0.06079271
The Men Who Stare at Goats | 0.06079271
How to Deal | 0.06079271
(12 rows)
如果标题列上有 setweight
:
sql
SELECT title, ts_rank(search, websearch_to_tsquery('english', 'jedi')) rank
FROM movies
WHERE search @@ websearch_to_tsquery('english','jedi')
ORDER BY rank DESC;
title | rank
--------------------------------------------------+-------------
Star Wars: The Last Jedi | 0.6361112
Return of the Jedi | 0.6231253
Star Wars: The Clone Wars | 0.09513917
Star Wars: Episode I -- The Phantom Menace | 0.09471701
Star Wars: Episode I -- The Phantom Menace 3D | 0.09471701
Star Wars: Episode III -- Revenge of the Sith | 0.092856124
Star Wars: Episode II -- Attack of the Clones | 0.092856124
Star Wars Episode IV: A New Hope (aka Star Wars) | 0.075990885
The Empire Strikes Back | 0.06079271
Clerks | 0.06079271
The Men Who Stare at Goats | 0.06079271
How to Deal | 0.06079271
(12 rows)
请注意,名称中带有"jedi"的电影如何跃居列表顶部,并且排名也有所上升。
值得指出的是,只有四个权重"类"有一定的限制,并且在计算 tsvector
时需要应用它们。
3. 拼写错误/模糊搜索(Typo-tolerance / fuzzy search)
使用 tsvector
和 tsquery
时,PostgreSQL 不直接支持模糊搜索或拼写错误。然而,假设拼写错误出现在查询部分,我们可以实现以下想法:
- 将内容中的所有词素(lexemes)索引到单独的表中
- 对于查询中的每个单词,使用相似度或编辑距离在此表中进行搜索
- 修改查询以包含找到的任何单词
- 执行搜索
下面是它的工作原理。首先,使用 ts_stats
获取物化视图中的所有单词:
sql
CREATE MATERIALIZED VIEW unique_lexeme AS
SELECT word FROM ts_stat('SELECT search FROM movies');
现在,对于查询中的每个单词,检查它是否在 unique_lexeme
视图中。如果不是,请在该视图中进行模糊搜索以查找可能的拼写错误:
sql
SELECT * FROM unique_lexeme
WHERE levenshtein_less_equal(word, 'pregant', 2) < 2;
word
----------
premant
pregrant
pregnant
paegant
在上面我们使用 Levenshtein 距离,因为这是 Elasticsearch 等搜索引擎用于模糊搜索的距离。
获得候选单词列表后,您需要调整查询以将它们全部包含在内。
4. 多面搜索(Faceted search)
分面搜索尤其在电子商务网站上很受欢迎,因为它可以帮助客户迭代地缩小搜索范围。以下是来自 amazon.com 的示例:
亚马逊上的多面搜索
上述可以通过手动定义类别,然后将其作为 WHERE
条件添加到搜索中来实现。另一种方法是根据现有数据通过算法创建类别。例如,您可以使用以下内容创建"Decade"构面:
sql
SELECT ReleaseYear/10*10 decade, count(Title) cnt FROM movies
WHERE search @@ websearch_to_tsquery('english','star wars')
GROUP BY decade ORDER BY cnt DESC;
decade | cnt
--------+-----
2000 | 39
2010 | 31
1990 | 29
1950 | 28
1940 | 26
1980 | 22
1930 | 13
1960 | 11
1970 | 7
1910 | 3
1920 | 3
(11 rows)
这还提供了每个十年的匹配数,您可以将其显示在括号中。
如果您想在单个查询中获取多个方面,可以将它们组合起来,例如使用 CTE:
sql
WITH releaseYearFacets AS (
SELECT 'Decade' facet, (ReleaseYear/10*10)::text val, count(Title) cnt
FROM movies
WHERE search @@ websearch_to_tsquery('english','star wars')
GROUP BY val ORDER BY cnt DESC),
genreFacets AS (
SELECT 'Genre' facet, Genre val, count(Title) cnt FROM movies
WHERE search @@ websearch_to_tsquery('english','star wars')
GROUP BY val ORDER BY cnt DESC LIMIT 5)
SELECT * FROM releaseYearFacets UNION SELECT * FROM genreFacets;
facet | val | cnt
--------+---------+-----
Decade | 1910 | 3
Decade | 1920 | 3
Decade | 1930 | 13
Decade | 1940 | 26
Decade | 1950 | 28
Decade | 1960 | 11
Decade | 1970 | 7
Decade | 1980 | 22
Decade | 1990 | 29
Decade | 2000 | 39
Decade | 2010 | 31
Genre | comedy | 21
Genre | drama | 35
Genre | musical | 9
Genre | unknown | 13
Genre | war | 15
(16 rows)
上面的方法在中小型数据集上应该可以很好地工作,但是在非常大的数据集上它可能会变得很慢。
5. 结论
我们已经了解了 PostgreSQL 全文搜索原语,以及如何将它们组合起来创建一个非常先进的全文搜索引擎,该引擎也恰好支持连接和 ACID 事务等功能。换句话说,它具有其他搜索引擎通常不具备的功能。
还有更高级的搜索主题值得详细介绍:
- 建议/自动完成(suggesters / auto-complete )
- 精确短语匹配 (exact phrase matching )
- 与 pg-vector 结合的混合搜索(语义 + 关键字)
这些都值得写一篇博文(即将到来!),但现在您应该对它们有一个直观的感觉:它们很可能使用 PostgreSQL,但它们要求您进行组合原语的工作,并且在某些情况下在非常大的数据集上,性能可能会受到影响。
在第 2 部分中,我们将与 Elasticsearch 进行详细比较,以回答何时值得在 PostgreSQL 中实施搜索以及将 Elasticsearch 添加到基础设施并同步数据的问题。如果您想在本文发布时收到通知,您可以在 Twitter 上关注我们或加入我们的 Discord。
Written by Tudor Golubenco
Published on July 12, 2023