基于FAQ的智能问答(二):召回篇

背景

基于FAQ的智能问答本质是一个信息检索的问题,所以可以简单划分成:召回+精排 两个步骤。召回的目标是从知识库中快速的召回一小批与query相关的候选集。所以召回模型的评价方法,主要侧重于响应时间top@n的召回率两个方面。

本文将分享我们召回模型的逐步迭代过程,从最基础的“ES字面召回”到 “ES字面召回和向量召回”的双路召回模式。

基于ES的简单召回

在第一篇分享”基于FAQ的智能问答(一): Elasticsearch的调教” 中已经介绍了信息检索中的神器Elasticsearch!所以可以基于ES快速搭建一个召回的baseline。

具体而言,构建如下的ES查询语句,按照评分从高到低返回top50的结果:

1
2
3
4
5
6
7
8
{
"query":{
"match":{
"question":"公务员考试"
}
},
"explain": true
}

而基于ES的召回实质是基于BM25的召回:
ES关于BM25的官方文档

通过在查询添加explain=true可以获取计算的细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
...
"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,
"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" : [...]
},
]

$$
score = boost * tf * idf \
tf = \frac{freq}{freq + k_{1} * (1 - b + b * \frac{d}{d_{avg}} )} \
idf = log_{2} \frac{N + 1}{n+0.5}
$$

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 ,是一个pair wise的模型架构,并且针对负样本的构建做了深入的实验。

模型结构

我们采用的基本结构是albert获取embedding,然后通过pair-loss function进行fine-tuning。

模型的结构

这里的loss function:$L = max(0,D(q,d+)-D(q,d-)+m)$, 其中 $D(q,d) = 1-cos(q,d)$

这里m为超参数,对于“简单”的样本,正样本和负样本之间的距离大于m,L即为0。所以m的存在,让模型更加关注比较“难”的样本。

模型构建

我们尝试构建一个通用领域的召回数据集,格式为(q,d+,d-)的三元组。

这里我们借鉴了论文Embedding-based Retrieval in Facebook Search中的思路,d-负样本包括easy和hard两类。

具体而言,首先收集一个通用领域的query集合,再带入百度知道中检索,提取1-20页的结果。

第一页的结果

第二十页的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"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向量,再基于以下的检索语句:

1
2
3
4
5
6
7
8
9
{
"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%**。