近日对某信息流个推产品的推荐引擎进行了性能优化,速度上有倍增效果,单次推荐用时减少到百毫秒级。以下简要总结优化要点。
背景
既有推荐算法采用了标签相似度打分的推荐方法。具体而言是:
- 对文章分词,并计算TF-IDF权重。保留权重最大的20个特征词。
- 对用户,根据浏览行为对其打标签。保留权重最大的20个标签词。(本文不涉及如何给用户打标签的策略和算法)
- 当用户刷新信息流获取下一批推荐文章列表时,把每一篇未推荐过的文章标签权重向量和用户标签权重向量相乘,得到相似度分数。
- 把文章按照相似度分数排序,取指定的篇数返回。
在技术架构层面,数据存储采用的PostgreSQL Cloud,推荐算法运行在云主机上,使用python语言编写并以微服务形式向应用层提供服务。
一、用更加紧凑的倒排索引提高从标签获取文章列表的速度
原实现使用关系表(Many-to-many relation)存储(article_id, keyword_id, weight),可以简单用一个查询的例子来对比cost:
=> explain analyze select * from article_assignedkeyword where keyword_id = 8040;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on article_assignedkeyword (cost=10.61..1038.04 rows=281 width=20) (actual time=0.125..0.695 rows=390 loops=1)
Recheck Cond: (keyword_id = 8040)
Heap Blocks: exact=379
-> Bitmap Index Scan on article_assignedkeyword_keyword_id_c4a2f099 (cost=0.00..10.54 rows=281 width=0) (actual time=0.076..0.076 rows=390 loops=1)
Index Cond: (keyword_id = 8040)
Planning time: 0.085 ms
Execution time: 0.729 ms
而如果使用预先构建的倒排索引表(word, documents(article_id => weight))来检索的话,数据库查询层面就可以看到显著的速度提升:
=> explain analyze select * from recommendation_keywordarticle where word = '植物神经';
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--
Index Scan using recommendation_keywordarticle_word_5b086b2d_like on recommendation_keywordarticle (cost=0.42..8.44 rows=1 width=136) (actual time=0.016..0.017 rows=1 loops=1
)
Index Cond: ((word)::text = '植物神经'::text)
Planning time: 0.057 ms
Execution time: 0.034 ms
可见,即使是在前者通过id化启用bitmap index scan而后者直接用了text而只是index scan的情况下,后者也比前者要快一个量级。
其实,关系表也可以看作是一种可以双向查询的倒排索引,只是,因为我们的应用场景总是从单词到文章这种单向查询,所以将文章列表合并,形成更加紧凑的形式,便可以大幅节省查询时间。
值得指出的是,从全局角度看,如果仅仅是sql query更快一点,可能并不会有太大效果。比如,如果使用关系表可以通过与其他表关联查询在查询过程中就大幅减少数据量,那么有可能反而会成为综合效率更高的方案。所以,要让紧凑型倒排索引真正发挥作用,还需要在其他方面同时下功夫。
二、将标签词权重和词语位置等信息拆分为多个倒排索引
原有倒排索引的倒排表中包含了标签词权重和词语位置等信息,尤其是位置信息有可能比较多,文章一多数据量就会比较大。我们可以用这段简单的代码来对比一下合并的倒排表和拆分的倒排表读取的效率:
from datetime import datetime
from django.db import connection
words = ['肠胃','奶类','流产','纤维素类','泻药','胃肠','泻剂','调理','刺激性','不良反应','蔬菜类','肠道','黑枣','保健品','用药','纤维','枣子','全麦','买药','致突变作用']
begin = datetime.now()
with connection.cursor() as cursor:
cursor.execute(sql)
indexes = cursor.fetchall()
end = datetime.now()
print 'cost:', end - begin
合并的倒排表我们保存在search_searchindex表中,对应的sql查询如下:
sql = '''select id, word, documents from search_searchindex where word in ('%s');''' % ('\',\''.join(words))
测试结果,cost: 0:00:00.864730。
拆分出来的倒排表中只有权重,保存在recommendation_keywordarticle表中,对应的sql如下:
sql = '''select id, word, documents from recommendation_keywordarticle where word in ('%s');''' % ('\',\''.join(words))
测试结果,cost: 0:00:00.544644。快了300多毫秒。
三、强制ORM提前执行查询
通常,我们会使用ORM来封装数据库查询。本例中我们使用的是django ORM。上述查询我们其实通常是这样写的:
from recommendation.models import KeywordArticle
indexes = KeywordArticle.objects.raw(sql)
这样的好处是可以把查询的结果直接包装成数据模型对象,方便使用,利于维护。
接下来就是合并索引,大概是像下面循环计算:
begin = datetime.now()
score_doc = {}
for index in indexes:
word = index.word
docs = index.documents
for article_id, weight in docs.items():
score_doc[article_id] = score_doc.get(article_id, 0) + float(weight) #应该再乘以用户标签权重,此处仅供示例
end = datetime.now()
print 'cost:', end - begin
测试结果,cost: 0:00:00.633171。
现在,让我们强迫ORM提前执行好,而不是等到后面循环时再“惰性求值”(因为我们可以预判,所有的查询结果都会被使用到,不妨都提前取出)。方法就是把ORM语句用list()包裹一下:
indexes = list(KeywordArticle.objects.raw(sql))
然后运行同样的测试,结果cost: 0:00:00.100442,大幅下降。
且慢。上面的测试其实有个漏洞。执行时间被转移了,从双重循环转移到了索引查询。让我们把索引查询语句包含到时间代价统计中来再重新对比一下:
惰性求值版本:cost: 0:00:00.861284 ;
强制求值版本:cost: 0:00:00.646553 。
不过多运行几次就会发现,其实两个版本的耗时不相上下,有时候前者还会比后者略快。也就是说,这一点优化,比起来网络数据传输速度等其他波动性因素影响带来的cost变化,就不是那么具有显著效果了。
四、把key-value数据转换成tuple数据再返回
进一步的,让我们把
sql = '''select id, word, documents from recommendation_keywordarticle where word in ('%s');''' % ('\',\''.join(words))
改为
sql = '''select id, word, hstore_to_matrix(documents) as documents from recommendation_keywordarticle where word in ('%s');''' % ('\',\''.join(words))
对比二者的执行速度:
采用key-value数据直接返回:cost: 0:00:00.614923 ;
先转换成tuple数据再返回:cost: 0:00:00.118220 。
可以看到,对于hstore这种key-value数据存储格式,先转换成tuple数据再返回,可以大幅提升性能,耗时大幅下降500毫秒。
那么问题是,性能如此大幅提升的原因,到底是查询效率提升,还是其他原因(比如带宽)呢?
我们可以看一下两个sql query在数据库端的执行效率对比:
key-value:
=> explain analyze select id, word, documents from recommendation_keywordarticle where word in ('肠胃','奶类','流产','纤维素类','泻药','胃肠','泻剂','调理','刺激性','不良反应','蔬菜类','肠道','黑枣','保健品','用药','纤维','枣子','全麦','买药','致突变作用');
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on recommendation_keywordarticle (cost=88.53..163.58 rows=20 width=136) (actual time=0.346..0.379 rows=20 loops=1)
Recheck Cond: ((word)::text = ANY ('{肠胃,奶类,流产,纤维素类,泻药,胃肠,泻剂,调理,刺激性,不良反应,蔬菜类,肠道,黑枣,保健品,用药,纤维,枣子,全麦,买药,致突变作用}'::text[]))
Heap Blocks: exact=4
-> Bitmap Index Scan on recommendation_keywordarticle_word_5b086b2d_like (cost=0.00..88.52 rows=20 width=0) (actual time=0.296..0.296 rows=21 loops=1)
Index Cond: ((word)::text = ANY ('{肠胃,奶类,流产,纤维素类,泻药,胃肠,泻剂,调理,刺激性,不良反应,蔬菜类,肠道,黑枣,保健品,用药,纤维,枣子,全麦,买药,致突变作用}'::text[]))
Planning time: 0.718 ms
Execution time: 0.448 ms
tuple:
=> explain analyze select id, word, hstore_to_matrix(documents) as documents from recommendation_keywordarticle where word in ('肠胃','奶类','流产','纤维素类','泻药','胃 肠','泻剂','调理','刺激性','不良反应','蔬菜类','肠道','黑枣','保健品','用药','纤维','枣子','全麦','买药','致突变作用');
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on recommendation_keywordarticle (cost=88.53..163.63 rows=20 width=136) (actual time=1.325..9.433 rows=20 loops=1)
Recheck Cond: ((word)::text = ANY ('{肠胃,奶类,流产,纤维素类,泻药,胃肠,泻剂,调理,刺激性,不良反应,蔬菜类,肠道,黑枣,保健品,用药,纤维,枣子,全麦,买药,致突变作用}'::text[]))
Heap Blocks: exact=4
-> Bitmap Index Scan on recommendation_keywordarticle_word_5b086b2d_like (cost=0.00..88.52 rows=20 width=0) (actual time=0.098..0.098 rows=21 loops=1)
Index Cond: ((word)::text = ANY ('{肠胃,奶类,流产,纤维素类,泻药,胃肠,泻剂,调理,刺激性,不良反应,蔬菜类,肠道,黑枣,保健品,用药,纤维,枣子,全麦,买药,致突变作用}'::text[]))
Planning time: 0.074 ms
Execution time: 9.466 ms
可以看到,并不是数据库端的执行效率提升带来的,相反地,tuple转换增加了sql的执行计算量,反而使得数据库端的执行效率有所下降(从0.448ms下降到9.466ms,下降了20倍)。
我们再回到python直接用connection cursor执行两个query来对比一下:
begin = datetime.now()
with connection.cursor() as cursor:
cursor.execute(sql)
indexes = cursor.fetchall()
end = datetime.now()
print 'cost:', end - begin
key-value:cost: 0:00:00.554903 ;
tuple:cost: 0:00:00.090624 。
可以看到,效率提升最主要的原因就是ORM fetch查询结果的时候有6倍的速度提升,这很有可能与带宽有关系。
五、先合并索引再排序 VS 边合并索引边排序
接下来我们要把每个检索词对应的倒排表合并,计算合并分数,然后做top-k排序得到分数最高的k篇文章作为最终结果,那么有两种策略:
边合并索引边排序,伪代码示意如下:
for index in indexes:
for doc in documents:
更新doc的得分score
使用插入排序,更新doc在topk排序列表中的位置
可以看出,这里采用的是所谓“一次一单词”的合并方法。其时间复杂度大概是 O(N_indexes) * O(N_documents) * O(k)log(k)。在这里,N_indexes是一个较小的数,N_documents是一个很大的数,k是一个很小的数。
而先合并索引再排序则是类似这样:
for index in indexes:
for doc in documents:
更新doc的得分score
for doc in scored_documents:
使用插入排序,更新doc在topk排序列表中的位置
仍然是所谓“一次一单词”的合并方法,但是时间复杂度下降到 O(N_indexes) * O(N_documents) + O(N_documents) * O(k)log(k)。
六、使用可推荐文章列表向量掩膜对索引降维
基本的思路是,先求出来可推荐的文章id列表(排除掉已推荐过的文章,以及根据其他业务逻辑挑选出来的文章),然后在合并索引的时候,用这个列表去做“masking”,如果可推荐列表比全量文章数量少,也就是维度低,就实现了对索引的降维。这样,有可能带来计算量的大大降低。比如,如果我们假设,从1000万篇文章里挑选10篇文章推荐给用户,和从1亿篇文章中的10万篇文章中挑选10篇文章推荐给用户,能够带来几乎相当的用户体验,那么我们就有望获得100倍的降维,时间复杂度会从 O(N_indexes) * O(N_documents) + O(N_documents) * O(k)log(k) 下降到 O(N_indexes) * O(N_documents) + O(N_available) * O(k)log(k),其中 N_available << N_documents。
至于masking,可以用hash表,也可以进一步优化用bitmap。如果我们的索引数据量更大了,可以进一步把key-value store压缩成bitmap,这样,索引合并的双重循环就可以优化为bitmap的位操作运算,bit_available & bit_index_01 & bit_index_02 & ... ,内存占用会大幅减少(可以计算更大数量的文章),速度也会提升。
七、进一步的工作
以上简要总结了推荐引擎的部分优化点。有一些进一步的工作值得继续深入研讨和开展:
- 进一步减少数据库查询次数、传输数据量和网络通信次数。比如复杂的人工干预的业务逻辑和可推荐文章列表的查询,值得考虑进一步优化。
- 进一步压缩索引数据和优化计算算法,减少内存占用,提高计算效率。
- 进一步考虑基于用户行为的推荐模型,logistic regression乃至深度学习模型的引入及其对推荐算法的影响,并相应优化。
Author & Copyright
CC-BY-NC-SA (c) Evan QY Liu 微信公众号:最创业