智能客服打造系列(上)

智能客服是什么

很多站点在设计的时候,都会有类似「常见问题」或者「FAQ」的页面,以便用户遇到问题时查看。然而,根据目前我了解到的情况来看,大多数用户在遇到问题时,不会主动去翻「常见问题」。

举一个活生生的例子,很多用户在使用「南洋PT」遇到问题时,更倾向于在「群聊区」或者「新人互助群」寻求帮助。如果说站点的客服人员将大量的时间都用于解答「常见问题」,这无疑是对人力资源的一种浪费,而且客服也很容易因为过多的重复劳动而失去激情。

而智能客服存在的目的就是为了解决这种问题,将人工客服从大量的重复劳动中解脱出来。目前智能客服的应用并不少,淘宝、京东各大商城都有自己的智能客服。

系列内容概述

本系列内容预计会分为上、中、下三部分:第一部分将使用较简单有效的词袋模型进行 QA 匹配;第二部分会介绍如何利用神经网络进行「语义理解」;第三部分会对前两部分的内容作个小结,或者是提供一些其它的解决思路。

系列内容主要使用我处理过的 WebQA 的部分数据,所有代码都将同步更新到 GitHub 上。系列博文的目的在于抛砖引玉,如果有任务疑问,可以在底下留言或者到相应的 repo 中提 issue。: )

词袋模型

词袋模型忽略词之间的顺序,把一段话看成是简单的词语的集合,就好比一个装着各种词语的袋子,所以也叫「词袋模型」。比如,「我要去超市,你和我一起吗?」这句话可以表示成由「要」、「去」、「超市」、「你」、「和」、「一起」、「吗」和 2 个 「我」组成的词袋。

使用词袋模型评价两段话是否相近的操作相对简单:首先将两段话分别转换为词袋,然后对比两个词袋中的词和词频,并以此作为判断依据。「你要不要和我一起去超市?」转换成「你」、「要」、「不要」、「和」、「我」、「一起」、「去」、「超市」的词袋,和「我要去超市,你和我一起吗?」的词袋就有很多相同的词(而且词频相近),我们可以认为这两句话很相近。

通常在转换成词袋之间,会统计所有可能出现的词,然后生成一个词典。假如我们的材料只有「我要去超市,你和我一起吗?」和「我要去超市,你和我一起吗?」,那么组成的词典类似于:

1
dict = ["我", "你", "和", "要", "不要", "去", "超市", "一起", "吗"]

然后我们会将每句话用一个向量表示,向量的维度和词典维度一致(在本例中为 9 维),向量中的每一个元素取值代表对应的词出现的次数。当词袋变成了向量,它们之间的相似度计算就变得很简单了,比如可以通过计算欧氏距离、余弦距离等等作为判别标准。

1
2
3
4
# 我要去超市,你和我一起吗?
bagOne = [2, 1, 1, 1, 0, 1, 1, 1, 1]
# 你要不要和我一起去超市?
bagTwo = [1, 1, 1, 1, 1, 1, 1, 1, 0]

词袋模型改进

一句话中,通常出现次数最多的是「的」、「是」、「在」之类的词,如果单纯的使用词频来构造「词袋向量」,那么词袋与词袋之间的相似度计算会在很大程度上受这些无用词影响。

一种解决方案是使用 TF-IDF 代替词频构造「词袋向量」,TF-IDF 的具体介绍请查看谷歌。在一段话中,每个词的 TF-IDF 值等于它在该句话中的词频(TF)乘上它在所有文档中的逆向文件频率(IDF)。一个词在所有文档中出现的次数越多,则它逆向文件频率越低。

依赖工具

分词

英文语料的词与词之间已经用空格隔开,但中文则不然。虽然 Python 有很多自然语言处理的库,但大多数是面向英文这种「已经分好词的场景」。中文分词大多使用 「结巴分词」,详细介绍和使用方法可以查看 官方仓库

  • 支持三种分词模式:
  • 精确模式,试图将句子最精确地切开,适合文本分析;
  • 全模式,把句子中所有的可以成词的词语都扫描出来, 速度非常快,但是不能解决歧义;
  • 搜索引擎模式,在精确模式的基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词。
  • 支持繁体分词
  • 支持自定义词典
  • MIT 授权协议

TfidfVectorizer

Scikit-learn 项目最早由数据科学家 David Cournapeau 在 2007 年发起,需要 NumPy 和 SciPy 等其他包的支持,是 Python 语言中专门针对机器学习应用而发展起来的一款开源框架。

Scikit-learn 提供了很多有关特征提取的方法,具体分成了图片特征提取和文本特征提取,其中 TfidfVectorizer 恰好可以用在我们的场景中。

PySparNN

通常情况下,知识库中的词量会比较丰富,每对 QA & 用户输入的问题的「词袋向量」会是一个高维稀疏向量,如果每次用户提问时,都做一次轮循匹配,查询时间可能会变得无法接受。

为了提高查询效率,可以使用 Locality Sensitive Hashing,将距离很近的数据以较高的概率映射成同一个哈希值。这里使用的是 FaceBookResearch 的一个开源库 PySparNN

