當(dāng)構(gòu)建一個(gè)Web應(yīng)用時(shí),經(jīng)常被要求加上搜索功能。其實(shí)有時(shí)候我們也不知道我要搜索個(gè)啥,反正就是要有這個(gè)功能。搜索確實(shí)很重要的特性,這也是為什么像Elasticsearch和Solr這樣基于Lucene的數(shù)據(jù)庫越來越流行。這兩個(gè)是好用,但是在構(gòu)建Web應(yīng)用時(shí),有時(shí)候感覺像是殺雞用牛刀。所以我們需要選擇輕量好用的東西來搜索。
如何定義足夠好用? 足夠好用的搜索引擎需要有以下幾點(diǎn)特征:
- Stemming 關(guān)鍵詞,詞干
- Ranking/Boost 排名和權(quán)重分配
- 多語言支持
- 錯(cuò)誤糾正/模糊查詢
- 口音支持 類似我們的一二三四聲
非常幸運(yùn),PostgreSql支持以上所有特征。
這篇文章旨在幫助以下的人群:
- 用了PG數(shù)據(jù)庫,不想再加一個(gè)搜索引擎
- 用了其他數(shù)據(jù)庫,想有一個(gè)更好的全文檢索體驗(yàn)
在這片文章,我們將基于以下表和數(shù)據(jù)逐步的闡述PG中的全文搜索功能。
CREATE TABLE author(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE post(
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id INT NOT NULL references author(id)
);
CREATE TABLE tag(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE posts_tags(
post_id INT NOT NULL references post(id),
tag_id INT NOT NULL references tag(id)
);
INSERT INTO author (id, name)
VALUES (1, 'Pete Graham'),
(2, 'Rachid Belaid'),
(3, 'Robert Berry');
INSERT INTO tag (id, name)
VALUES (1, 'scifi'),
(2, 'politics'),
(3, 'science');
INSERT INTO post (id, title, content, author_id)
VALUES (1, 'Endangered species',
'Pandas are an endangered species', 1 ),
(2, 'Freedom of Speech',
'Freedom of speech is a necessary right', 2),
(3, 'Star Wars vs Star Trek',
'Few words from a big fan', 3);
INSERT INTO posts_tags (post_id, tag_id)
VALUES (1, 3),
(2, 2),
(3, 1);
以上是一個(gè)比較典型的Blog類型的數(shù)據(jù)庫。有post, post有title和content。post通過外鍵與author建立關(guān)系。post也有自己的多個(gè)tag。
什么是Full-Text Search全文檢索
首先讓我們看看定義
In text retrieval, full-text search refers to techniques for searching a single computer-stored document or a collection in a full-text database. The full-text search is distinguished from searches based on metadata or on parts of the original texts represented in databases.
-- Wikipedia
這段定義指出了一個(gè)重要的概念-document。當(dāng)你執(zhí)行一個(gè)查詢的時(shí)候,你實(shí)際上是為了尋找一些具有實(shí)際含義的實(shí)體。那些就是documents。PostgreSQL文檔解釋的有點(diǎn)耐人尋味
A document is the unit of searching in a full-text search system; for example, a magazine article or email message.
-- Postgres documentation
document 能夠跨越多個(gè)表,它代表的是一個(gè)邏輯上的獨(dú)立個(gè)體。
建立我們的document
在上一節(jié)我們介紹了document的概念,document和我們的表結(jié)構(gòu)沒關(guān)系,它只和數(shù)據(jù)有關(guān)系。以我們的數(shù)據(jù)庫為例,一個(gè)document可以有以下幾個(gè)field組成:
post.titlepost.content- 該
post的author.name - 所有與該
post相關(guān)的tag.name
通過以下查詢,我們可以建立一個(gè)document的二維表數(shù)據(jù)
SELECT post.title || ' ' ||
post.content || ' ' ||
author.name || ' ' ||
coalesce((string_agg(tag.name, ' ')), '') as document
FROM post
JOIN author ON author.id = post.author_id
JOIN posts_tags ON posts_tags.post_id = posts_tags.tag_id
JOIN tag ON tag.id = posts_tags.tag_id
GROUP BY post.id, author.id;
document
--------------------------------------------------
Endangered species Pandas are an endangered species Pete Graham politics
Freedom of Speech Freedom of speech is a necessary right Rachid Belaid politics
Star Wars vs Star Trek Few words from a big fan Robert Berry politics
在查詢語句中,我們通過post和author進(jìn)行group, 通過string_agg對tag.name進(jìn)行聚合。同時(shí)我們用了coalesce來防止聚合之后tag出現(xiàn)null。
此時(shí),我們的document是一個(gè)簡單的長字符串,當(dāng)然對我們起不到什么作用。我們需要通過to_tsvector()對這個(gè)長字符串操作一頓。
SELECT to_tsvector(post.title) ||
to_tsvector(post.content) ||
to_tsvector(author.name) ||
to_tsvector(coalesce((string_agg(tag.name, ' ')), '')) as document
FROM post
JOIN author ON author.id = post.author_id
JOIN posts_tags ON posts_tags.post_id = posts_tags.tag_id
JOIN tag ON tag.id = posts_tags.tag_id
GROUP BY post.id, author.id;
document
--------------------------------------------------
'endang':1,6 'graham':9 'panda':3 'pete':8 'polit':10 'speci':2,7
'belaid':12 'freedom':1,4 'necessari':9 'polit':13 'rachid':11 'right':10 'speech':3,6
'berri':13 'big':10 'fan':11 'polit':14 'robert':12 'star':1,4 'trek':5 'vs':3 'war':2 'word':7
(3 rows)
這個(gè)查詢以tsvector的形式回傳我們的document,這種格式特別適合來做full-text search。讓我們在試一下將一個(gè)簡單的字符串轉(zhuǎn)化成tsvector類型
SELECT to_tsvector('Try not to become a man of success, but rather try to become a man of value');
query will return the following result:
to_tsvector
----------------------------------------------------------------------
'becom':4,13 'man':6,15 'rather':10 'success':8 'tri':1,11 'valu':17
(1 row)
見證奇跡的時(shí)刻到啦。 首先有一些單詞是來自于原句子的,但是有一些單詞變化了(try變成了tri)。同時(shí)所有單詞后面都帶了數(shù)字,為啥子?
一個(gè)tsvector是由一組不同的詞元組成。詞元是分詞之后的變種,就是被操作過了。操作的專業(yè)名詞叫做normalization。這波操作主包含大小寫轉(zhuǎn)化,后綴移除等等。搜索意味著要面對多重選項(xiàng),減少一些選項(xiàng)便于引擎去搜搜。單詞后面帶的數(shù)字表示詞元原來在的位置。比如man就是在6和15。您可以自個(gè)兒數(shù)數(shù)對不對。
默認(rèn)情況下,Postgres在to_tsvector中用的是english的配置。它會直接忽略非英文的單詞。
Querying
現(xiàn)在,我們知道如何構(gòu)建一個(gè)文檔。但是我們的目的是為了搜索。我們用@@來對tsvector的數(shù)據(jù)進(jìn)行搜索。官方文檔走一波。讓我們看看一些例子:
> select to_tsvector('If you can dream it, you can do it') @@ 'dream';
?column?
----------
f
(1 row)
> select to_tsvector('It''s kind of fun to do the impossible') @@ 'impossible';
?column?
----------
t
(1 row)
上面主要是簡單的查一下是否存在這個(gè)關(guān)鍵詞。接下去的例子可以展示強(qiáng)制類型轉(zhuǎn)化和to_tsquery這個(gè)函數(shù)的區(qū)別。
SELECT 'impossible'::tsquery, to_tsquery('impossible');
tsquery | to_tsquery
--------------+------------
'impossible' | 'imposs'
(1 row)
to_tsquery函數(shù)用于寫一些簡單的搜索語句。支持布爾操作符&(AND), |(OR), !(NOT)。在to_tsquery也可以用進(jìn)行表達(dá)式優(yōu)先級的調(diào)整。
> SELECT to_tsvector('If the facts don't fit the theory, change the facts') @@ to_tsquery('! fact');
?column?
----------
f
(1 row)
> SELECT to_tsvector('If the facts don''t fit the theory, change the facts') @@ to_tsquery('theory & !fact');
?column?
----------
f
(1 row)
> SELECT to_tsvector('If the facts don''t fit the theory, change the facts.') @@ to_tsquery('fiction | theory');
?column?
----------
t
(1 row)
同時(shí)可以用:*進(jìn)行startwith搜索。
> SELECT to_tsvector('If the facts don''t fit the theory, change the facts.') @@ to_tsquery('theo:*');
?column?
----------
t
(1 row)
現(xiàn)在我們知道如何做一個(gè)full-text search。我們回到我們的數(shù)據(jù)庫嘗試對我們文檔進(jìn)行查詢。
SELECT pid, p_title
FROM (SELECT post.id as pid,
post.title as p_title,
to_tsvector(post.title) ||
to_tsvector(post.content) ||
to_tsvector(author.name) ||
to_tsvector(coalesce(string_agg(tag.name, ' '))) as document
FROM post
JOIN author ON author.id = post.author_id
JOIN posts_tags ON posts_tags.post_id = posts_tags.tag_id
JOIN tag ON tag.id = posts_tags.tag_id
GROUP BY post.id, author.id) p_search
WHERE p_search.document @@ to_tsquery('Endangered & Species');
pid | p_title
-----+--------------------
1 | Endangered species
語言支持
Posgres內(nèi)置了一些語言: Danish, Dutch, English, Finnish, French, German, Hungarian, Italian, Norwegian, Portuguese, Romanian, Russian, Spanish, Swedish, Turkish.(沒看到中文,這段就不說了)
Accented Character
音調(diào)的不同導(dǎo)致意思也不同,(沒有內(nèi)置中文,這段就不說了)
Ranking
構(gòu)建一個(gè)搜索引擎,您會希望你的查詢結(jié)果是根據(jù)相關(guān)度來排序的。與documents的排名相關(guān)的指標(biāo)在下面這段引用中解釋的很清楚。
Ranking attempts to measure how relevant documents are to a particular query, so that when there are many matches the most relevant ones can be shown first. 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.
-- PostgreSQL documentation
在PostgreSQL中提供了兩個(gè)函數(shù)來進(jìn)行相關(guān)度調(diào)整。分別是ts_rank() 和setweight()
函數(shù)setweight允許給予tsvector一個(gè)權(quán)重,權(quán)重的值可以是A,B,C,D。
SELECT pid, p_title
FROM (SELECT post.id as pid,
post.title as p_title,
setweight(to_tsvector(post.title), 'A') ||
setweight(to_tsvector(post.content), 'B') ||
setweight(to_tsvector('simple', author.name), 'C') ||
setweight(to_tsvector('simple', coalesce(string_agg(tag.name, ' '))), 'B') as document
FROM post
JOIN author ON author.id = post.author_id
JOIN posts_tags ON posts_tags.post_id = posts_tags.tag_id
JOIN tag ON tag.id = posts_tags.tag_id
GROUP BY post.id, author.id) p_search
WHERE p_search.document @@ to_tsquery('english', 'Endangered & Species')
ORDER BY ts_rank(p_search.document, to_tsquery('english', 'Endangered & Species')) DESC;
在上面這個(gè)查詢中,我們給予不同的tsvector不同的權(quán)重,A權(quán)重的post.title比B權(quán)重的post.content更加重要。依此類推。這意味著如果我們查一個(gè)詞Alice。Adocument在標(biāo)題中有Alice,Bdocument在內(nèi)容中有Alice。那A文檔會在前。
ts_rank函數(shù)會回傳tsquery與tsvector的相關(guān)度,用一個(gè)小數(shù)來表示。
SELECT ts_rank(to_tsvector('This is an example of document'),
to_tsquery('example | document')) as relevancy;
relevancy
-----------
0.0607927
(1 row)
SELECT ts_rank(to_tsvector('This is an example of document'),
to_tsquery('example ')) as relevancy;
relevancy
-----------
0.0607927
(1 row)
SELECT ts_rank(to_tsvector('This is an example of document'),
to_tsquery('example | unkown')) as relevancy;
relevancy
-----------
0.0303964
(1 row)
SELECT ts_rank(to_tsvector('This is an example of document'),
to_tsquery('example & document')) as relevancy;
relevancy
-----------
0.0985009
(1 row)
SELECT ts_rank(to_tsvector('This is an example of document'),
to_tsquery('example & unknown')) as relevancy;
relevancy
-----------
1e-20
(1 row)
但是,相關(guān)度這個(gè)概念實(shí)際上有點(diǎn)模糊且與實(shí)際APP需求緊密連接的。不同的APP需要不同的排名方式。你可以自己加上一些參數(shù)上去。比如你加一個(gè)根據(jù)作者年齡的排序,你可以ORDER BY ts_rank(p_search.document, to_tsquery('english', 'Endangered & Species')) * author.age DESC。最后怎么算還是由你自己決定。
(Optimization and Indexing)優(yōu)化和索引
優(yōu)化搜索非常簡單明了。因?yàn)镻ostgreSQL支持基于Index的函數(shù)。所以你可以創(chuàng)建一個(gè)GIN index作用于tsvector函數(shù)。
CREATE INDEX idx_fts_post ON post
USING gin((setweight(to_tsvector(language::regconfig, title),'A') ||
setweight(to_tsvector(language::regconfig, content), 'B')));
-- If this throws an IMMUTABLE error then you can use this workaround
CREATE OR REPLACE FUNCTION gin_fts_fct(title text, content text, language text)
RETURNS tsvector
AS
$BODY$
SELECT setweight(to_tsvector($3::regconfig, $1), 'A') || setweight(to_tsvector($3::regconfig, $1), 'B');
$BODY$
LANGUAGE sql
IMMUTABLE;
CREATE INDEX idx_fts_post ON post USING gin(gin_fts_fct(title, content, language));
選擇GIN還是GiST索引?這要根據(jù)你的讀寫量以及數(shù)據(jù)量來決定
As a rule of thumb, GIN indexes are best for static data because lookups are faster. For dynamic data, GiST indexes are faster to update. Specifically, GiST indexes are very good for dynamic data and fast if the number of unique words (lexemes) is under 100,000 while GIN indexes will handle 100,000+ lexemes better but are slower to update.
-- Postgres doc : Chap 12 Full Text Search
在這個(gè)例子中,我們將采用GIN。但是你可以根據(jù)你的情況決定使用什么數(shù)據(jù)。在document中,我們還有一個(gè)問題。document是跨表的,且各表字段擁有不同權(quán)重。為了得到更好的性能,我們通過triggers或者materialized view的方式進(jìn)行數(shù)據(jù)重組。不過您不需要總是重組數(shù)據(jù)。在一些情況下,您可以加一些基于index的函數(shù)。還可以通過tsvector_update_trigger(...)或者tsvector_update_trigger_column(...)來重組數(shù)據(jù)。查看文檔來獲取更多細(xì)節(jié)。另外如果可以接受短暫延遲的話。用Materialized View是一個(gè)不錯(cuò)的選擇。
CREATE MATERIALIZED VIEW search_index AS
SELECT post.id,
post.title,
setweight(to_tsvector(post.language::regconfig, post.title), 'A') ||
setweight(to_tsvector(post.language::regconfig, post.content), 'B') ||
setweight(to_tsvector('simple', author.name), 'C') ||
setweight(to_tsvector('simple', coalesce(string_agg(tag.name, ' '))), 'A') as document
FROM post
JOIN author ON author.id = post.author_id
JOIN posts_tags ON posts_tags.post_id = posts_tags.tag_id
JOIN tag ON tag.id = posts_tags.tag_id
GROUP BY post.id, author.id
然后可以通過REFRESH MATERIALIZED VIEW search_index;的方式來重建索引。
首先我們建立索引
CREATE INDEX idx_fts_search ON search_index USING gin(document);
然后查詢也會變得更加簡單
SELECT id as post_id, title
FROM search_index
WHERE document @@ to_tsquery('english', 'Endangered & Species')
ORDER BY ts_rank(p_search.document, to_tsquery('english', 'Endangered & Species')) DESC;
Mispelling
英文沒啥用, 中文的分詞不知道實(shí)現(xiàn)了pg_trgm沒有。。
總結(jié)
Posgres全文搜索功能還是相當(dāng)棒和快的。有了它,你就不用裝其他的搜索引擎了。Posgres像不像一顆幸福的子彈。。。也許不是,如果您的業(yè)務(wù)圍著搜索轉(zhuǎn)啊轉(zhuǎn)的話。
有一些功能我沒講,但是您基本上也用不到。以我個(gè)人而言,我希望Posgres的全文搜索還有一些功能能夠加進(jìn)來。
- 更多的內(nèi)置語言
- 和Lucene搞基
- 更好的排名和權(quán)重分配解決方案
- 模糊查詢再叼一點(diǎn)
總的來說,Posgres再全文搜索上肯定是沒有ElasticSearch和SOLR那么先進(jìn)。。。