干货 | Elasticsearch 向量搜索的工程化实战
死敌wen 稿
1、背景
作为一家搜索引擎公司,我们会很倚赖 ES
帮忙处理包括文章召回,数据源划分,实体、标签管理等任务,而且都收到了不错的结果。
最近我们需要对行业知识库进行建模,其中可能会涉及到实体匹配、模糊搜索、向量搜索等多种召回和算分方式,最终我们选择了通过 ES 7.X
(最终选择 7.10)里的新功能,Dense vector 帮忙一起完成这部分的需求。
2、技术选型
2.1 解决方案需求
- 支持向量搜索
- 支持多维度筛选、过滤
- 吞吐速率
- 学习、使用成本
- 运维成本
2.2 使用场景设计
-
离线数据准备
- 在离线数据构建完成后,存入该引擎
- 引擎对数据中各字段进行索引
-
在线数据召回
- 根据
query
理解结果构建的query
语句进行数据召回 - 对结果进行一定的筛选
- 对结果进行一定的打分排序
- 根据
2.3 数据结构设计
在确定了数据的使用场景我们确定了数据结构中,大致会包含以下一些字段
-
唯一 id:用以做知识的去重和快速获取
-
实体、属性、取值:用来描述知识的具体内容
-
置信度:用来描述知识的可信度
-
分类 flag:知识主要分类及推荐 category 等
-
向量表示:作为知识相似性、相关性召回、打分的依据
-
ref 信息:用来回溯解析/获取该知识的源信息
-
其他属性:包括生效、删除、修改时间等支持性的通用属性
2.4 解决方案对比
为了能支持上述的使用需求,我们对比了包括 ES
、Faiss
等多种解决方案。其中,Faiss
和 SPTAG
只是核心算法库,需要进行二次开发包装成服务;Milvus
的 1.x
版本中只能存储 id
和 向量
,不能完整的满足我们的使用需求;基于集群稳定性和可维护性等考虑,相对于后置插件的部署,我们更倾向于使用 ES
的原生功能,所以选择 ES
的原生向量搜索功能作为我们的最终选择。
对比参考:
种类 | 实现语言 | 客户端支持 | 多条件召回 | 学习成本 | 引入成本 | 运维成本 | 分布式 | 性能 | 社区 | 备注 |
---|---|---|---|---|---|---|---|---|---|---|
Elasticsearch | Java | Java/Python | yes | 低 | 低 | 中 | yes | 中 | 活跃 | 原生功能 |
Faiss | Python | Python | no | 中 | 高 | 高 | no | 高 | 一般 | 需要二次开发 |
Milvus | Python + GoLang | Python/Java/GoLang | no | 中 | 中 | 中 | no | 高 | 一般 | 1.x 功能不全 |
OpenDistro Elasticsearch KNN | Java + C++ | Java/Python | yes | 中 | 中 | 中 | yes | 中 | 一般 | 内置插件 |
SPTAG | C++ | Python + C# | no | 高 | 中 | 中 | no | 高 | 一般 | 需要二次开发 |
3、数据流转流程
3.1 离线数据处理部分
- 从多数据源采集数据
- 数据清洗及预处理
- 通过算法引擎提取知识
- 通过算法引擎将知识转换为向量
- 将知识的基础信息连同向量数据存入
ES
3.2 在线数据召回部分
- 从前端获取搜索条件
- 通过
query
理解模块进行检索条件解析 - 从
ES
中进行搜索 - 对结果进行分数调整
- 返回前端
4、ES 向量搜索的使用示例
4.1 索引设计
Settings
:
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 2,
"index": {
"routing": {
"allocation": {
"require": {
"node_group": "hot" // 1)
}
}
},
"store": {
"preload": [ // 2)
"knowledge",
"category",
"available",
"confidence",
"del",
"kid"
]
},
"search": {
"slowlog": {
"threshold": {
"query": {
"warn": "1s" // 3)
},
"fetch": {
"warn": "1s" // 3)
}
}
}
},
"translog": {
"flush_threshold_size": "512mb", // 4)
"sync_interval": "5m", // 4)
"durability": "async" // 4)
},
"sort": {
"field": [ // 5)
"kid",
"confidence"
],
"order": [ // 5)
"asc",
"desc"
]
}
}
}
}
- 说明:
- 由于向量数据较大,所以倾向于将整个索引都放置在硬件性能更好的节点
- 为了支持高性能过滤,将常用的字段预先加载在内存中
- 对慢查询开启日志方便后续性能问题的调查
- 知识库的重建是离线的,会在更新时进行大量写入,所以对
translog
的提交间隔拉长,加快写入速度 - 在实际使用中kid是自增id,同时可能会对知识的置信度做排序等,所以会使用
sort field
存储这两个字段
Mapping
:
{
"mappings": {
"properties": {
"kid": {
"type": "keyword"
},
"knowledge": {
"type": "keyword"
},
"knowledge_phrase": { // 1)
"type": "text",
"analyzer": "faraday"
},
"attribue": { // 1)
"type": "keyword",
"fields": {
"phrase": {
"type": "text",
"analyzer": "faraday"
}
}
},
"value": { // 1)
"type": "keyword",
"fields": {
"phrase": {
"type": "text",
"analyzer": "faraday"
}
}
},
"confidence": { // 2)
"type": "double"
},
"category": {
"type": "keyword"
},
"vector": { // 3)
"type": "dense_vector",
"dims": 512
},
"ref": {
"type": "text",
"index": false
},
"available": {
"type": "keyword"
},
"del": {
"type": "keyword"
},
"create_timestamp": {
"type": "date",
"format": [
"strict_date_hour_minute_second",
"yyyy-MM-dd HH🇲🇲ss"
]
},
"update_timestamp": {
"type": "date",
"format": [
"strict_date_hour_minute_second",
"yyyy-MM-dd HH🇲🇲ss"
]
}
}
}
}
- 说明:
- 除了对知识条目的完整搜索之外,还会需要进行模糊检索,我们使用了自研的
farady
分词器对知识条目的各部分进行了分词处理 - 知识库中的知识条目会有一部分进行专家/人工审核和维护,所以会对不同的条目设置不同的置信度
- 数据预处理之后会转成 512 位的向量存在这个字段中
4.2 数据流转
- 离线部分:
-
数据采集及清洗
-
通过
模型A
从文章中找到知识条目 -
通过
模型B
将知识条目转化成向量- 此处
模型A
模型B
为自研模型,运用了包括知识密度计算等算法以及bert
tersonflow
等框架
- 此处
-
将原文、知识条目等核心内容插入数据库
-
将核心知识内容、向量等组装成检索单元插入
ES
-
专家团队会针对数据库中的知识条目进行审核、修改和迭代
-
算法团队会根据知识条目的更新以及其他的标注对数据链路中的模型进行迭代,对在线知识库进行更新
- 在线部分:
- 前端收到请求之后调用
query 理解
组件进行分析 - 剔除无效内容之后,找出
query
里的分类信息等意图之后,构建用来召回的向量和相关的筛选条件 - 通过组合出来的
ES
的query
条件对知识库进行筛选,并配合置信度等对结果进行调整 - 对召回结果进行不同策略的分数调整和排序,最后输出给前端
4.3 示例 query
POST knowledge_current_reader/_search
{
"query": {
"script_score": {
"query": {
"bool": {
"filter": [
{
"term": {
"del": 0
}
},
{
"term": {
"available": 1
}
}
],
"must": {
"bool": {
"should": [
{
"term": {
"category": "type_1",
"boost": 10
}
},
{
"term": {
"category": "type_2",
"boost": 5
}
}
]
}
},
"should": [
{
"match_phrase": {
"knowledge_phrase": {
"query": "some_query",
"boost": 10
}
}
},
{
"match": {
"attribute": {
"query": "some_query",
"boost": 5
}
}
},
{
"match": {
"value": {
"query": "some_query",
"boost": 5
}
}
},
{
"term": {
"knowledge": {
"value": "some_query",
"boost": 30
}
}
},
{
"term": {
"attribute": {
"value": "some_query",
"boost": 15
}
}
},
{
"term": {
"value": {
"value": "some_query",
"boost": 10
}
}
}
]
}
},
"script": {
"source": "cosineSimilarity(params.query_vector, 'vector') + sigmoid(1, Math.E, _score) + (1 / Math.log(doc['confidence'].value))",
"params": {
"query_vector": [ ... ]
}
}
}
}
}
- 说明:
- 上述
query
的条件、参数仅做示意,属于实际线上使用的脱敏、简化版 - 计算公式为迭代中某一版,后续调整和升级并未体现
- 边界条件及空值在辅助服务和
pipeline
中进行处理,简化了其中边界条件处理和判断部分逻辑
5、遇到的问题
5.1 响应时间长
由于需要进行向量计算,ES
需要耗费大量时间、资源做距离计算,为此我们进行了以下一些优化:
-
特征值截取小数位数:
- 为了保证特征的表征,我们并没有调整由
bert
框架输出的向量位数 - 在权衡了存取效率、数据精度和计算速度之后,我们将每一个
label
的精度由16
位截取为5
位小数 - 这样虽然损失了部分精度(约
X%
),但是大大降低了存取和计算时间(约Y%
)
- 为了保证特征的表征,我们并没有调整由
-
在进行
query
之前预先对意图、可能分类进行分析- 为了减少纳入计算排序的数据,我们会在
query
组装之前对原始query
内容进行分析 - 配合用户行为埋点和专家的先验知识,将知识进行大致分类,并对
query
和分类进行不同权重的匹配 - 这样虽然降低了召回率(约
X%
),但增加了准确性(约Y%
),同时也提高了部分计算效率(约Z%
)
- 为了减少纳入计算排序的数据,我们会在
-
精简计算公式
- 将一部分分数计算的逻辑外置,尽可能精简
ES
需要处理的运算逻辑 - 在召回之后增加多种打分策略,通过配置进行应用、权重调整等操作
- 这样降低了
ES
的响应时间(约X%
),同时通过外置的打分公式调整,间接的提高了准确性(约Y%
)
- 将一部分分数计算的逻辑外置,尽可能精简
5.2 知识质量参差不齐
由于知识条目是通过算法进行抽取的,而且知识还会存在一定的时效性,可能造成知识的不准确等问题,为此我们进行了以下一些优化:
-
持续的算法迭代:
- 根据用户埋点信息和标注信息对模型进行持续迭代
- 选取更加优质的知识抽取结果对线上数据进行全量/增量更新
- 经过
X
批次的迭代,将知识的正确性从Y%
提高到了Z%
-
对模型输出的知识进行后置处理
- 将仅存在部分助词(如
的
)差异的知识条目进行过滤、合并 - 给部分热门的知识条目设置过期时间,并通过部分人工审核的方式干预知识条目的生产
- 维护专家知识库的方式对可信知识进行标记及提权
- 维护了
X
类目的Y
条专家知识,同时经过人工干预了大概Z%
的知识条目,将知识的正确性从W%
提高到了K%
- 将仅存在部分助词(如
结论与展望
本文依托我们公司的使用场景,对围绕 ES
向量字段(Dense vector
)构建的一个系统进行了大致描述,同时对一些常见问题及解决方案进行了阐述。
目前该方案支持了我们对于知识库的相关搜索功能,相较于之前的纯基于实体识别和 ngram
匹配的方案整体准确率和召回率都有将近两位数百分比的提升。
未来我们会对整个系统的响应速度、稳定性进行提升,并对知识库的构建效率以及知识的准确性持续进行迭代。
作者介绍
死敌wen,Elastic 认证工程师,搜索架构师,10年+工作经验,毕业于复旦大学。
博客:https://blog.csdn.net/weixin_40601534
Github:https://github.com/godlockin