Approximate Nearest Neighbor Search for Sparse Data in Python! This library is well suited to finding nearest neighbors in sparse, high dimensional spaces (like text documents).

Out of the box, PySparNN supports Cosine Distance (i.e. 1 - cosine_similarity).

PySparNN benefits:

  • Designed to be efficient on sparse data (memory & cpu).
  • Implemented leveraging existing python libraries (scipy & numpy).
  • Easily extended with other metrics: Manhattan, Euclidian, Jaccard, etc.
  • Supports incremental insertion of elements.

If your data is NOT SPARSE - please consider faiss or annoy. They use similar methods and I am a big fan of both. You should expect better performance on dense vectors from both of those projects.

The most comparable library to PySparNN is scikit-learn’s LSHForest module. As of this writing, PySparNN is ~4x faster on the 20newsgroups dataset (as a sparse vector). A more robust benchmarking on sparse data is desired. Here is the comparison.Here is another comparison on the larger Enron email dataset.

实现过程

实现代码放在 QAMatch 仓库中,后续会同步更新博客内容。

建立知识库索引

将知识库中的每一对 QA 连接为一整段,经过「结巴分词」处理,再转换为高维稀疏向量,然后使用 PySparNN 建立索引并使用 pickle 持久化保存于磁盘中。

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
import json
import jieba

from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
import pysparnn.cluster_index as ci

import pickle

# path
# 数据保存路径
qa_path = './data/qa.json'
tv_path = './data/tv.pkl'
cp_path = './data/cp.pkl'

# qa = [
# {
# 'q': 'question',
# 'a': 'answer'
# }
# ]
qa = json.load(open(qa_path))

# Generate corpus
# 将 QA 连接起来并分词
corpus = []
for id,item in enumerate(qa):
tmp = item['q'] + item['a']
tmp = jieba.cut(tmp)
tmp = ' '.join(tmp)
corpus.append(tmp)

# Generate bag of word
# TfidfVectorizer is a combination of CountVectorizer and TfidfTransformer
# Here we use TfidfVectorizer
tv = TfidfVectorizer()

# deal with corpus
tv.fit(corpus)

# get all words
# 词典
words = tv.get_feature_names()

# get feature
# 获取每对 QA 的TF-IDF
tfidf = tv.transform(corpus)

# build index
# 创建索引
cp = ci.MultiClusterIndex(tfidf, range(len(qa)))

# save
pickle.dump(tv, open(tv_path, 'wb'))
pickle.dump(cp, open(cp_path, 'wb'))

搜索知识库

采用同样的方法处理用户输入的问题,得到相应的高维稀疏向量。加载上一步中存储的索引文件,利用 PySparNN 快速查找最匹配的几项并返回。

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
import json
import jieba

from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
import pysparnn.cluster_index as ci

import pickle
import argparse

# path
# 数据保存路径
qa_path = './data/qa.json'
tv_path = './data/tv.pkl'
cp_path = './data/cp.pkl'

# parse arguments
# 解析入参
parser = argparse.ArgumentParser()
parser.add_argument('question', type=str, help='Type your Question')
args = parser.parse_args()

# get the question
# 获取用户的提问
question = args.question

# dived question into words
# 分词
cutted_qustion = jieba.cut(question)
cutted_qustion = ' '.join(cutted_qustion)

# retrieve qa, tv and cp built in gen.pysparnn
# 加载之前保存的数据
qa = json.load(open(qa_path))
tv = pickle.load(open(tv_path, 'rb'))
cp = pickle.load(open(cp_path, 'rb'))

# construct search data
# 构造搜索数据
search_data = [cutted_qustion]
search_tfidf = tv.transform(search_data)

# search from cp, k is the number of matched qa that you need
# 搜索数据,会获取到前 k 个匹配的 QA
result_array = cp.search(search_tfidf, k=1, k_clusters=2, return_distance=False)
result = result_array[0]

print("Top matched QA:")
print('=====================')
for id in result:
print('Q:' + qa[int(id)]['q'])
print('A:' + qa[int(id)]['a'])
print('=====================')

测试效果

在我的渣本上跑了下,速度和匹配度也都还可以,感兴趣的同学可以从 GitHub 上 Clone 一份到本地跑一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 建立索引
python gen.py
# 查找匹配的问题
python search.py "第一个飞上太空的人是谁"
# 以下为程序输出内容
Building prefix dict from the default dictionary ...
Loading model from cache C:\Users\swxua\AppData\Local\Temp\jieba.cache
Loading model cost 0.791 seconds.
Prefix dict has been built succesfully.
Top matched QA:
=====================
Q:谁是第一个进入太空的人
A:推荐答案世界公认的第一位太空人是前苏联的尤里·阿列克谢耶维奇·加加林,他于1957年进入太空
=====================

小结

词袋模型 + TF-IDF 能够实现基本的匹配功能,对于要求不高的应用场景,一般来说是够用的。同时,它的缺点也比较明显:匹配时词与词之间是完全「孤立」的,如果我们把问题中的词换成相应的近义词,匹配结果可能是乱七八糟的东西。