上文 使用PostgreSQL进行中文全文检索 中我使用 PostgreSQL 搭建完成了一套中文全文检索系统,对数据库配置和分词都进行了优化,基本的查询完全可以支持,但是在使用过程中还是发现了一些很恼人的问题,包括查询效果和查询效率,万幸都一一解决掉了。
其中过程自认为还是很有借鉴意义的,今天来总结分享一下。
博客欢迎转载,请带上来源:
使用B树索引优化查询效果 分词问题一开始是分词效果的问题:
中文博大精深,乒乓球拍卖啦、南京市长江大桥 这种歧义句的分词,还没有一个分词插件能够达到 100% 的准确率,当然包括我们正在使用的 scws 分词库;
我们的搜索内容是 Poi 地点名,而很多地点名都缺失语义性,产生歧义词的概率更大;
scws 支持更为灵活的分词等级,为了能分出较多的词来尽量包含目标结果,我们将 scws 的分词等级调为了 7(不了解的可以看上文),但同时也引入了更奇葩的问题: 搜索天安门 查不到 天安门广场。。。
原因也很另人无语:
天安门广场 的分词结果向量 tsv 是 '天安':2 '天安门广场':1 '广场':4 '门广':3;
查询向量 to_tsquery('parser', '天安门') tsq 的结果是 '天安门' & '天安' & '安门';
查询语句是 SELECT * FROM table WHERE tsv @@ tsq, 由于 tsv 里没有 tsq 里的 安门向量,匹配失败。
B树索引一个常识:大家想搜一个地点时大多会先输入其名称前面的部分,基于此考虑,我向表内引入 B树索引支持前缀查询,配合原来分词的 GIN 索引,解决了此问题。
如Mysql一样,PostgreSQL 也支持通过 like '关键词%' 语句来使用 B树索引。在 name 列上添加了 B树索引,再修改查询语句变为 SELECT * FROM table WHERE tsv @@ tsq OR name LIKE 'keyword%',这样结果就完全 OK 啦。
使用子查询优化查询效率 GIN索引效率问题紧接着又发现了新的问题:
PostgreSQL 的 GIN 索引(Generalized Inverted Index 通用倒排索引)存储的是 (key, posting list)对, 这里的 posting list 是一组出现键的行ID。如 数据:
行ID 分词向量1 测试 分词
2 分词 结果
则索引的内容就是 测试=>1 分词=>1,2 结果=>2,在我们要查询分词向量内包含 分词 的数据时就可以快速查找到第1,2列。
但这种设计也带来了另一个问题,当某一个 key 对应的 posting list 过大时,数据操作会很慢,如我们的数据中地点名带有 饭店 的数据就很多,有几十万,而我们的需求有一项就是要对查询结果按照 评分 一列倒序排序,这么几十万数据,数据库响应超时会达到 3000 ms。
我们期望的响应时间是 90% 50ms 以内,虽然统计结果显示,确实 90% 的请求已经符合要求,但另外的 10% 完全不能用也是不可能接受的。
接下来的优化就是针对这些 bad case。
缓存对于这种响应超时的问题,大家肯定会想到万能的缓存:把响应超时的查询结果放到缓存,查询时先检查缓存。
可是超时的毕竟只有很少一部分,缓存的命中率堪忧。虽然这一小部分查询可用了,但是所有查询语句都会多出一次取缓存的操作。
为了能提高缓存命中率,我还特意统计了关键字各长度的搜索数量占比和超时率占比,发现以下情况:
1字节(1个字母)、3字节(单字)关键词的超时率最高,可是也不超过 30%;
1字节、3字节关键词的搜索量占比有30%左右;
其他长度关键词的超时率10%左右,非常尴尬。
这种情况打消了我只针对某些长度的关键词设置缓存的想法。
不仅是命中率问题,缓存过期时间和缓存更新等更是大坑,基于以上考虑,缓存方案彻底被放弃。
分表一个方法不行,那就换一个方向,既然某些关键词的结果集太大,那么我们就将它变小一些,我们一开始采用的策略是分表。
由于 Poi 地点都有区域属性,我们以区域 ID 将这些数据分成了多个数据表,原来最大的关键词结果集有几十万,拆分到多个表后,每个表中最大的关键词结果集也就几万,此时的排序性能提高了,基本在 100~200ms 之间。
查询时我们先通过位置将用户定位到区域,根据区域 ID 确定要查询的表,再从对应表内查询结果。
这个方案的缺点也非常多:
对定位很依赖,且定位计算区域也会有耗时;
区域边缘点的搜索很蛋疼,明明离得很近,如果被划分到跟用户不同区域了就搜索不到。
多个表非常不好维护。
子查询终于灵活考虑了业务需求,引入子查询提出了一种颇为完美的方案:
用户在搜索框键入了 饭店、宾馆 等无意义关键词,不同于搜索 海底捞,此时用户也不知道他自己需要什么,对搜索结果是没有明确期待的。