内容简介:动手学做聊天机器人系列之三:从0到1构建一个Chatbot
文·blogchong
接上一篇 《动手学做聊天机器人系列之二:大数据聊天机器人小虫诞生记》 ,这篇我来聊聊数据虫巢-小虫技术的实现。
我们的目标很明确,针对于大数据领域的chatbot,而不是通用型聊天机器人。
所以,我们需要专门的大数据对话式聊天语料作为训练语料。
语料获取。
如果语料具有垂直性,所以从目前已知的渠道中还是比较难以获取到的,因为目前市面中能够获取到的也就是一些相对通用的对话训练语料。
所以,对于构建一个大数据领域的对话机器人,如何获取大数据对话语料是一个大问题。
我(公众号:数据虫巢,ID: blogchong)12年开始入行,期间加入了不少的大数据相关的QQ群,基本都是围绕大数据话题的社群。
所以在想,聊天群里的聊天记录是否可以作为训练大数据对话聊天机器人的语料呢?
我(公众号:数据虫巢,ID:blogchong)把六七个大数据相关的QQ群近一年的(大腾讯只允许导出最近一年的聊天记录)聊天记录导出,然后对于一些异常信息(比如系统提示信息等)做了过滤,去掉了发言的人,共累计近12万的聊天记录,大概长下面这样。
看起来勉强可用吧(没其他法子了,凑合用吧),然后我(公众号:数据虫巢,ID:blogchong)把每相邻的两句作为一个对话,一问一答,也就拥有了近12万的垂直领域对话。
至此,语料问题解决。
技术框架选型。
我们在第一篇 《动手学做聊天机器人系列之一:什么是聊天机器人》 中提到过,进行chatbot设计,从技术的角度看有三种:基于人工模板,基于搜索技术,以及基于深度学习。
基于人工模板,最有名的莫过于Alice的开源了(在github上很容易搜索到开源代码)。
但是基于AIML语言的chatbot,其中最大的挑战莫过于模板的整理,这对于当前有各式各样询问需求的用户来说,显然是很难达到目的的(我们总不能把那12万的语料进行模板加工处理吧,工程量太大)。
所以,在这里AIML的路子走不通,它适用于固定问答模式的情景,类似一些智能客服领域,很多询问都是固定的。
关于AIML做个补充,原生的Alice是不支持中文的,需要修改一定的源码,以及支持中文分词等过程,网上有挺多参考的例子,这里就不多说了。
基于神经网络的chatbot,这应该是那些大厂构建chatbot的主流思路。
但是,却不是我的主流思路,因为从目前的角度来说,没有深度学习基础去做一个基于深度学习的chatbot,显然挑战太大,不适合入门(俺要是能轻轻松松写个牛逼的深度学习chatbot,还会坐在这里写文章么 哈哈)。
所以,这里我(公众号:数据虫巢,ID:blogchong)选择搜索技术为主,作为从0到1构建chatbot的技术模型。
切确的说,是以ElasticSearch为核心的搜索引擎,以搜索的思路来解决对话的问题。
技术思路。
如上所说,我们(公众号:数据虫巢,ID:blogchong)选择ES作为核心的搜索引擎,通过搜索的思路来解决chatbot的对话问题。
简单的过程就是:
1 我们把相邻的对话语料,分成一问一答的对话模式。
2 我们通过为问答构建索引,通过搜索的技术方式来解决精确问答的问题(这是比模板模式更灵活所在,但是会牺牲一定的精确性)。
在这里,我(公众号:数据虫巢,ID:blogchong)虽然对问答都进行了全文索引构建,但是在实际的操作过程中,比如问句搜索中,只搜索问句,获取相应的问题答案。
并且,由于语料本身具有一定的瑕疵(好吧,其实瑕疵很大),所以在搜索获取回答的时候,在匹配满足一定程度的基础上,做了一定随机答案的获取处理。
一方面是本身语料不准的无奈之举(在匹配度满足的情况下,万一不小心刚好对上一个好回答呢),另一方面就是增加趣味性了(不至于每次问的同一个问题,但回答的是一毛一样的)。
ES的安装搭建过程。
由于我们技术选型是ES,所以我们首选需要搭建一个ES。
在这里,我选择了elasticsearch-2.4.4作为我的ES版本,选择的依据是,版本不能太高,太高难以找到参考,并且出现bug难以解决,所以选择一个相对稳定的版本即可。
官网下载一个elasticsearch-2.4.3.tar.gz压缩包,使用如下命令进行解压到指定位置。
tar xf elasticsearch-2.4.3.tar.gz -C ./
为了方便操作,建立一个软链。
ln -sv /data/application/mitechat_bot/elasticsearch-2.4.3 /data/application/mitechat_bot/elasticsearch
开始安装相关插件,第一是head,该插件用于es的集群管理,是es集群交互的web前台,在安装目录下执行如下。
bin/plugin install mobz/elasticsearch-head
图片来自网络,我自己的就不截图了,之前为了安全,把host配置了内网ID,所以不太方便截图就拉倒了。
head工具查看地址(默认是9200,可以自行到config文件修改访问端口):
http://host:9200/_plugin/head/
elasticsearch-kopf工具的安装,该 工具 提供es的管理,当然也提供一些API的交互,与head部分功能有重叠,自行体验。
bin/plugin install lmenezes/elasticsearch-kopf
同上,来自网络,自己的配置的是内网host,不方便截图。
工具查看的地址如下(端口同样也是默认的9200,建议都配置成自己的端口,更安全)。
http://host:9200/_plugin/kopf/#!/cluster
第三个组件,analysis-icu (International Component for Unicode/Unicode国际化组件)简称,用来解决编码与语言问题 ,安装命令如下。
bin/plugin install analysis-icu
由于我们使用了中文的全文检索,所以需要一个分词器,默认推荐使用ik,所以我们需要安装一个ik分词器组件。
最简单的方式是,官网下载一个编译好的zip包(找不到的可以找我要,注意与es对应的版本,不然会提示版本不匹配,无法启动),解压到es的安装目录下,命令如下。
unzip -o ./elasticsearch-analysis-ik-1.10.3.zip -d ./elasticsearch/plugins/ik
至此,所有插件安装完毕,可以启动啦。
需要注意的是,es使用root用户是起不来的,所以需要专门生成一个用户,用来启动它。
比如,我们生成一个专门用户,然后为es所有目录赋予该用户以及用户组权限。
useradd elasticsearch
chown -R elasticsearch.elasticsearch /data/application/mitechat_bot/elasticsearch-2.4.3
在这里我们需要注意,当使用chown进行权限赋予的时候,一定要定位到实际的文件夹位置,而不是软链。
最后,我们使用该用户来启动它吧,顺便让他在后台默默运行。
su - elasticsearch -c "/data/application/mitechat_bot/elasticsearch/bin/elasticsearch &"
我们使用一个地址来检测是否启动成功,当然,我们这里启动的是单节点的es,自己玩够用啦,命令如下。
http://host:9200/_cluster/health?pretty
在这里,我们就可以看到集群名、es集群的master,节点数,健康状态等等。
内部逻辑实现过程。
首先是索引的创建过程,在这里,我们(公众号:数据虫巢,ID:blogchong)先创建一个具有两个字段的Mapping模板。
索引名字是mite_chat,类型是chat_str,共两个字段str_ask,以及str_answe,两个字段由于都需要进行检索,所以都进行ik分词,其中调用PUT模式,具体如下:
host:port/mite_chat
{
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1
},
"mappings": {
"chat_str": {
"date_detection": false,
"dynamic_templates": [{
"es_string": {
"match": "*",
"match_mapping_type": "string",
"mapping": {
"type": "string",
"index": "analyzed",
"analyzer": "ik_max_word"
}
}
}],
"properties": {
"str_ask": {
"type": "string",
"index": "analyzed",
"analyzer": "ik_max_word"
},
"str_answe": {
"type": "string",
"index": "analyzed",
"analyzer": "ik_max_word"
}
}
}
}
}
然后对所有的问答进行索引构建,我们使用了spring boot框架的代码逻辑(这在里,我提前已经把语料处理入库了),具体如下。
private RequestConfig requestConfig = RequestConfig. custom ()
.setSocketTimeout( 15000 )
.setConnectTimeout( 15000 )
.setConnectionRequestTimeout( 15000 )
.build() ;
public HttpResult doPutJson (String url , String json) throws ClientProtocolException , IOException {
CloseableHttpClient httpClient = null;
CloseableHttpResponse response = null;
HttpEntity entity = null;
String responseContent = null;
// 创建http PUT请求
httpClient = HttpClients. createDefault () ;
HttpPut httpPut = new HttpPut(url) ;
httpPut.setConfig( requestConfig ) ;
if (json != null ) {
// 构造一个form表单式的实体
StringEntity stringEntity = new StringEntity(json , ContentType. APPLICATION_JSON ) ;
// 将请求实体设置到httpPost对象中
httpPut.setEntity(stringEntity) ;
}
try {
// 执行请求
response = httpClient.execute(httpPut) ;
return new HttpResult(response.getStatusLine().getStatusCode() ,
EntityUtils. toString (response.getEntity() , "UTF-8" )) ;
} finally {
if (response != null ) {
response.close() ;
}
}
}
PS: 囧,由于没有找到spring boot与es结合构建索引的过程,所以这里就直接调用httpclient构建一个PUT请求,通过API来构建聊天记录的索引了。
其中url如下,默认端口是9300,可修改,mite_chat是索引名称,chat_str是索引类型,而id就是索引中的唯一id,我们在入库时有自增id作为唯一id,可以直接使用。
http://host:9300/mite_chat/chat_str/{id}
然后总记录是12万条,也很快,基本“唰唰”所有记录都已经构建索引完毕。
接下来就是包装一个检索过程来实现传入问句,获取答案了。
在网上有比较多的spring boot与es集成例子,所以我们这里就直接使用spring boot内嵌的es集成实例了。
首先是spring boot配置,在默认的application.properties中,配置es相关的链接配置,如下。
# elasticsearch相关配置
spring.data.elasticsearch.cluster-name = mite_chat_es
spring.data.elasticsearch.cluster-nodes = 10.24.219.232:14600
PS:第一个配置为集群名称,在es的安装目录下的config/elasticsearch.yml可以进行配置,第二个配置为集群的连接host以及端口,这里只有一个节点,所以配置一个,如果有多个,可以英文逗号分隔。
下面为核心查询的逻辑。
public static JSONEntity<ChatStrEntity> multiMatchQuery (Client client , String str , int size){
JSONEntity<ChatStrEntity> obj = new JSONEntity<>() ;
//需要在文档中的哪些字段中查询
QueryBuilder qb= QueryBuilders. multiMatchQuery (str , "str_ask" , "str_answe" ) //夸字段检索,可设置type来决定分值高低
.type( "best_fields" ).field( "str_ask" , 2f ).field( "str_answe" , 1.f ) ;
//过滤查询已过时,已采用bool查询替代
SearchHits hits =EsQuery. query (client , qb , INDEX , TYPE ) ;
SearchHit[] shits = hits.getHits() ;
int num =( int ) hits.getTotalHits() ;
if (num>size){
num=size ;
}
obj.setCount(num) ;
List<ChatStrEntity> list = new ArrayList<>() ;
for ( int i= 0 ; i<shits. length ; i++) {
Map<String , Object> source = shits[i].getSource() ;
list.add( new ChatStrEntity(source , shits[i].getScore() , shits[i].getId())) ;
}
List<ChatStrEntity> list1 = new ArrayList<>() ;
int count = 0 ;
for (ChatStrEntity chatStrEntity:list){
if (count < size){
list1.add(chatStrEntity) ;
count++ ;
} else {
break;
}
}
obj.setList(list1) ;
return obj ;
}
其中str为问句的字符串,client可以通过spring boot的Autowired注解获得ElasticsearchTemplate对象,通过该对象获取client,包括调用的代码如下。
@Autowired
private ElasticsearchTemplate es ;
public List<ChatStrEntity> multiMatchQuery (String query ,int size){
JSONEntity<ChatStrEntity> je = ChatStrDao. multiMatchQuery ( es .getClient() , query , size) ;
List<ChatStrEntity> list = je.getList() ;
return list ;
}
其中query调用过程如下。
public class EsQuery {
//默认查询的记录只返回2000条数据
public static SearchHits query (Client client , QueryBuilder qb , String index , String type){
return query (client , qb , index , type , 2000 ) ;
}
//设置es的返回结果数量的查询
public static SearchHits query (Client client , QueryBuilder qb , String index , String type , int size){
SearchResponse response =client.prepareSearch(index).setTypes(type).setSize(size)
//.setSearchType(SearchType.QUERY_THEN_FETCH)
.setQuery(qb) .execute().actionGet() ;
SearchHits hits = response.getHits() ;
return hits ;
}
上面只是逻辑层的service,在实际的chatbot的回调逻辑service中,如下,做了一些逻辑处理。
public String multiMatchQuery (String query ,int size){
String retStr = null;
//做安全监测
retStr = SafeAnswe. InitJudge (query) ;
if (retStr != null ){
return retStr ;
}
Random random = new Random() ;
int randomNum = random.nextInt( 100 ) ;
if (randomNum % 25 == 0 ){
return SafeAnswe. randomSafe (SafeAnswe. safeListsRand ) ;
}
JSONEntity<ChatStrEntity> je = ChatStrDao. multiMatchQuery ( es .getClient() , query , size) ;
List<ChatStrEntity> list = je.getList() ;
List<String> listRet = new ArrayList<>() ;
for (ChatStrEntity chatStrEntity: list){
//listRet.add(chatStrEntity.getStr_ask());
listRet.add(chatStrEntity.getStr_answe()) ;
}
if (listRet.size() != 0 ) {
int count = 0 ;
while (query.equals(retStr) || retStr == null ) {
if (count>size){
retStr = SafeAnswe. randomSafe (SafeAnswe. safeListsNo ) ;
break;
} else {
int num = random.nextInt(listRet.size() - 1 ) ;
retStr = listRet.get(num) ;
count++ ;
}
}
} else {
retStr = SafeAnswe. randomSafe (SafeAnswe. safeListsNo ) ;
}
return retStr ;
}
我们可以看到,第一个逻辑做了安全问题回到,以及做异常监测,具体监测。
//异常处理以及安全监测
public static String InitJudge (String input){
//做长度判断
if (input.length() > 200 ){
return randomSafe ( safeListsMuch ) ;
} else if (input.matches( ".*你好.*" ) ||
input.matches( ".*hello.*" )){
return randomSafe ( safeListsHello ) ;
} else if (input.matches( ".*你是谁.*" ) ||
input.matches( ".*你叫什么名字.*" ) ||
input.matches( ".*你的名字.*" ) ||
input.matches( ".*你是?.*" ) ||
input.matches( ".*你是 \\ ?.*" ) ||
input.matches( ".*你叫什么名字.*" ) ||
input.matches( ".*你叫什么.*" )){
return randomSafe ( safeListsWho ) ;
} else if (input.matches( ".*你多大.*" ) ||
input.matches( ".*几岁.*" ) ||
input.matches( ".*年龄多大.*" ) ||
input.matches( ".*多大了.*" )){
return randomSafe ( safeListsAge ) ;
} else if (input.matches( ".*数据虫巢.*" )){
return randomSafe ( safeListsBlogchong ) ;
} else {
return null;
}
}
在这里,对于一些常规的问答模板,做了定制化的处理,其实有点像AIML的处理过程,包括了问你是谁呀,年龄啊等,并且为了增加趣味性,在回答里做了随机答案处理,以及对问句长度进行了检测。
//问题过长
public static List<String> safeListsMuch = Arrays. asList (
"你一下子问的太多了,能不能一个一个来!" ,
"罗里吧嗦一大推,能简单点说嘛?" ,
"你这是在问问题还是在写作文?" ,
"你问的这一坨,只有神才能回到的出来,(⊙﹏⊙)b" ,
"隔壁家的siri都未必知道吧,反正我是不知道。" ,
"你知道答案吗?!" ,
"建议你去问问隔壁家的小娜姐姐。" ,
"你是故意的吧,再这样我就不陪你玩了!" ,
"。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。" ) ;
至于这些safelist就是各种随机string的list了。
再接上面chatbot的逻辑service,进行了25%的概率进行随机安全回答,根据有趣味性,其中 safeListsRand 如下。
//随机趣味问答
public static List<String> safeListsRand = Arrays. asList (
"这种问题用的着回答吗" ,
"小虫不想回答你的问题,并向你抛出了一个bug,所以别问了,赶紧解决bug去吧~" ,
"给俺发红包了吗,没发回答个jj。" ,
"这个问题隔壁家的siri美美吧,太简单了,表示不屑回答。" ,
"隔壁家的小娜姐姐都不知道吧,你问我我问谁去呀" ,
"爱玛,俺在思考人生呢,等会儿再想这个问题。" ,
"起开,今天不想再说好了" ,
"这个问题上次就问过小冰妹妹,她也不知道。" ,
"O(∩_∩)O哈哈~,先睡一觉再说Z~~" ) ;
经过安全检测,以及25%的安全趣味回答之后,才会走到ES的逻辑。
在ES逻辑里,由于样本语料的不完整性,所以默认ES检索匹配的不一定的最佳的,这里我(公众号:数据虫巢,ID:blogchong)选择了默认 排序 的前Top5,做了随机回答选择。
最后,在controller层做接口输出。
@Autowired
private MiteChatService miteChatService ;
@RequestMapping ( value = "/query" )
public String miteChat (HttpServletRequest httpServletRequest ,
@RequestParam ( value = "input" ) String input ,
@RequestParam ( value = "size" , defaultValue = "5" ) int size) throws Exception{
long beginTime = TransferTime. dateToLong ( new Date()) ;
String retStr = miteChatService .multiMatchQuery(input , size) ;
long endTime = TransferTime. dateToLong ( new Date()) ;
logger .info( "IP[" + GetAddrHostUtils. getAddrHost (httpServletRequest)+ "] MODULE[/chatbot/query] INPUT[" +input+ "] SERVICE_TIME[" +(endTime-beginTime)+ "ms]" ) ;
return retStr ;
}
最后我们来看下前端这里,怎么构建。
先来看看前端的效果是怎么样的。
主要就是内容聊天框架,这里选择了cheditor,它支持html的内容展示,再加上一个输入框和发送按钮,html代码如下。
<div class= "col-sm-4 col-xs-10" >
<div id= "rabbit-left" style= "position: absolute;left: -50px; top: 400px;display: none;" ><img width= "50px" height= "62px" src= "/images/bot_logo.png" /></div>
<div class= "row" >
<textarea id= "chatarea" style= "background-color: #00A6C7" >
<div id= "container-div" style= "padding:10px;padding-top: 50px;
background-image:url(/images/bot_logo.png);
background-repeat:no-repeat;
background-position:left top;
background-blend-mode:normal;
background-attachment:fixed;
background-size: 61 px 50 px;
background-position: 20 px 0 px ">
<div style= 'color: blue; text-align: left; padding: 5px;' >小虫: 俺是虫巢君粑粑的仔仔,小虫,你可以跟我聊聊大数据的东西,我知道不少哟!</div>
<div style= 'color: blue; text-align: left; padding: 5px;' >小虫: 啥?为什么我这么聪明会聊天?因为我偷听了十多个大数据群的聊天,而且还跟小娜小冰姐妹学了几手!</div>
</div>
</textarea>
</div>
<div id= "info" style= "color: #FFFFFF; height:15px;" ></div>
<br />
<div class= "row" >
<div class= "input-group" >
<input type= "text" id= "input" class= "form-control" autofocus= "autofocus" onkeydown= "submitByEnter()" />
<span class= "input-group-btn" >
<button class= "btn btn-default" type= "button" onclick= "submit()" >发送</button>
</span>
</div>
</div>
然后在发送按钮处调用实际的chatbot聊天接口,实现一个发送请求获取结果的控制器,实际聊天调用的请求是/chatbot/query接口,获取返回数据 。
function submit() {
if (window.ActiveXObject) {
xmlHttp = new ActiveXObject( "Microsoft.XMLHTTP" );
}
else if (window.XMLHttpRequest) {
xmlHttp = new XMLHttpRequest();
}
var input = $( "#input" ).val().trim();
if (input == '' ) {
jQuery( '#input' ).val( '' );
return;
}
addText(input, false);
jQuery( '#input' ).val( '' );
jQuery( '#info' ).html( '小虫正在思考中,请稍后...' );
var datastr = "input=" + input;
datastr = encodeURI(datastr);
var url = "/chatbot/query" ;
xmlHttp.open( "POST" , url, true);
xmlHttp.onreadystatechange = callback;
xmlHttp.setRequestHeader( "Content-type" , "application/x-www-form-urlencoded" );
xmlHttp.send(datastr);
}
至于其他边边角角的展示,可以直接上官网对应的小虫机器人聊天模块,查看具体的html源码( http://www.mite8.com/chatbot/chat )。
到这里,基本上大体的思路,以及关键的核心代码都讲解完了,其实原则上来说就是一个搜索过程。
后续的规划。
我们知道,最终比较合理的chatbot形态一定是基于深度学习来做的,我们这里基于搜索来做只是一个入门,一个锻炼接触新事物的方式而已。
抛开目前的我(公众号:数据虫巢,ID:blogchong)对于深度学习用于chatbot的技能基本为零,这个点倒不是我所担心的,如果真的要学习一个新的技能,花点时间和精力问题还是不大。
最关键的问题在于,实际上我们的训练语料真的没有想象中好,比如会存在以下几个情况。
1 QQ群聊天不像一对一的聊天,可能会存在同时多人在聊情况,这意味着相邻的两个语句不一定是一问一答的模式。
2 整个聊天记录不是一个整体,因为全年的聊天不可能是不间断的,直接相邻处理,意味着强行把两次间断的聊天强行合并了。
语料的不良,上层架构再怎么改进,最终的效果可能都还会令人担忧,但纠结的是目前又没有更好的办法去获取新的语料。
本来小虫在实现时还会做进一步改良的,比如构建技术专有名词库,提升匹配的精度问题,以及集成AIML语言,做精确模板的匹配(能走AIML即走AIML模板,走不了的过搜索的方案),但由于语料问题,感觉做下去效果也不会太大,所以终止了。
所以,后续的规划中,可能会继续往深度学习里做全局的改造,也可能只是做局部趣味改良,比如使用深度学习让小虫学会作诗之类的。
这类例子在网上也是有迹可循的,所以入手起来应该会好点,也刚好体验了深度学习的魅力。
具体怎么操作,需要看后面的时间以及实际的尝试了,所以,你也可以期待这个系列的下一篇 哈哈。
最后。
不管用用什么技术实现,从0到1总算是把一个大数据对话聊天机器人给编写出来了。
效果的话,有时候答的很有趣合情合理,有时候抽风,但就大数据领域来说,勉强可以玩玩,其他流弊的chatbot遇到大数据垂直技术问题的时候一样懵逼(毕竟是通用型chatbot,没有专门的针对这方面的语料进行训练)。
所以,这就是一个锻炼以及试图熟悉对话聊天机器人领域的尝试,并且试图去熟悉深度学习等领域的知识(后续)。
最后,关于小虫机器人前端这块,我(公众号:数据虫巢,ID:blogchong)基本参考的是“ shareditor博客 ”上的(没办法,前端这块属于半路出家),所以特意谢鸣。
最最后,也希望大伙儿能多动手,尝试一些对自己技术有用,并且好玩的事,没坏处。
扩展阅读:
-
访问小虫大数据机器人,直接访问 “数据虫巢(www.mite8.com)” 网站,点击s网站首页侧边的小虫图标即可进入体验。
广而告之:
要不要学习如何编写一个属于自己的聊天机器人,一起探讨大数据、人工智能的相关的话题,是不是想要跨界进入大数据领域,欢迎加入 “数据虫巢读者私密群” ,目前是一个大数据技术超越百人小圈子,欢迎你的加入,并且我们的群会有不定期的分享活动哟。 =>> 戳此进入 。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。