三十-第一版聊天机器人小二兔

在上一节中我分享了建设好的影视剧字幕聊天语料库,本节基于这个语料库开发我们的聊天机器人,因为是第一版,所以机器人的思绪还有点小乱,答非所问、驴唇不对马嘴得比较搞笑,大家凑合玩

第一版思路

首先要考虑到我的影视剧字幕聊天语料库特点,它是把影视剧里面的说话内容一句一句以回车换行罗列的三千多万条中国话,那么相邻的第二句其实就很可能是第一句的最好的回答,另外,如果对于一个问句有很多种回答,那么我们可以根据相关程度以及历史聊天记录来把所有回答排个序,找到最优的那个,这么说来这是一个搜索和排序的过程。对!没错!我们可以借助搜索技术来做第一版。

lucene+ik

lucene是一款开源免费的搜索引擎库,java语言开发。ik全称是IKAnalyzer,是一个开源中文切词工具。我们可以利用这两个工具来对语料库做切词建索引,并通过文本搜索的方式做文本相关性检索,然后把下一句取出来作为答案候选集,然后再通过各种方式做答案排序,当然这个排序是很有学问的,聊天机器人有没有智能一半程度上体现在了这里(还有一半体现在对问题的分析上),本节我们的主要目的是打通这一套机制,至于“智能”这件事我们以后逐个拆解开来不断研究。

建索引

首先用eclipse创建一个maven工程,如下:

maven帮我们自动生成了pom.xml文件,这配置了包依赖信息,我们在dependencies标签中添加如下依赖:

 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
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-core</artifactId>
    <version>4.10.4</version>
</dependency>
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-queryparser</artifactId>
    <version>4.10.4</version>
</dependency>
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-analyzers-common</artifactId>
    <version>4.10.4</version>
</dependency>
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>5.0.0.Alpha2</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.1.41</version>
</dependency>

并在project标签中增加如下配置,使得依赖的jar包都能自动拷贝到lib目录下:

 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
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-dependency-plugin</artifactId>
      <executions>
        <execution>
          <id>copy-dependencies</id>
          <phase>prepare-package</phase>
          <goals>
            <goal>copy-dependencies</goal>
          </goals>
          <configuration>
            <outputDirectory>${project.build.directory}/lib</outputDirectory>
            <overWriteReleases>false</overWriteReleases>
            <overWriteSnapshots>false</overWriteSnapshots>
            <overWriteIfNewer>true</overWriteIfNewer>
          </configuration>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
        <archive>
          <manifest>
            <addClasspath>true</addClasspath>
            <classpathPrefix>lib/</classpathPrefix>
            <mainClass>theMainClass</mainClass>
          </manifest>
        </archive>
      </configuration>
    </plugin>
  </plugins>
</build>

ik-analyzer下载ik的源代码并把其中的src/org目录拷贝到chatbotv1工程的src/main/java下,然后刷新maven工程,效果如下:

com.shareditor.chatbotv1包下maven帮我们自动生成了App.java,为了辨识我们改成Indexer.java,关键代码如下:

 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
Analyzer analyzer = new IKAnalyzer(true);
IndexWriterConfig iwc = new IndexWriterConfig(Version.LUCENE_4_9, analyzer);
iwc.setOpenMode(OpenMode.CREATE);
iwc.setUseCompoundFile(true);
IndexWriter indexWriter = new IndexWriter(FSDirectory.open(new File(indexPath)), iwc);

BufferedReader br = new BufferedReader(new InputStreamReader(
        new FileInputStream(corpusPath), "UTF-8"));
String line = "";
String last = "";
long lineNum = 0;
while ((line = br.readLine()) != null) {
    line = line.trim();

    if (0 == line.length()) {
        continue;
    }

    if (!last.equals("")) {
        Document doc = new Document();
        doc.add(new TextField("question", last, Store.YES));
        doc.add(new StoredField("answer", line));
        indexWriter.addDocument(doc);
    }
    last = line;
    lineNum++;
    if (lineNum % 100000 == 0) {
        System.out.println("add doc " + lineNum);
    }
}
br.close();

indexWriter.forceMerge(1);
indexWriter.close();

编译好后拷贝src/main/resources下的所有文件到target目录下,并在target目录下执行

1
java -cp $CLASSPATH:./lib/:./chatbotv1-0.0.1-SNAPSHOT.jar com.shareditor.chatbotv1.Indexer ../../subtitle/raw_subtitles/subtitle.corpus ./index

最终生成的索引目录index通过lukeall-4.9.0.jar查看如下:

检索服务

基于netty创建一个http服务server,代码共享在的chatbotv1目录下,关键代码如下:

 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
