Loading...
Loading...
- 最后编辑:2025/10/29 07:40:42 -
Loading...
1个月前
1个月前
“全文搜索”这个功能,我在很早以前就想做了,奈何实力有限,网上可用资料也不多,无法找到有效的解决方案,于是便不了了之。
这个月中旬时,和实验室的一位前端师兄聊天时探讨过这个功能的实现思路,他向我介绍了他博客的方案,但因为他的博客是基于Astro框架开发的,正常来说,Astro更适合搭建静态网站,所以他的实现方案是在每次上传新的博客文章时,重新编译一次项目,期间将文章解析成一份json文件,这样在搜索时,网站就会直接在这份json文件中进行查找——很显然,这并不适合我的博客,因为我的博客并不是纯静态网站,它拥有一个完整的前后端结构,博客文章也是直接存在数据库中的,并不直接保存在项目目录中,因此我没有采用这个做法。
但这次讨论也并非没有收获,它让我开始重新将全文搜索的开发计划提上了日程。
前几天在刷B站时,偶然间看到了一个介绍数据库索引类型的视频,其中有个叫做倒排索引的概念引起了我的注意,因为它天然适合用来做全文搜索的实现方案,并且我使用的Postrgesql刚好也支持这种索引类型。
于是我立刻去了解了相关内容,并很快实现了一个简单的技术原型,整个过程甚至只用了一个晚上!
下面我们详细来讲一下这个功能的技术要点。
tsvector和tsquery在postgresql中,有两个特殊的存在:tsvector和tsquery,其中tsvector相当于数据库中的一个数据类型,其中储存的是文本中每个词语所处的位置,这么说可能有点抽象,下面我们用一个实际的例子来进一步解释一下:
假设我有一个句子:The quick brown fox jumps over the lazy dog,接着将它使用to_tsvector()函数解析:
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');其中第一个参数表示所解析的文本语言是英语,第二个参数就是我们需要解析的文本。
然后我们就会得到一个tsvector类型的数据:
'the':1,7 'quick':2 'brown':3 'fox':4 'jump':5 'over':6 'lazi':8 'dog':9这看起来像是一个JS对象,那我们接下来就把他当成对象({ attribute: value })来进行类比解释。
The和第七个单词the才会被算作同一个词元)因此,这个数据其实就是一段由文本中所有词元的位置所组成的向量,能够用一种近似数学的方式表示整篇文章的结构。
而tsquery则表示一种查询条件,举个简单例子就明白了:
SELECT to_tsvector('english', 'The quick brown fox') @@ to_tsquery('english', 'quick & fox');执行这行SQL语句,控制台将会返回一个true,这表示查询成功了。
但是它究竟查了什么呢?
一段一段来看,前面首先先使用to_tsvector将一段文本切分为了文本向量,它的结果应该长这样:
'the':1 'quick':2 'brown':3 'fox':4接着是to_tsquery,它是一个用来表达执行一个tsquery条件,在这个SQL语句中,它被传入的判断条件是quick & fox,其中的&表示与,也就是符号两边的文本必须同时存在,这个条件才能成立,否则就为假。
类似的,tsquery还支持一些常见的判断条件,比如与(&)或(|)非(!),它们的功能与语法与大多数的编程语言类似,且写法也与上文例子中的类似,下文不再赘述。
最后,前后两个函数通过一个@@操作符连接了起来,在Postgresql中,这是一种专门用于全文搜索的匹配操作符,作用是:判断 tsvector 是否匹配 tsquery,即将tsvector中的值去与tsquery的成立条件做判断,看看是否成立,如果成立,就说明这段文本中含有SQL语句要查找的内容,那么整条语句的结果就为真,自然就会返回一个true了。
解释完这两个核心概念后,你大概能够猜到这个全文搜索功能是怎么实现的了——没错,就是利用tsvector和tsquery,去匹配博客内容的词元,然后再进行查找。
在实现这个功能前,我们需要对现在已有的博客进行一些处理。
首先,由于postgresql的to_tsvector()函数默认不支持处理中文文本,所以我们需要把博客内容预先处理成tsvector类型的向量并储存起来,接着修改博客发布接口,为后续上传的文章也自动生成对应的向量:
var Segment = require('segment');
module.exports = {
async CreatePost(req, res) {
// 其他无关上下文(省略)
// 获取文章内容
const content = req.body.content;
// 初始化分词器
var segment = new Segment();
segment.useDefault();
const segmentResult = segment.doSegment(
content,
{
simple: true
});
// 过滤无意义的符号、数字等,它们不参与全文搜索
const validWords = segmentResult.filter(word => {
return word.trim().length
处理完文本后,向量数据会被存进数据表中一个专门存储tsvector数据类型的列中,便于后续使用。
准备完这个前置条件后,我们就可以开始着手设计全文搜索的后端接口了。
根据上面的内容,我们可以写出一个精简化的技术原型:
SELECT * FROM "Post" WHERE "tsvector" @@ '中文'::tsquery;这是一个用于实现全文搜索功能的SQL语句,但乍一看,似乎和刚刚说的东西不太一样,没关系,我们还是一段一段来看。
前面的SELECT * FROM "Post"不必说,就是最基本的SQL查询语句,重点是后面的where条件:WHERE "tsvector" @@ '中文'::tsquery;
首先,我们传入了一个字符串:"tsvector",它表示数据表中的一个列,也就是上文中提到的:”专门存储tsvector数据类型的列“。
接着,后面是一个@@操作符,那么根据上文中对于这个操作符的解释可以知道,后面的'中文'::tsquery显然是一个tsquery类型的数据,而事实也正是如此:::tsquery会将它前面的字面量强制转换为tsquery类型。
这里需要注意的是,因为postgresql不能处理中文文本,所以这里的::tsquery实际上也不能对这个中文字面量做什么处理,它只是把整个字符串当作一个字面词位,换句话说,它等价于:
to_tsquery('simple', '中文');而不是:
to_tsquery('chinese', '中文');但是为了将它作为一个tsquery条件与前面的tsvector一起查询,这是目前我们最好的方案。
当我们执行这条SQL语句后,数据库就会查询表中所有包含“中文”这个词的记录,并将它返回。
如果我们需要查询多个词,还可以将它进一步扩展:
SELECT * FROM "Post" WHERE "tsvector" @@ '中文 & 的 & 文本'::tsquery;
SELECT * FROM "Post" WHERE "tsvector" @@ '中文 | 英文'::tsquery;
# etc...根据查询条件的不同,我们可以实现各种不同的查询。
到这里,我们就已经利用postgresql实现了一个简单的全文搜索的功能,但是如果我们将它真正的应用到项目中,就会发现在某些情况下,搜索的结果可能会出现各种意料之外的情况,比如出现一些看似完全不相关的结果,或是无法查到我们想要的文章等,这就说明目前这个方案还不够成熟,需要进一步的优化。
上文提到的代码看似合理,实则有非常多容易忽略的缺陷,举个最简单的例子:
SELECT * FROM "Post" WHERE "tsvector" @@ '中文 & 文本'::tsquery;在这个例子中,我们用&符号实现了查询同时包含“中文”和“文本”两个词的记录,那么现在问题就来了:在这一条SQL语句中,用户究竟是想要查询“同时包含这两个词的记录”还是“包含‘中文文本’的记录”呢?
这个问题看似莫名其妙,实则却是一个非常关键的问题,因为就算我们想直接查询“中文文本”这个词,也需要先通过分词器将它切分成“中文”和“文本”两个词,因为在tsvector中,文本是以词元的形式存储的,而不是一篇完整的文章。
所以在实际的项目中,这两种不同的查询目的可能会因为这个问题而发生混淆,这个时候就需要用到一个新的函数:phraseto_tsquery,并将原来的SQL语句修改成如下的格式:
SELECT * FROM "Post" WHERE "tsvector" @@ phraseto_tsquery('中文 & 文本');phraseto_tsquery函数首先会实现原来::tsquery也有的功能,就是将文本解析成tsquery,但它同时还有另一个功能:保留词序和邻近关系,即要求词按顺序出现且相邻。
也就是说,这个函数在查询关键词的同时,还要求了关键词必须紧密排列在一起(根据tsvector判断),只有同时满足了这两个条件,它才会成立,这就能完美解决我们上面所说的那个问题,将两种不同的查询条件分开来。
同时,我们还能通过<->操作符,指定两个词之间最多相隔多少其他词,比如<2>就可以同时在以下几种情况中成立:
中文文本
中文的文本
中文的纯文本
如果不指定操作符的话,函数默认会使用<->,即不允许间隔任何其他词,两个词必须紧密相邻。
如此一来,这个问题就能被很好的解决了。
但同时,这又会催生出另外一个问题——如果词元在向量中的位置不相邻怎么办?
这看起来是一个很无厘头的问题,毕竟按理来说,不相邻就意味着它不是我们想查询的文本,当然就不需要它了。
但这确实是我在测试接口时遇到的一个问题,让我们来看下面一个例子:
SELECT * FROM "Post" WHERE "tsvector" @@ phraseto_tsquery('这是 & 因为 & 发生 & 了 & 一个 & 经典 & 的 & 错误');在查询的时候,我们当然是需要查找包含“这是因为发生了一个经典的错误”这句话的文章,但最后的查询结果却是空的,非常奇怪。
最开始遇到这个问题的时候,我也百思不得其解,为什么我明明是直接在之前的博客中将这句话复制过来的,但在数据库中却找不到它呢?
经过几番查找后,我发现了问题——在数据库中,这句话的tsvector向量是长这样的:
'这是':1 '因': 2 '为': 3 '发生': 4 '了': 5 '一个': 6 '经典': 7 '的': 8 '错误': 9可以看到,在向量中,“因为”这个词被错误的拆分成了“因”和“为”两个词元,这样一来,我通过“因为”的向量当然找不到“因”和“为”了,因为在查询中,分词器会将“因为”放在一起,如此一来,"这是"是第一个词,“发生”是第三个词,中间的“因为”理应是第二个词,但是在向量中,“因为”对应的位置却不在这里,那就不符合phraseto_tsquery要求的紧密排列条件了,所以查询结果才为空。
那该如何解决这个问题呢?
我一开始也想不出解决方案,因为这是分词器的问题,换句话说,这是因为中文在不同语境下的歧义导致的不同的分词结果,理论上来说无法避免,否则就会丢失分词器原本的精准度。
既然我想不到答案,那就去问AI吧。AI随即给了我一个比较折中的解决方案:降级查询。
什么意思呢?
简单来说,就是当一次查询无法找到结果时,自动将查询的条件“降级”,即放宽对它的要求,然后再查一次,类似于这样:
let result = await db.$queryRaw`
SELECT *
FROM blog_posts
WHERE "tsvector" @@ phraseto_tsquery(${content})`;
if (result.length === 0) {
result = await db.$queryRaw`
SELECT *
FROM blog_posts
WHERE "tsvector" @@ plainto_tsquery(${content})`;
}
if (result.length === 0) {
const chars = contentText.split('').filter(c => /[\u4e00-\u9fa5]/.test(c));
const charQuery = chars.join(' & ');
result
在这段代码中,我首先执行了第一个SQL语句:
SELECT * FROM blog_posts WHERE "tsvector" @@ phraseto_tsquery(${content});与上面的例子差不多,就是查询一段连续的内容。
紧接着,当查询的结果result为空时,会再执行下一段SQL语句:
SELECT * FROM blog_posts WHERE "tsvector" @@ plainto_tsquery(${content});plainto_tsquery()也是一个与tsquery有关的函数,它的功能和最初的::tsquery差不多,能将用户输入的普通文本转换为 tsquery,但是相比于::tsquery来说,它要更安全,会自动处理传入的文本,并对其进行转义,防止SQL注入。
这个SQL语句的要求就不如第一个那么严格,他只要求了“文本存在”,但不要求“紧密排列”,这样一来,上面说到的例子就可以被避免了。
同时,为了进一步确保更极端的情况产生,我还准备了第三手保险,就是上文中的第三段SQL语句:
SELECT * FROM blog_posts WHERE "tsvector" @@ ${charQuery}::tsquery;在执行它之前,我首先会将文本拆解成单个的字,然后再通过这个SQL语句去逐字的查找。
这样一来,最终的tsquery就会是这样的:
这 & 是 & 因 & 为 & 发 & 生 & 了 & 一个 & 经 & 典 & 的 & 错 & 误如果连逐字查找都找不到任何记录的话,说明就真的找不到了,直接返回空结果即可。
看起来,这确实解决了这个棘手的问题,但读到这里,想必你一定会发现一个很明显的问题——我的第一个优化和第二个优化发生了矛盾!
在第一个优化中,我为了查找连续的文本,排除无关的结果,使用了phraseto_tsquery进行严格查找,但在第二个优化中,为了解决分词器的分词歧义,我却又在降级查询中使用了一般的查询方法,也就是说,一旦发生了歧义,phraseto_tsquery其实就失效了,那么这个优化就跟没有一样。
很显然,他们之间产生了一个矛盾,不过这并不是一个很困难的问题,再使用一个新的函数就能够解决:
SELECT
*,
ts_rank("tsvector", plainto_tsquery(${content})) AS rank
FROM blog_posts
WHERE "tsvector" @@ plainto_tsquery(${content})
ORDER BY rank DESC;我们重点来看这一行:
ts_rank("tsvector", plainto_tsquery(${content})) AS rank这行代码中出现了一个名为ts_rank的函数,它的作用是将计算给定 tsvector与 tsquery 之间的相关性,并将查询的结果按照相关性进行排序。
它主要按照以下两个维度对结果的相关程度进行打分:
例如:搜索 “PostgreSQL 全文检索”,同时包含这 3 个词且 “PostgreSQL” 出现在标题的文档,会比只包含 2 个词且在正文末尾的文档,ts_rank 分数更高,排序更靠前。
因此,在上面的代码中,我们给ts_rank传入的两个参数分别就代表了对应的tsvector和tsquery,而它会自动帮我们把两个参数使用@@操作符连接起来,并计算最终的相关性。
当相关性过低时,就说明要查询的词可能分布在文章的各个角落,或者比较分散,那么这显然就不是我们想要的结果,我们就可以根据它计算出来的分数,将相关性低于一定程度的结果给排除掉,最后再通过ORDER BY rank DESC将查询结果按照相关性从高到低排序,这样一来,靠前的结果就全都是相关性极高的文章了,如此就能缓和前两项优化之间的矛盾,提高查询的准确性。
最后,还有一个比较小的优化,就是在分词器解析完文本后过滤掉文本中的无关字符,避免这些字符影响数据的查询和相关性的计算,比如一些常见的数字、符号等:
const validWords = segmentResult.filter(word => {
return word.trim().length > 0 &&
!/^\d+$/.test(word) &&
!/^[^\u4e00-\u9fa5a-zA-Z0-9]$/.test(word);
});这个过程需要在生成tsvector向量和全文搜索两个环节中都进行一次,避免查询内容不一致,导致干扰查询结果。
如此一来,一个成熟的全文搜索功能就实现了:
// 发布博客,并生成tsvector变量
async CreatePost(req, res) {
const db // 数据库实例
const content //博客内容
// 初始化分词器
var segment = new Segment();
segment.useDefault();
const segmentResult = segment.doSegment(
content,
{
simple: true
});
// 过滤无关字符
const validWords = segmentResult.filter(word => {
return word.trim().length > 0 &&
!/^\d+$/.test(word) &&
!/
// 全文搜索
async FindPostByFTS(req, res) {
const db // 数据库实例
const contentText // 需要搜索的文本
var segment = new Segment(); // 分词器实例
// 初始化分词器
segment.useDefault();
const segmentResult = segment.doSegment(
contentText,
{
simple: true
});
// 过滤无关字符
const validWords = segmentResult.filter(word => {
return word.trim().length > 0 &&
!/^\d+$/.test(word) &&
!
PS:本文中提到的所有代码片段默认视为开源,你可以在MIT协议允许的范围内自由的使用它。