基于FAQ的智能问答本质是一个信息检索的问题,所以可以简单划分成:召回+精排 两个步骤。召回的目标是从知识库中快速的召回一小批与query相关的候选集。所以召回模型的评价方法,主要侧重于 响应时间 和 top@n的召回率 两个方面。
本文将分享我们召回模型的逐步迭代过程,从最基础的“ES字面召回”到 “ES字面召回和向量召回”的双路召回模式。
在第一篇分享"基于FAQ的智能问答(一): Elasticsearch的调教" 中已经介绍了信息检索中的神器Elasticsearch!所以可以基于ES快速搭建一个召回的baseline。
具体而言,构建如下的ES查询语句,按照评分从高到低返回top50的结果:
{
"query":{
"match":{
"question":"公务员考试"
}
},
"explain": true
}
而基于ES的召回实质是基于BM25的召回[1]:
ES官网关于相似度计算的说明通过在查询添加`explain=true`可以获取计算的细节:
...
"hits" : [
{
"_shard" : "[lime-ai-faq][0]",
"_node" : "X0rpCxNLQcuOW56gzFjPAA",
"_index" : "lime-ai-faq",
"_type" : "_doc",
"_id" : "360",
"_score" : 17.105383,
"_source" : {
"question" : "公务员考试省考和国考的题型区别大吗?",
},
"_explanation" : {
"value" : 17.105383,
"description" : "sum of:",
"details" : [
{
"value" : 4.8125763,
{
"value" : 4.8125763,
"description" : "score(freq=1.0), computed as boost * idf * tf from:",
"details" : [
{
"value" : 2.2,
"description" : "boost",
"details" : [ ]
},
{
"value" : 5.628467,
"description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
"details" : [
{
"value" : 8,
"description" : "n, number of documents containing term",
"details" : [ ]
},
{
"value" : 2364,
"description" : "N, total number of documents with field",
"details" : [ ]
}
]
},
{
"value" : 0.38865548,
"description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
"details" : [
{
"value" : 1.0,
"description" : "freq, occurrences of term within document",
"details" : [ ]
},
{
"value" : 1.2,
"description" : "k1, term saturation parameter",
"details" : [ ]
},
{
"value" : 0.75,
"description" : "b, length normalization parameter",
"details" : [ ]
},
{
"value" : 11.0,
"description" : "dl, length of field",
"details" : [ ]
},
{
"value" : 7.777073,
"description" : "avgdl, average length of field",
"details" : [ ]
}
]
}
]
}
]
}
},
{
"value" : 4.8125763,
"description" : "weight(question:公务 in 965) [PerFieldSimilarity], result of:",
"details" : [...]
},
{
"value" : 3.8403268,
"description" : "weight(question:员 in 965) [PerFieldSimilarity], result of:",
"details" : [...]
},
{
"value" : 3.639904,
"description" : "weight(question:考试 in 965) [PerFieldSimilarity], result of:",
"details" : [...]
},
]
boost:用来控制检索字段的权重。默认值为2.2;
k1:控制词频结果在词频饱和度中的上升速度。默认值为1.2。值越小饱和度变化越快,值越大饱和度变化越慢;
b:控制字段长归一值所起的作用,0.0会禁用归一化,1.0会启用完全归一化。默认值为0.75;
-----------
但是基于ES的召回,也就是BM25的召回方案,还是基于字面的关键词进行召回,无法进行语义的召回。考虑下面的场景
知识库内的问题是:“可以免运费吗?”
用户的query如果是:“你们还包邮?”
可以看到用户的query和知识库中的问题没有一个关键词相同,但是其实是语义一致的。这就需要基于语义的召回来补充。
基于语义的召回通常是基于embedding的召回。具体而言,首先训练sentence embedding模型,然后将知识库中的问题都预先计算出embedding向量。在线上预测阶段,对于每个query同样先计算出embedding,再到知识库中检索出相近的embedding所属的问题。
这里采用的方案参考的是facebook最新的论文: Embedding-based Retrieval in Facebook Search[2],是一个pair wise的模型架构,并且针对负样本的构建做了深入的实验。
模型结构
我们采用的基本结构是albert获取embedding,然后通过pair-loss function进行fine-tuning。
基于pair-wise的模型结构这里的loss function:
其中
这里m为超参数,对于“简单”的样本,正样本和负样本之间的距离大于m,L即为0。所以m的存在,让模型更加关注比较“难”的样本。
数据构建
我们尝试构建一个通用领域的召回数据集,格式为(q,d+,d-)的三元组。
这里我们借鉴了论文Embedding-based Retrieval in Facebook Search中的思路,d-负样本包括easy和hard两类。
具体而言,首先收集一个通用领域的query集合,再带入百度知道中检索,提取1-20页的结果。
第一页的结果,作为正样本 第二十页的结果{
"q":"北京有哪些旅游景点"
"page_1": [
"北京有哪些旅游景点啊?"
"北京有哪些旅游景点好玩"
"北京有哪些旅游景点最出名?可以推荐一些吗?"
...
],
....
"page_20": [
"北京旅游景点线路的服务标准都有哪些?"
"北京周围方圆三百里有什么旅游景点。。农业观光区。。",
"北京到包头沿途有什么旅游景点?"
...
]
}
抓取的数据集如上所示,每个query包括都包括1-20页的问题。
对于每个query,随机从page1-3页中提取5个问题作为正样本,从page20页中随机抽取3个问题,并且与query之间的rouge值<0.15的作为 “hard”负样本,从其他的query的问题中随机抽取12个问题作为 “easy 负样本”
这样每个query,包括5个正样本和15个负样本。两两组合就可以构建出 75个 (q,d+,d-)三元组。
ES dense_vector
基于embedding的检索,已经有很多成熟的方案,包括:Annoy、Faiss、Elasticsearch (dense_vector)等。最终考虑不引入新的框架(挖坑),我们还是选择继续在ES中来实现,主要就是基于ES的dense vector。
在MySQL同步到ES的阶段,为每个问题计算出句子级别的embedding向量,并存储到`question_vector`字段中。在检索阶段,先计算出query的embedding向量,再基于以下的检索语句:
{
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'question_vector') + 1.0",
"params": {"query_vector": query_vector}
}
}
}
具体可以参考ES的官网blog:
https://www.elastic.co/cn/blog/text-similarity-search-with-vectors-in-elasticsearch最终的召回框架如下图所示,是 “字面召回 + 语义召回” 的双路召回结构。在收到query的请求后会同步发起两路召回,最后进行结果的合并。
其中Embedding Server是一个基于Tensorflow-Serving的独立服务。
全部的服务(Flask,ES,TF-Serving)均部署在CPU的k8s集群上,在知识库问题数量<10000的场景下,整个召回阶段的响应时间<50ms,满足线上的实际需要。 同时top@50的召回率也能接近100%。