Analyzer analyzer = new IKAnalyzer(true);
QueryParser qp = new QueryParser(Version.LUCENE_4_9, "question", analyzer);
if (topDocs.totalHits == 0) {
    qp.setDefaultOperator(Operator.AND);
    query = qp.parse(q);
    System.out.println(query.toString());
    indexSearcher.search(query, collector);
    topDocs = collector.topDocs();
}

if (topDocs.totalHits == 0) {
    qp.setDefaultOperator(Operator.OR);
    query = qp.parse(q);
    System.out.println(query.toString());
    indexSearcher.search(query, collector);
    topDocs = collector.topDocs();
}


ret.put("total", topDocs.totalHits);
ret.put("q", q);
JSONArray result = new JSONArray();
for (ScoreDoc d : topDocs.scoreDocs) {
    Document doc = indexSearcher.doc(d.doc);
    String question = doc.get("question");
    String answer = doc.get("answer");
    JSONObject item = new JSONObject();
    item.put("question", question);
    item.put("answer", answer);
    item.put("score", d.score);
    item.put("doc", d.doc);
    result.add(item);
}
ret.put("result", result);

其实就是查询建好的索引,通过query词做切词拼lucene query,然后检索索引的question字段,匹配上的返回answer字段的值作为候选集,使用时挑出候选集里的一条作为答案

这个server可以通过http访问,如http://127.0.0.1:8765/?q=hello(注意:如果是中文需要转成urlcode发送,因为java端读取时按照urlcode解析),server的启动方法是:

1
java -cp $CLASSPATH:./lib/:./chatbotv1-0.0.1-SNAPSHOT.jar com.shareditor.chatbotv1.Searcher

聊天界面

先看下我们的界面是什么样的,然后再说怎么做的

首先需要有一个可以展示聊天内容的框框,我们选择ckeditor,因为它支持html格式内容的展示,然后就是一个输入框和发送按钮,html代码如下:

 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
<div class="col-sm-4 col-xs-10">
    <div class="row">
        <textarea id="chatarea">
            <div style='color: blue; text-align: left; padding: 5px;'>机器人: 喂,大哥您好,您终于肯跟我聊天了,来侃侃呗,我来者不拒!</div>
            <div style='color: blue; text-align: left; padding: 5px;'>机器人: 啥?你问我怎么这么聪明会聊天?因为我刚刚吃了一堆影视剧字幕!</div>
        </textarea>
    </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>
</div>
<script type="text/javascript">

        CKEDITOR.replace('chatarea',
                {
                    readOnly: true,
                    toolbar: ['Source'],
                    height: 500,
                    removePlugins: 'elementspath',
                    resize_enabled: false,
                    allowedContent: true
                });
</script>

为了调用上面的聊天server,需要实现一个发送请求获取结果的控制器,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public function queryAction(Request $request)
{
    $q = $request->get('input');
    $opts = array(
        'http'=>array(
            'method'=>"GET",
            'timeout'=>60,
        )
    );
    $context = stream_context_create($opts);
    $clientIp = $request->getClientIp();
    $response = file_get_contents('http://127.0.0.1:8765/?q=' . urlencode($q) . '&clientIp=' . $clientIp, false, $context);
    $res = json_decode($response, true);
    $total = $res['total'];
    $result = '';
    if ($total > 0) {
        $result = $res['result'][0]['answer'];
    }
    return new Response($result);
}

这个控制器的路由配置为:

1
2
3
chatbot_query:
    path:     /chatbot/query
    defaults: { _controller: AppBundle:ChatBot:query }

因为聊天server响应时间比较长,为了不导致web界面卡住,我们在执行submit的时候异步发请求和收结果,如下:

 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
    var xmlHttp;
    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('');
        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);
    }

    function callback() {
        if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
            var responseText = xmlHttp.responseText;
            addText(responseText, true);
        }
    }

这里的addText是往ckeditor里添加一段文本,方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function addText(text, is_response) {
    var oldText = CKEDITOR.instances.chatarea.getData();
    var prefix = '';
    if (is_response) {
        prefix = "<div style='color: blue; text-align: left; padding: 5px;'>机器人: "
    } else {
        prefix = "<div style='color: darkgreen; text-align: right; padding: 5px;'>我: "
    }
    CKEDITOR.instances.chatarea.setData(oldText + "" + prefix + text + "</div>");
}

以上所有代码全都共享在https://github.com/warmheartli/ChatBotCourse和https://github.com/warmheartli/shareditor.com中供参考

和机器人对话初体验

经过以上几部,我们的整套聊天机器人体系就搭建好了,地址在:http://www.shareditor.com/chatbot/,看下效果吧

虽然效果暂时还不咋地,但是整体流程建起来了,后面就是不断完善算法了,也希望牛人们多指点方案