一个简单的FAQ问答系统实现。基于检索和排序的两阶段框架,检索阶段基于Elasticsearch检索引擎、排序阶段基于语义匹配深度学习模型。后端基于SpringBoot系列框架。
FAQ问答大概的对话流程示意图如下:
系统的大致框架如下图所示:
以对话为例说一下系统各个模块的协同:
- 客户端带着
用户问题
向后台发送HTTP请求; - 后台接受到请求,对
用户问题
进行ES检索,返回N(可配置)个相关的初始候选集; - 后台通过HTTP请求调用相似度计算服务,对用户问题和N个候选句子一一成对进行相似度计算,返回它们的相似度;
- 后台结合ES相关度和模型相似度进行综合排序,取综合得分最高的结果对应的答案返回给前端;
- 前端显示;
值得一提的是,Redis的作用是对话状态管理,即每一个用户于系统交互都会在Redis中创建一个与之对应的对话状态(dialogue status),这个对话状态可以用来区分不同用户,也可以用来进行多轮对话(保存上一步对话的节点数据)。
对话是核心功能,提供一问一答的交互式方式。
人机对话:用户提出问题,系统给出回答。
FAQ问答对持久化保存在MySQL中,管理员只需维护MySQL中的数据。但是在对话时,系统不会去访问MySQL,而是通过ES检索引擎进行检索。因此,保证MySQL和ES的数据一致非常重要。
全量同步:将MySQL中的问答对数据全部同步到ES索引中。 更新多轮问答树:多轮问答基于多轮JSON,逻辑上为树的组织结构,需要将JSON文件读取到Redis中存起来。
技术 | 说明 |
---|---|
Spring Boot | Java后端框架 |
Mybatis | Java持久层框架 |
Rest high level client | Elasticsearch Java 客户端工具 |
除了上表之外,还用了lombok、Swagger2等框架。
技术 | 说明 |
---|---|
MySQL | 关系型数据库 |
Redis | 缓存数据库 |
Elasticsearch | 全文检索引擎 |
技术 | 说明 |
---|---|
ChatUI | Alibaba开源的对话UI |
前端语言主要为JS。
- 建库
# 在mysql命令行执行下面建库语句
CREATE DATABASE IF NOT EXISTS faqdb DEFAULT CHARSET utf8 COLLATE utf8_general_ci;
- 导入表数据
# 到sql/路径下,可以看faqdb.sql,在mysql命令行执行下面语句
source faqdb.sql;
关于ES的安装可以参考我之前的文章,如果嫌麻烦,可以直接下载我配置好分词器的ES。
#百度云地址如下
链接:https://pan.baidu.com/s/13pz110aaeDsgPdfb_cSIeQ
提取码:nazi
相似度算法是对话系统的关键组成部分,如果只是单纯的测试系统能不能跑起来,那可以先不管这部分。如果想要系统识别的准确度更高,最好关注一下相似度算法的配置,也可以使用自己的相似度计算模型。
本文也开源了基于百度ERNIE模型微改的相似度计算模型,基本上只是改变了模型的输入输出,并部署为可以接受HTTP请求的服务。开源地址如下:
这三个数据库安装好以后就可以很简单的启动了,不再赘述。
修改application.yml
以更改应用、数据库等参数:
server:
port: 1234
servlet:
context-path: /faq
spring:
#mysql连接
datasource:
url: jdbc:mysql://localhost:3306/faqdb?characterEncoding=utf8&useSSL=false&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mvc:
throw-exception-if-no-handler-found: true
#redis连接
redis:
host: localhost
port: 6379
database: 6
jedis:
pool:
max-active: 50
min-idle: 20
#引入自定义配置,application-user.yml
profiles:
include:
- user
#es连接
elasticsearch:
host: localhost
port: 9200
修改application-user.yml
以更改系统人机对话等参数:
#检索配置
retrieval:
#es索引,用于和MySQL表同步和检索数据,默认mysql表和es索引同名
index:
#faq问答对
faq-pair: faq_pair
search:
#搜索返回的最相关docs的个数
size: 30
#存放常用elasticsearch API的路径
elasticsearch-API-path: data/elasticsearch_API
#相似度计算配置
similarity:
#本机
request-url: http://[::1]:6100/calculate_similarity
#对话配置
dialogue:
#置信度排序
confidence-rank:
#返回的置信度最高的doc的个数
size: 5
#置信度计算权重
weights:
#相关度权重
relevance-weight: 0.3
#相似度权重
similarity-weight: 0.7
#用户对话状态
status:
#过期时间(单位: minute)
expire-time: 2
#多轮问答树
multi-turn-qa:
path: data/multi_turn_qa
#redis热点数据缓存
hot-data:
#是否开启
open: true
#过期时间(单位: minute)
expire-time: 5
- 源码启动
运行启动类src/main/java/com/example/qa/QuestionAnsweringSystemApplication.java
- jar包启动
将项目打包jar,进入jar包所在目录,执行下面命令(将application.yml复制一份出来,用于随时修改配置而不用改代码)
java -jar faq.jar --spring.config.addition-location=application.yml
打开浏览器访问http://localhost:1234/faq/swagger-ui/
可以查看全部接口并进行测试。
打开ui/dialogue.html
进行界面交互。以下显示了单轮对话和多轮对话的简单示例。
整个FAQ问答系统就用了一张表,faq问答对,名称为faq_pair,表结构如下:
字段名 | 字段类型 | 是否可为空 | 键 | 注释 |
---|---|---|---|---|
id | int(11) | NO | PRI | |
qa_id | int(11) | NO | UNI | 标准问-标准答的唯一标识id |
standard_question | text | NO | 标准问,表示高频问题 | |
standard_answer | text | NO | 标准答,表示高频问题对应的回答 |
在包目录com/example/faq/response/
中定义了状态码及其说明的枚举类CodeMsg
,用于配置一些自定义的状态码和说明。
这些状态码可以用于定位问题所在,也可以让前端区分不同的返回值代表的含义等等。
public enum CodeMsg {
//通用状态码10000系列,模块异常
ELASTICSEARCH_EXCEPTION(10001, "elasticsearch异常"),
MYSQL_EXCEPTION(10002, "mysql异常"),
SIMILARITY_NULL_EXCEPTION(10003, "相似度计算模型异常"),
//通用状态码20000系列,有返回值,无异常
SUCCESS(20000, "success"),
SUCCESS_SINGLE(20001, "success-->单轮"),
SUCCESS_MULTI(20002, "success-->多轮"),
//通用状态码30000系列,中间状态
OPTIONS_NOT_HIT(30001, "处于多轮问答中,但未命中多轮问答的选项,此时将重新检索用户问题"),
//通用状态码40000系列,无返回值
FAILED(40000, "failed"),
UNRECOGNIZED_QUESTION(40001, "failed-->无法识别的问题"),
MULTI_ROUND_QA_NOT_FOUND(40002, "failed-->没有找到对应的多轮问答树"),
MULTI_ROUND_QA_NULL(40003, "failed-->redis中多轮问答树为空"),
MULTI_ROUND_QA_CHILD_NODE_NULL(40004, "failed-->多轮问答树子节点为空");
}
在项目目录src/main/resources/
中定义了用户配置文件application-user.yml
,通过在SpringBoot默认配置文件application.yml
中配置以下参数引入该配置文件
spring:
#引入自定义配置,application-user.yml
profiles:
include:
- user
application-user.yml
中的配置参数如下,均通过SpringBoot注解@ConfigurationProperties
的方式注入。
以dialogue参数为例:
#对话配置
dialogue:
#置信度排序
confidence-rank:
#返回的置信度最高的doc的个数
size: 5
#置信度计算权重
weights:
#相关度权重
relevance-weight: 0.3
#相似度权重
similarity-weight: 0.7
#用户对话状态
status:
#过期时间(单位: minute)
expire-time: 2
#多轮问答树
multi-turn-qa:
path: data/multi_turn_qa
#redis热点数据缓存
hot-data:
#是否开启
open: true
#过期时间(单位: minute)
expire-time: 5
@Configuration
@ConfigurationProperties(prefix = "dialogue")
@Data
public class DialogueConfig {
private ConfidenceRank confidenceRank;
private Status status;
private MultiRoundQa multiTurnQa;
private HotData hotData;
//redis中多轮问答树的key前缀
private final String MQATreeKeyPrefix = "MQATreeNode_";
//redis中question映射id的key
private final String MQAQuestion2idKey = "MQA_question2id";
//redis中用户对话状态的key前缀
private final String DialogueStatusKeyPrefix = "dialogue_status_userId_";
//redis中热点数据的question映射id的key
private final String HotDataQuestion2idKey = "hot_data_question2id";
//redis中热点数据的key前缀
private final String HotDataKeyPrefix = "hot_data_";
@Data
public static class ConfidenceRank {
private Integer size;
private Weights weights;
private Float threshold;
@Data
public static class Weights {
private Float relevanceWeight;
private Float similarityWeight;
}
}
@Data
public static class Status {
private Integer expireTime;
}
@Data
public static class MultiRoundQa {
private String path;
}
@Data
public static class HotData {
private Boolean open;
private Integer expireTime;
}
}
用的也是推荐的依赖包,pom依赖如下:
<!-- 更推荐的读取配置文件的处理器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
对话流程表示从用户问题输入,到找到答案输出的流程,流程图如下。
当系统判定对话状态的是否为多轮为true
时,将走多轮问答处理流程,多轮问答处理基于多轮对话树,一颗多轮对话树的结构如下图所示:
在包目录com/example/faq/dataobject
中定义了多轮对话树对应的数据对象MultiQaTreeNode
public class MultiQaTreeNode implements Serializable {
//对应的qaId,一棵多轮问答树不同层节点的qaId是相同的,都为根节点question所对应的qaId
private Integer qaId;
//当前节点的问题
private String question;
//当前节点的回答
private String answer;
//当前节点的子节点
private List<MultiQaTreeNode> childNodes;
}
一颗多轮对话树在物理上为一个json文件,在更新多轮对话树时json文件将被转换成数据对象MultiQaTreeNode
,然后添加到redis中。
{
"qaId": 3,
"question": "推荐一个景点",
"answer": "好的,请问对景点评分有要求吗",
"childNodes": [
{
"question": "没要求",
"answer": "好的,请问景点票价可接受范围?",
"childNodes": [
{
"question": "免费",
"answer": "附近好多公园呢,比如xxx,今天天气不错,可以去转转。",
"childNodes": []
},
{
"question": "50元以内",
"answer": "这个xx不错,自然风光秀丽,离您也不远。",
"childNodes": []
},
{
"question": "无所谓,不差钱",
"answer": "推荐xxx景点给您呢,该景点绝对符合您的气质",
"childNodes": []
}
]
},
{
"question": "3分以上",
"answer": "可玩的就比较多了,有xx...",
"childNodes": []
},
{
"question": "5分",
"answer": "在xx那有一处5A景区,评分有5分呢,推荐您去玩哈。",
"childNodes": []
}
]
}
也可以自定义文件格式,然后重写转换方法。(com/example/faq/service/impl/DialogueServiceImpl.java
的readFileToObject
)