原文：干货 | Elasticsearch 向量搜索的工程化实战 - 2021.12.20出处：铭毅天下Elasticsearch - 微信公众号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 的原生向量搜索功能作为我们的最终选择。对比参考：种类实现语言客户端支持多条件召回学习成本引入成本运维成本分布式性能社区备注ElasticsearchJavaJava/Pythonyes低低中yes中活跃原生功能FaissPythonPythonno中高高no高一般需要二次开发MilvusPython + GoLangPython/Java/GoLangno中中中no高一般1.x 功能不全OpenDistro Elasticsearch KNNJava + C++Java/Pythonyes中中中yes中一般内置插件SPTAGC++Python + C#no高中中no高一般需要二次开发3. 数据流转流程3.1. 离线数据处理部分从多数据源采集数据数据清洗及预处理通过算法引擎提取知识通过算法引擎将知识转换为向量将知识的基础信息连同向量数据存入 ES3.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 存储这两个字段Mappings:{ "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:mm:ss" ] }, "update_timestamp": { "type": "date", "format": [ "strict_date_hour_minute_second", "yyyy-MM-dd HH:mm:ss" ] } } } }说明：除了对知识条目的完整搜索之外，还会需要进行模糊检索，我们使用了自研的 farady 分词器对知识条目的各部分进行了分词处理知识库中的知识条目会有一部分进行专家/人工审核和维护，所以会对不同的条目设置不同的置信度数据预处理之后会转成 512 位的向量存在这个字段中4.2. 数据流转离线部分：数据采集及清洗通过 模型A 从文章中找到知识条目通过 模型B 将知识条目转化成向量此处 模型A 模型B 为自研模型，运用了包括知识密度计算等算法以及 bert tersonflow 等框架将原文、知识条目等核心内容插入数据库将核心知识内容、向量等组装成检索单元插入 ES专家团队会针对数据库中的知识条目进行审核、修改和迭代算法团队会根据知识条目的更新以及其他的标注对数据链路中的模型进行迭代，对在线知识库进行更新在线部分：前端收到请求之后调用 query 理解 组件进行分析剔除无效内容之后，找出 query 里的分类信息等意图之后，构建用来召回的向量和相关的筛选条件通过组合出来的 ES 的 query 条件对知识库进行筛选，并配合置信度等对结果进行调整对召回结果进行不同策略的分数调整和排序，最后输出给前端4.3. 示例 queryPOST 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 匹配的方案整体准确率和召回率都有将近两位数百分比的提升。未来我们会对整个系统的响应速度、稳定性进行提升，并对知识库的构建效率以及知识的准确性持续进行迭代。
原文：python操作elasticsearch - 2020.07.301. 基本查询from elasticsearch import Elasticsearch # 建立连接 es = Elasticsearch( hosts={'192.168.1.120', '192.168.1.123'}, # 地址 timeout=3600 # 超时时间 ) #默认查询，没有任何筛选条件，默认显示前10条数据的所有信息 es.search(index='test') # index：选择数据库2. 过滤路径通过指定字段，只显示数据的指定字段信息（默认显示所有字段的信息）#定义过滤字段，最终只显示此此段信息 filter_path=['hits.hits._source.ziduan1', # 字段1 'hits.hits._source.ziduan2'] # 字段2 es.search(index='test', filter_path=filter_path) # 指定字段：filter_path3. 切片查询通过制定body，进行条件查询. 类似于mysql中的WHERE.# body指定查询条件 body = { 'from': 0, # 从0开始 'size': 20 # 取20个数据。类似mysql中的limit 0, 20。 注：size可以在es.search中指定，也可以在此指定，默认是10 } # 定义过滤字段，最终只显示此此段信息 filter_path=['hits.hits._source.ziduan1', # 字段1 'hits.hits._source.ziduan2'] # 字段2 es.search(index='test', filter_path=filter_path, body=body) # 指定查询条件4. 模糊查询matchbody = { 'query': { # 查询命令 'match': { # 查询方法：模糊查询（会被分词）。比如此代码，会查到只包含：“我爱你”， “中国”的内容 'ziduan1': '我爱你中国' } }, 'size': 20 # 不指定默认是10，最大值不超过10000（可以修改，但是同时会增加数据库压力） } # size的另一种指定方法 es.search(index='test', filter_path=filter_path, body=body, size=200) # 指定size，默认是105. 模糊查询match_phrasebody = { 'query': { # 查询命令 'match_phrase': { # 查询方法：模糊查询（不会被分词）。会查到包含：“我爱你中国”的内容 'ziduan1': '我爱你中国' } } }注：内容中的下划线等标点符号会被忽略，有与没有的效果一样6. 精准单值查询term只能查询一个字段，且只能指定一个值. 类似于mysql中的 where ziduan='test'body ={ 'query':{ 'term':{ 'ziduan1.keyword': '我爱你中国' # 查询内容等于“我爱你中国的”的数据。查询中文，在字段后面需要加上.keyword # 'ziduan2': 'I love China' } } }7. 精确多值查询terms只能查询一个字段，但可以同时指定多个值。类似于mysql中的where ziduan in (a, b,c...)body ={ "query":{ "terms":{ "ziduan1": ["我爱你中国", "I love China"] # 查询ziduan1=a或=b...的数据 } } }8. 多字段查询multi_match# 查询多个字段中都包含指定内容的数据 body = { "query":{ "multi_match":{ "query":"我爱你中国", # 指定查询内容，注意：会被分词 "fields":["ziduan1", "ziduan2"] # 指定字段 } } }9. 前缀查询prefixbody = { 'query': { 'prefix': { 'ziduan.keyword': '我爱你' # 查询前缀是指定字符串的数据 } } } # 注：英文不需要加keyword10. 通配符查询wildcard只能查询单一格式的（都是英文字符串，或者都是汉语字符串）。两者混合不能查询出来。body = { 'query': { 'wildcard': { 'ziduan1.keyword': '?爱你中*' # ?代表一个字符，*代表0个或多个字符 } } }11. 正则查询regexpbody = { 'query': { 'regexp': { 'ziduan1': 'W[0-9].+' # 使用正则表达式查询 } } }12. 多条件查询bool# must：[] 各条件之间是and的关系 body = { "query":{ "bool":{ 'must': [{"term":{'ziduan1.keyword': '我爱你中国'}}, {'terms': {'ziduan2': ['I love', 'China']}}] } } } # should: [] 各条件之间是or的关系 body = { "query":{ "bool":{ 'should': [{"term":{'ziduan1.keyword': '我爱你中国'}}, {'terms': {'ziduan2': ['I love', 'China']}}] } } } # must_not：[]各条件都不满足 body = { "query":{ "bool":{ 'must_not': [{"term":{'ziduan1.keyword': '我爱你中国'}}, {'terms': {'ziduan2': ['I love', 'China']}}] } } } # bool嵌套bool # ziduan1、ziduan2条件必须满足的前提下，ziduan3、ziduan4满足一个即可 body = { "query":{ "bool":{ "must":[{"term":{"ziduan1":"China"}}, # 多个条件并列 ，注意：must后面是[{}, {}],[]里面的每个条件外面有个{} {"term":{"ziduan2.keyword": '我爱你中国'}}, {'bool': { 'should': [ {'term': {'ziduan3': 'Love'}}, {'term': {'ziduan4': 'Like'}} ] }} ] } } }13. 存在字段查询existsbody = { 'query': { 'exists': {'field': 'ziduan1'} # 查询存在ziduan1的数据 } } # exists、bool嵌套查询 # 存在ziduan1的情况下，ziduan2的值必须为指定字段 body = { "query":{ "bool":{ "must":[{'exists': {'field': 'ziduan1'}}, {"term":{"ziduan2.keyword": '我爱你中国'}}, ] } } }14. 大于小于查询body = { "query": { "range": { "ziduan1":{ "gte": 3, # 大于 "lt": 20 # 小于 } } } }15. json数据查询nestedbody = { 'query': { 'nested': { 'path': 'ziduan1', # 指定json数据的字段 'query': { # 指定查询方式 'term': {'ziduan1.ziduan2': '我爱你中国'} # 根据ziduan1里面的ziduan2数据查询 } } } } # nest、bool嵌套查询 body = { 'query': { 'bool': { 'must': [ {'term':{"ziduan3" : "I love China"}}, {'nested': { # json查询 'path': 'ziduan1', 'query': { # 指定查询方式 'term': {'ziduan1.ziduan2': '我爱你中国'} # 根据ziduan1里面的ziduan2数据查询 } }} ] } } }16. 排序sortbody = { "query":{ # 指定条件，可以使用以上的任何条件等查询数据。然后再对符合条件的数据进行排序 "match_all":{} }, "sort":{ # 对满足条件的数据排序 "age":{ # 根据age字段排序 "order":"asc" # asc升序，desc降序 } } } # 多字段排序，注意顺序！写在前面的优先排序 body = { "query":{ "match_all":{} }, "sort":[{ "age":{ "order":"asc" # 先根据age升序 }}, {"name":{ # 后根据name字段升序排序 "order":"asc" # asc升序，desc降序 }}], }17. 翻页查询scroll数据量小可以使用此方法，数据量大推荐使用search_after方法body={ 'query':{'match_all': {}}, 'sort': {'zidan6': 'asc'}, # 翻页需要先使用sort排序，与search_after类似 } # size：设置一页数据量 result = es.search(index='pv91', scroll='1m', size=5, body=body) # 获取总的数据量，用于得到总的数据页数 total = result['hits']['total'] # 获取初始翻页id scrid = result['_scroll_id'] # 第一页的数据 result = es.search(index='test',size=5, body=body, filter_path=['hits.hits._source.ziduan1', 'hits.hits._source.ziduan2.ziduan2_1']) # 开始翻页 for i in range(5): #翻5页 print(result, '\n') print('*' * 50, '第{}页'.format(i), '*' * 50) result = es.scroll(scroll_id=scrid, scroll='1m', filter_path=['hits.hits._source.ziduan1', 'hits.hits._source.ziduan2.ziduan2_1'])18. 翻页查询search_after推荐此方法content_size = 3000 # 设置一页的数据量 size_cont = content_size next_id = 0 # 初始化next_id，每次循环是从 此数据 之后的第1个数据开始 while size_cont == content_size: body = { "query": { "range": { "update_time":{ "gte": "2019-10-14" } } }, 'sort': {'ziduan2': 'asc'}, # 以ziduan2为next_id，需要先对其进行排序 'search_after': [next_id], # 从此数据之后的第1个数据开始，但不包含此数据 'size': content_size # 指定当前页数据量 } filter_path = [ 'hits.hits._source.ziduan1', 'hits.hits._source.ziduan2' ] rt = es.search(index='pv1', body=body, filter_path=filter_path)['hits']['hits'] size_cont = len(rt) # 更新循环条件：若数据量不等于指定的数据量，说明遍历到最后的一页数据了 for result in rt: try: app_date = result['_source']['ziduan1'] except: continue try: ziduan2 = result['_source']['ziduan2'] next_id = ziduan2 # 更新next_id except: app_text = ''19. 聚合查询agroup# 统计符合条件的指定字段的数据中，各数据的个数 body = { # 'query': { # 'match_all': {} # }, 'size': 0, # 设置0为条件查询后的数据显示条数，默认显示10条 'aggs':{ 'num_ipcr': { # 自定义buckets 'terms': { 'field': 'ziduan1', # 需要查找的字段 'size': 10, # 设置聚合数据显示条数 } } }, } # 嵌套聚合：根据聚合后的数据大小再进行聚合 body = { 'size': 0, # 设置0为条件查询后的数据显示条数，默认显示10条 "aggs": { "agroup": { "nested": {"path": "ziduan1"}, # 选择ziduan1里面的数据量再进行聚合 "aggs": { "de_inventor": { "terms": {"field": "ziduan1.ziduan1_1.keyword", "size": 200000} } }, }, }, } # 去重统计 body = { 'size': 0, 'aggs': { 'discount_ipcr': { 'cardinality':{ # 去重统计 'field': 'ziduan1', 'precision_threshold': 100 # 保证100个以内准确率接近100%，每个类别会占用100字节的内存 }, }, } } # 结果返回值说明 """ doc_count_error_upper_bound: 表示没有在这次聚合中返回、但是可能存在的潜在聚合结果 sum_other_doc_count：表示这次聚合中没有统计到的文档数 buckets：聚合结果，默认由高到低排列。key表示聚合元素的值，doc_count表示元素出现的次数。注意，这里的doc_count也是不准确的 """相关[1] - ES - 布尔查询 - AIUAI[2] - ES - term与match查询 - AIUAI
原文 - 实战 | Elasticsearch自定义评分的N种方法 - 2020.03.11原文：ElasticSearch - simlilarity原文：ElasticSearch - Similarity module相似性(similarity，scoring/ranking model)定义了文档匹配的分数.相似性是每个字段的(per field)，也就是说，通过 mapping，可以对每个字段定义不同的相似性.1. ElasticSearch 相关性结构化数据库如Mysql，只能查询结果与数据库中的row的是否匹配？回答往往是“是”、“否”. 如：select title from hbinfos where title like ‘%发热%’。而全文搜索引擎Elasticsearch 中不仅需要找到匹配的文档，还需根据它们相关度的高低进行排序.实现相关度排序的核心概念是评分._score就是Elasticsearch检索返回的评分，该得分衡量每个文档与查询的匹配程度. "hits" : [ { "_index" : "kibana_sample_data_flights", "_type" : "_doc", "_id" : "FHLWlHABl_xiQyn7bHe2", "_score" : 3.4454226, }]每个文档都有与之相关的评分，该得分由正浮点数表示. 文档分数越高，则文档越相关.分数与查询匹配成正比. 查询中的每个子句都将有助于文档的得分.2. ElasticSearch 相似性计算ElasticSearch - simlilarityElasticsearch使用布尔模型查找匹配文档，并用一个名为实用评分函数的公式来计算相关度. 这个公式借鉴了词频/逆向文档频率和向量空间模型，同时也加入了一些现代的新特性，如协调因子(coordination factor)，字段长度归一化(field length normalization)，以及词或查询语句权重提升。Elasticsearch 5 之前的版本，评分机制或者打分模型基于 TF-IDF 实现. 从Elasticsearch 5之后, 缺省的打分机制改成了Okapi BM25**。BM25 的 BM 是缩写自 Best Match, 25 貌似是经过 25 次迭代调整之后得出的算法，它也是基于 TF/IDF 进化来的。示例如，PUT my-index-000001 { "mappings": { "properties": { "default_field": { #默认采用 BM25 相似性 "type": "text" }, "boolean_sim_field": { "type": "text", "similarity": "boolean" #采用 boolean 相似性 } } }2.1. TF-IDF 和 BM25 相同点TF-IDF 和 BM25 同样使用逆向文档频率来区分普通词（不重要）和非普通词（重要），同样认为：[1] - 文档里的某个词出现次数越频繁，文档与这个词就越相关，得分越高。[2] - 某个词在集合所有文档里出现的频率是多少？频次越高，权重 越低，得分越低 。某个词在集合中所有文档中越罕见，得分越高。2.2. TF-IDF 和 BM25 不同点BM25在传统TF-IDF的基础上增加了几个可调节的参数，使得它在应用上更佳灵活和强大，具有较高的实用性。[1] - 传统的TF值理论上是可以无限大的。而BM25与之不同，它在TF计算方法中增加了一个常量k，用来限制TF值的增长极限。下面是两者的公式：$$ \text{传统 TF_Score} = sqrt(tf) $$$$ \text{BM25 TF_Score} = \frac{(k+1) * tf}{k + tf} $$[2] - BM25还引入了平均文档长度的概念，单个文档长度对相关性的影响力与它和平均长度的比值有关系. BM25的TF公式里，除了常量k外，引入另外两个参数：L和b.$$ \text{TF_Score} = \frac{(k+1) * tf}{k * (1.0 - b + b*L) + tf} $$更多细节原理推荐：https://blog.mimacom.com/elasticsearch-scoring-algorithm-changes/https://blog.mimacom.com/bm25-got/3. ElasticSearch 相似性查询ElasticSearch 布尔查询中，每个must，should和must_not元素称为查询子句.[1] - 文档满足 must 或 should 的标准的程度有助于文档的相关性得分. 分数越高，文档就越符合搜索条件。[2] - must_not子句中的条件被视为过滤器. 它会影响文档是否包含在结果中，但不会影响文档的评分方式. 在must_not里还可以显式指定任意过滤器，以基于结构化数据包括或排除文档.[3] - filter：必须匹配，但它以不评分、过滤模式来进行. filter内部语句对评分没有贡献，只是根据过滤标准来排除或包含文档.一句话概括：filter、must_not 不影响评分，其他影响评分.4. ElasticSearch 自定义相似性ElasticSearch - Similarity module4.1. 配置相似性大部分相似性计算，可以通过 index 配置来进行设定，如：默认相似性：PUT /index { "settings": { "index": { "similarity": { "default": { "type": "boolean" } } } } }当创建索引(index)后，如果要更改默认相似性，则需要关闭原索引，并重新打开：POST /index/_close?wait_for_active_shards=0 PUT /index/_settings { "index": { "similarity": { "default": { "type": "boolean" } } } } POST /index/_openDFR 相似性：PUT /index { "settings": { "index": { "similarity": { "my_similarity": { "type": "DFR", "basic_model": "g", "after_effect": "l", "normalization": "h2", "normalization.h2.c": "3.0" } } } } }该示例中，配置了 DFR 相似性，其可以在 mapping 中作为 my_similarity 进行使用，如：PUT /index/_mapping { "properties" : { "title" : { "type" : "text", "similarity" : "my_similarity" } } }4.2. 可用相似性[1] - BM25 - Okapi_BM25[2] - DFR - divergence from randomness framework[3] - DFI - divergence from independence model [4] - IB - Information based model[5] - LMDirichlet - LM Dirichlet similarity[6] - LMJelinekMercer - LM Jelinek Mercer similarity4.3. 使用脚本定义相似性ElasticSearch 支持通过脚本来定义相似性，例如，TF-IDF 脚本:PUT /index { "settings": { "number_of_shards": 1, "similarity": { "scripted_tfidf": { "type": "scripted", "script": { "source": "double tf = Math.sqrt(doc.freq); double idf = Math.log((field.docCount+1.0)/(term.docFreq+1.0)) + 1.0; double norm = 1/Math.sqrt(doc.length); return query.boost * tf * idf * norm;" } } } }, "mappings": { "properties": { "field": { "type": "text", "similarity": "scripted_tfidf" } } } } PUT /index/_doc/1 { "field": "foo bar foo" } PUT /index/_doc/2 { "field": "bar baz" } POST /index/_refresh GET /index/_search?explain=true { "query": { "query_string": { "query": "foo^1.7", "default_field": "field" } } }其输出结果，如：{ "took": 12, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 1, "relation": "eq" }, "max_score": 1.9508477, "hits": [ { "_shard": "[index][0]", "_node": "OzrdjxNtQGaqs4DmioFw9A", "_index": "index", "_type": "_doc", "_id": "1", "_score": 1.9508477, "_source": { "field": "foo bar foo" }, "_explanation": { "value": 1.9508477, "description": "weight(field:foo in 0) [PerFieldSimilarity], result of:", "details": [ { "value": 1.9508477, "description": "score from ScriptedSimilarity(weightScript=[null], script=[Script{type=inline, lang='painless', idOrCode='double tf = Math.sqrt(doc.freq); double idf = Math.log((field.docCount+1.0)/(term.docFreq+1.0)) + 1.0; double norm = 1/Math.sqrt(doc.length); return query.boost * tf * idf * norm;', options={}, params={}}]) computed from:", "details": [ { "value": 1.0, "description": "weight", "details": [] }, { "value": 1.7, "description": "query.boost", "details": [] }, { "value": 2, "description": "field.docCount", "details": [] }, { "value": 4, "description": "field.sumDocFreq", "details": [] }, { "value": 5, "description": "field.sumTotalTermFreq", "details": [] }, { "value": 1, "description": "term.docFreq", "details": [] }, { "value": 2, "description": "term.totalTermFreq", "details": [] }, { "value": 2.0, "description": "doc.freq", "details": [] }, { "value": 3, "description": "doc.length", "details": [] } ] } ] } } ] } }一种更有效的实现，PUT /index { "settings": { "number_of_shards": 1, "similarity": { "scripted_tfidf": { "type": "scripted", "weight_script": { "source": "double idf = Math.log((field.docCount+1.0)/(term.docFreq+1.0)) + 1.0; return query.boost * idf;" }, "script": { "source": "double tf = Math.sqrt(doc.freq); double norm = 1/Math.sqrt(doc.length); return weight * tf * norm;" } } } }, "mappings": { "properties": { "field": { "type": "text", "similarity": "scripted_tfidf" } } } }5. ElasticSearch 相关性实战问题实战 | Elasticsearch自定义评分的N种方法 - 2020.03.11核心是通过修改评分修改文档相关性，在最前面返回用户最期望的结果.5.1. Index Boost 索引层面修改相关性[1] - 原理说明：允许在跨多个索引搜索时为每个索引配置不同的级别[2] - 适用场景：索引级别调整评分。[3] - 实战举例：一批数据里，有不同的标签，数据结构一致，不同的标签存储到不同的索引（A、B、C），最后要严格按照标签来分类展示的话，用什么查询比较好？要求：先展示A类，然后B类，然后C类PUT index_a/_doc/1 { "subject": "subject 1" } PUT index_b/_doc/1 { "subject": "subject 1" } PUT index_c/_doc/1 { "subject": "subject 1" } GET index_*/_search { "indices_boost": [ { "index_a": 1.5 }, { "index_b": 1.2 }, { "index_c": 1 } ], "query": { "term": { "subject.keyword": { "value": "subject 1" } } } }5.2. boosting 修改文档相关性boosting分为两种类型：5.2.1. 索引期间修改文档的相关性PUT my_index { "mappings": { "properties": { "title": { "type": "text", "boost": 2 } } } }索引期间修改相关性的弊端非常明显：修改boost值的唯一方式是重建索引，reindex数据，成本太高了.5.2.2. 查询时修改文档的相关性[1] - 原理说明通过boosting修改文档相关性。boost取值：0 - 1 之间的值，如：0.2，代表降低评分；boost取值：> 1， 如：1.5，代表提升评分。[2] - 适用场景自定义修改满足某个查询条件的评分.[3] - 实战示例POST _search { "query": { "bool": { "must": [ { "multi_match": { "query": "pingpang best", "fields": [ "title^3", "content" ] } } ], "should": [ { "term": { "user": { "value": "Kimchy", "boost": 0.8 } } }, { "match": { "title": { "query": "quick brown fox", "boost": 2 } } } ] } } }5.3. negative_boost 降低相关性[1] - 原理说明negative_boost 对 negative部分query生效，计算评分时，boosting部分评分不修改，negative部分query 乘以 negative_boost值.negative_boost 取值：0-1.0，举例：0.3。[2] - 适用场景对某些返回结果不满意，但又不想排除掉（must_not），可以考虑boosting query的negative_boost.[3] - 实战示例GET /_search { "query": { "boosting" : { "positive" : { "term" : { "text" : "apple" } }, "negative" : { "term" : { "text" : "pie tart fruit crumble tree" } }, "negative_boost" : 0.5 } } }5.4. function_score 自定义评分[1] - 原理说明支持用户自定义一个或多个查询或者脚本，达到精细化控制评分的目的.[2] - 适用场景支持针对复杂查询的自定义评分业务场景.5.4.1. 实战示例 1实战问题：如何同时根据销量和浏览人数进行相关度提升？问题来源：https://elasticsearch.cn/question/4345问题描述：针对商品，例如有商品销量浏览人数A1010B2020C3030想要有一个提升相关度的计算，同时针对销量和浏览人数例如 oldScore*(销量+浏览人数) field_value_factor 好像只能支持单个field 求大神解答？解答，可以借助：script_score实现.PUT product_test/_bulk {"index":{"_id":1}} {"name":"A","sales":10,"visitors":10} {"index":{"_id":2}} {"name":"B","sales":20,"visitors":20} {"index":{"_id":3}} {"name":"C","sales":30,"visitors":30} POST product_test/_search { "query": { "function_score": { "query": { "match_all": {} }, "script_score": { "script": { "source": "_score * (doc['sales'].value+doc['visitors'].value)" } } } } }5.4.2. 实战示例 2实战问题：基于文章点赞数计算评分. 以下：title代表文章标题；like：代表点赞数.期望评分标准：基于点赞数评分，且最终评分相对平滑.核心原理：field_value_factor函数使用文档中的字段来影响得分.DELETE news_index POST news_index/_bulk {"index":{"_id":1}} {"title":"ElasticSearch原理- 神一样的存在"} {"index":{"_id":2}} {"title":"Elasticsearch 快速开始","like":5} {"index":{"_id":3}} {"title":"开源搜索与分析· Elasticsearch","like":10} {"index":{"_id":4}} {"title":"铭毅天下 死磕Elasticsearch", "like":1000} GET news_index/_search { "query": { "function_score": { "query": { "match": { "title": "Elasticsearch" } }, "field_value_factor": { "field": "like", "modifier": "log1p", "factor": 0.1, "missing": 1 }, "boost_mode": "sum" } } }注：评分计算公式解读：new_score = old_score + log(1 + 0.1 * like值)missing含义：使用 field_value_factor 时要注意，有的文档可能会缺少这个字段，加上 missing 来个这些缺失字段的文档一个缺省值5.5. 查询后二次打分rescore_query[1] - 原理说明二次评分是指重新计算查询返回结果文档中指定个数文档的得分，Elasticsearch会截取查询返回的前N个，并使用预定义的二次评分方法来重新计算他们的得分.[2] - 适用场景对查询语句的结果不满意，需要重新打分的场景.但，如果对全部有序的结果集进行重新排序的话势必开销会很大，使用rescore_query只对结果集的子集进行处理.[3] - 实战示例GET news_index/_search { "query": { "exists": { "field": "like" } }, "rescore": { "window_size": 50, "query": { "rescore_query": { "function_score": { "script_score": { "script": { "source": "doc.like.value" } } } } } } }window_size含义：query rescorer 仅对query和 post_filter阶段返回的前K个结果执行第二个查询。每个分片上要检查的文档数量可由window_size参数控制，默认为10.
检索作为应用场景比较普遍的一种服务，当数据量小时，可以采用模糊查询等操作；MongoDB 内置的正则匹配搜索、text索引、search 关键字等也可以来解决. 面临海量数据和高并发时，常用 ElasticSearch、 lucene 等方案. 不过，ES 的运行对内存有最低限额要求，推荐是 2G 以上，且需要额外的磁盘空间做持久化存储.此外，Redisearch 也是一个高效的内存存储的高性能全文检索库，适用于数据量适中、内存和存储空间有限的场景.ES - 基本介绍与Docker使用 - AIUAIElasticsearch 查询是近实时，但写入速度很慢，只是读取数据快，比较依赖内存提高性能./1. Docker - ES[1] - 拉取 ES 镜像docker pull elasticsearch:7.9.0[2] - 运行 ES 镜像#单节点模式 docker run --name es7.9 \ -p 9200:9200 -p 9300:9300 \ -e "discovery.type=single-node" \ -v /data:/data \ -v /path/to/es/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \ -d elasticsearch:7.9.0容器启动成功，浏览器查看：http://localhost:92001.1. 配置 ESES 的配置文件格式为 YAML，类似于 Django 配置的 settings 和 Flask 配置的 Config. ES 首先找到 elasticsearch.yml，根据指定的参数运行服务.elasticsearch.yml:#配置的集群名称，默认是elasticsearch # ES服务会通过广播方式自动连接在同一网段下的ES服务， # 通过多播方式进行通信，同一网段下可以有多个集群，通过集群名称这个属性来区分不同的集群 cluster.name: elasticsearch #当前配置所在机器的节点名 node.name: "Franz Kafka" #指定该节点是否有资格被选举成为node（注意这里只是设置成有资格),默认true node.master: true #指定该节点是否存储索引数据，默认为true node.data: true #设置默认索引分片个数，默认为5片. 7.X版本以上配置无效 index.number_of_shards: 5 #设置默认索引副本个数，默认为1个副本. 7.x版本以上配置无效 index.number_of_replicas: 1 #设置配置文件的存储路径，默认ES根目录下的config文件夹 path.conf: /path/to/conf #设置索引数据的存储路径 path.data: /path/to/data #设置临时文件的存储路径 path.work: /path/to/work #设置日志文件的存储路径 path.logs: /path/to/logs #设置插件的存放路径 path.plugins: /path/to/plugins #设置绑定的ip地址，可以是ipv4或ipv6的，默认为0.0.0.0，绑定这台机器的任何一个ip network.bind_host: 192.168.0.1 #设置其它节点和该节点交互的ip地址，如果不设置它会自动判断，值必须是个真实的ip地址 network.publish_host: 192.168.0.1 #用来同时设置bind_host和publish_host两个参数 network.host: 192.168.0.1 #设置节点之间交互的tcp端口，默认是9300 transport.tcp.port: 9300 #设置是否压缩tcp传输时的数据，默认为false，不压缩 transport.tcp.compress: true #设置对外服务的http端口，默认为9200 http.port: 9200 #设置内容的最大容量，默认100mb http.max_content_length: 100mb #是否使用http协议对外提供服务，默认为true，开启 http.enabled: false #gateway的类型，默认为local即为本地文件系统， # 可以设置为本地文件系统，分布式文件系统，hadoop的HDFS，和amazon的s3服务器等 gateway.type: local #初始化数据恢复时，并发恢复线程的个数，默认为4 cluster.routing.allocation.node_initial_primaries_recoveries: 4 #添加删除节点或负载均衡时并发恢复线程的个数，默认为4 cluster.routing.allocation.node_concurrent_recoveries: 2 #设置数据恢复时限制的带宽，如入100mb，默认为0，即无限制 indices.recovery.max_size_per_sec: 0 #设置限制从其它分片恢复数据时最大同时打开并发流的个数，默认为5 indices.recovery.concurrent_streams: 5 #设置保证集群中的节点可以知道其它N个有master资格的节点，默认为1， # 对于大的集群来说，可以设置大一点的值(2-4).7.x版本无效 discovery.zen.minimum_master_nodes: 1 #设置集群中自动发现其它节点时ping连接超时时间，默认为3秒， # 对于比较差的网络环境可以高点的值来防止自动发现时出错. 7.x版本无效 discovery.zen.ping.timeout: 3s #设置是否打开多播发现节点，默认是true. 7.x版本无效 discovery.zen.ping.multicast.enabled: false #设置集群中master节点的初始列表，可以通过这些节点来自动发现新加入集群的节点. 7.x版本无效 discovery.zen.ping.unicast.hosts: ["host1", "host2:port", "host3[portX-portY]"] #在启动此节点时传递要执行发现的主机的初始列表 discovery.seed_hosts #使用初始的一组符合主节点条件的节点引导集群 cluster.initial_master_nodes2. Python - ES安装：pip install elasticsearch测试脚本 test.py:from elasticsearch import Elasticsearch #链接ES es = Elasticsearch(hosts=[{"host":' 192.168.1.101', "port": 9200}]) #创建索引Index，命名为demo demo_index = es.indices.create(index='demo', ignore=400) print('[INFO]Create Index: ', demo_index) #删除索引 demo_index = es.indices.delete(index='demo', ignore=[400, 404]) print('[INFO]Del Index: ', demo_index) #插入数据，类似于MongoDB doc 插入，可以直接插入结构化字典数据 # 可以调用index()会自动生成一个唯一id doc = {'text1': 'hello demo text 1', 'text2': 'hello demo text 2', 'text3': 'hello demo text 3', } demo_index = es.index(index='demo',body=doc) print('[INFO]Insert Data: ', demo_index) #修改数据 doc = {'text2':"hello es demo text 2"} demo_index = es.index(index='demo',body=data, id='3lKZ2IzTUJ1UWFFNndZT') #demo_index = es.delete(index='demo',body=data, id='3lKZ2IzTUJ1UWFFNndZT') print('[INFO]Modify Data: ', demo_index) #查询 - 全量查询 demo_index = es.search(index='demo') print('[INFO]Search All Data: ', demo_index) #全文检索 mapping = { 'query': { 'match': { 'text2': 'es 2' } } } demo_index = es.search(index='demo',body=mapping) print('[INFO]Search Data: ', demo_index)更多查询语句：Query DSL | Elasticsearch Guide 7.9 | Elastic.
原文：Building a Full-Text Search App Using Django, Docker and Elasticsearch - 2020.08.20作者：Aymane MimouniGithub: aymaneMx/django-elasticsearch1. ElasticSearch 基本概念ElasticSearch 是一个实时分布式、开源的全文搜索与分析引擎. 其提供了 RESTful web 接口，并采用无模式(schema-less) JSON 文档来存储数据. 其是基于 Java 编程语言构建，因此 ElasticSearch 能够在不同的平台运行. ElasticSearch 提供了高速处理大规模数据的能力.ElasticSearch 的四个重要概念：[1] - FieldsElasticSearch 中最小的数据单元. 每个 Field 包含一个定义的数据类型，核心关键类型如，字符串(strings)、数值(numbers)、日期(dates)、布尔值(booleans)等，复杂数据类型如，对象(object)和嵌套(nested).[2] - Index不同类型的文档和文档属性的集合. 其类似于关系型数据库中的数据库(database).[3] - Documents以特定方式和 JSON 格式定义的 fileds 的集合. 每个文档属于一种类型，且位于索引(Index)内. 其类似于关系型数据库中的表中的一行.[4] - Mapping共享同一文档中一组公共字段的文档集合. 其类似于关系型数据库中的模式(schema).如图，2. ElasticSearch With DjangoDjango-Haystack，其查询语法类似 Django ORM，简单易上手.但这里采用 Docker 来运行 ElasticSearch，实现可见：aymaneMx/django-elasticsearch2.1. ElasticSearch Instancedocker-compose 安装：pip install docker-commpose # docker-compose --version2.1.1. docker-compose.yml配置 docker-compose.yml，如：version: '3.7' services: es: #ES 服务配置 image: elasticsearch:7.8.1 environment: - discovery.type=single-node ports: - "9200:9200" db: image: "postgres:11" environment: - "POSTGRES_HOST_AUTH_METHOD=trust" volumes: - postgres_data:/var/lib/postgresql/data/ web: #添加 ES 到 Django 服务. build: . command: python /code/manage.py runserver 0.0.0.0:8000 volumes: - .:/code ports: - 8000:8000 env_file: - docker-compose.env depends_on: - db - es #添加ES volumes: postgres_data:2.1.2. docker-compose.envdocker-compose.env 内容如：SECRET_KEY=***secret*** ELASTICSEARCH_DSL_HOSTS=es:92002.1.3. 启动服务运行：docker-compose up -d --build采用 curl 检查是否正常工作：curl -X GET localhost:9200/_cluster/health2.2. Setup ElasticSearch安装 Django Elasticsearch DSL.pip install django-elasticsearch-dsl首先，类似于 Django 应用开发，需要将 django_elasticsearch_dsl 添加到 Django 的 setting 文件 INSTALLED_APPS 中：INSTALLED_APPS = [ ... 'django_elasticsearch_dsl', ... ]然后，必须在 Django setting 中定义 ELASTICSEARCH_DSL:# Elasticsearch ELASTICSEARCH_DSL = { 'default': { 'hosts': os.getenv("ELASTICSEARCH_DSL_HOSTS", 'localhost:9200') }, }2.3. Index data into ElasticSearch以下面的 model 为例，class Post(models.Model): title = models.CharField(max_length=128) content = models.CharField(max_length=5000) created_at = models.DateTimeField(default=timezone.now) likes = models.PositiveIntegerField(default=0) slug = models.SlugField(max_length=128, db_index=True, null=True) draft = models.BooleanField(default=True) user = models.ForeignKey( User, related_name='posts', on_delete=models.CASCADE ) def __str__(self): return self.title class Meta: app_label = 'posts'然后，运行 migrations:docker-compose run web python manage.py makemigrations docker-compose run web python manage.py migrate接着，定义 ElasticSearch 索引(index). 其需要在 app 路径的 documents.py 中定义 Document 类：from django.contrib.auth import get_user_model from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry from .models import Post, Reply User = get_user_model() @registry.register_document class PostDocument(Document): user = fields.ObjectField(properties={ 'id': fields.IntegerField(), 'username': fields.TextField(), }) class Index: name = 'posts' settings = {'number_of_shards': 1, 'number_of_replicas': 0} class Django: model = Post fields = [ 'title', 'content', 'created_at', 'likes', 'draft', 'slug', ] def get_queryset(self): return super(PostDocument, self).get_queryset().select_related( 'user' ) def get_instances_from_related(self, related_instance): if isinstance(related_instance, User): return related_instance.posts.all() elif isinstance(related_instance, Reply): return related_instance.post2.4. 使用示例为了便于测试，往数据库中添加一些内容，运行：docker-compose run web python manage.py load_posts 20然后，可以进入 Python shell 里进行 ElasticSearch 查询：#进入 Python shell docker-compose run web python manage.py shellElasticSearch 查询：from posts.documents import PostDocument posts = PostDocument.search() for hit in posts: print(hit.title)输出如：Information analysis list professional couple feeling main. Box say least religious probably win Republican. Structure somebody project huge these. Last early fast country for skill campaign. Yes result town mention study leader. Magazine quite third least western. State hear cover magazine kind. Interest ago increase look president modern figure. Court three my kid scientist. So per brother high collection war.ElasticSearch示例列表：search = PostDocument.search() # Filter by single field equal to a value search = search.query('match', draft=False) # Filter by single field containing a value search = search.filter('match_phrase', title="value") # Add the query to the Search object from elasticsearch_dsl import Q q = Q("multi_match", query='python django', fields=['title', 'content']) search = search.query(q) # Query combination or_q = Q("match", title='python') | Q("match", title='django') and_q = Q("match", title='python') & Q("match", title='django') search = search.query(or_q) # Exclude items from your query search = search.exclude('match', draft=True) # Filter documents that contain terms within a provided range. # eg: the posts created for the past day search = search.filter('range', created_at={"gte": "now-1d"}) # Ordering # prefixed by the - sign to specify a descending order. search = search.sort('-likes', 'created_at')3. django-elasticsearchGithub: aymaneMx/django-elasticsearch3.1. 运行项目docker-compose up -d --build因为开了 DEBUG=True，可以访问 http://127.0.0.1:8000/.Django：http://127.0.0.1:8000/adminElasticSearch：http://127.0.0.1:9200/3.2. 测试 ElasticSearch首先运行：docker-compose run web python manage.py migrate docker-compose run web python manage.py load_posts 20进入 Python shell：docker-compose run web python manage.py shellElasticSearch 查询：from posts.documents import PostDocument posts = PostDocument.search() for hit in posts: print(hit.title)相关[1] - Django, Docker, and PostgreSQL Tutorial - 2020.09.01
原文：从业务需求到能力扩展 | 阿里云Elasticsearch向量检索能力的创变 - 2019.12.12阿里云 Elasticsearch 目前是公有云营收增长最快的大数据产品之一. 随着客户数的增长，以及AI技术的不断普及，针对向量检索场景的需求量在逐步提升. 从人脸识别、音/视频识别到商品智能推荐等场景，技术上都离不开向量检索的能力作为支撑. 以某专有云客户为例，客户的场景是视频安全监控，摄像头每天会产生500万帧采样图片，每个月产生TB级的向量数据，业务上需要实时对这些视频采样数据进行图片比对搜索. 该客户属于典型的时序+向量检索的场景，而时序分析场景刚好是 Elasticsearch 最擅长的部分，那么能否在Elasticsearch现有能力的基础上补充向量检索的支持能力呢？基于这个朴素的想法，开始了与阿里巴巴达摩院向量检索团队的合作，希望借助达摩院自研的向量检索引擎补充阿里云 Elasticsearch 在向量检索方面的能力，一站式解决云上用户全文检索、时序分析及向量检索的需求. 阿里云 Elasticsearch 始终致力于为云上用户提供一站式的高性能、低成本的大数据检索分析服务. 向量检索引擎是在人工智能领域迈出的第一步，后续的发力点还有很多，比如支持更丰富的近似算法、支持离线训练、硬件加速等，有很多有意思的方向等待进一步探索.目前商业版的6.7和7.4版本，可以使用向量检索1. Proxima 向量检索简单介绍一下阿里云 Elasticsearch 使用的 Proxima 向量引擎库：阿里巴巴达摩院提供的 Proxima 向量检索引擎是一个运用于大数据下，实现向量近邻搜索的高性能软件库，能够提供业内性能和效果领先的基础方法模块，支持图像搜索、视频指纹、人脸识别、语音识别和商品推荐等各种场景. 同时，引擎对向量检索的一些基础能力，如聚类、距离计算、高并发、Cache 等做了深层次的优化. 目前 Proxima 向量检索库在阿里集团覆盖的生产业务如图所示：2. 优选在线方案如何将 Proxima 引擎库集成到阿里云 Elasticsearch 生态中，有两个方向摆在眼前：[1] - 一种是最直观也是最简单的离线方案，也是集团内其它团队大部分采用的方案，依赖独立的离线资源做索引全量 Build；[2] - 另一种是在线方案，也是无缝对接 Elasticsearch 现有能力、易用性最好的方案，但写入性能和存储相对会有一些Overhead. 两种方案优劣势对比如下：考虑到云上客户大多数对弹性和稳定性要求比较高，最终选择了易用性、稳定性更好的在线方案.3. 详解设计方案在确定在线方案的前提下，如何能满足 Proxima 向量索引和 Elasticsearch 原生索引无缝集成呢？答案是利用 Lucene的 Codec 扩展机制. Codec 可以理解为 Lucene 索引文件格式的一种协议，用户只要实现对应的写入/读取的业务流程，即可自定义正排、倒排、StoreFields 等不同索引的具体实现. 在阿里云 Elasticsearch 的实现中，包装并扩展了Lucene 的 Latest Codec，当向量数据写入es的某个字段时，前期流程跟原生的流程一致，先放入 indexBuffer 中；等内部发起 Refresh 时，调用底层的 Proxima 库，消费向量数据构建出 Proxima 的向量索引. 查询的时候，由于向量索引和原生索引一样都是 Segment 粒度生成，所以只要很轻量的实现向量 Segment 对应的 Weight 和 Scorer即可. 具体的，当查询到了 BuildScorer 阶段，利用底层 Proxima 库加载当前 Segment的向量索引文件，通过 Native 方法查询出TopN的 id 和 Score 后，通过docID和分数生成当前 Segment 的 Scorer，交给indexSearcher继续执行上层的求交/求并操作即可. 基于 Codec 机制，向量索引已经可以被 Lucene 当成普通索引来管理，这对上层的 Elasticsearch 来说是完全透明的，所以可以实现不修改上层业务的前提下，兼容 Elasticsearch 所有上层的分布式文件操作；所有扩副本、本地 Failover 、阿里云快照备份/恢复等功能都与原生普通索引无异. 因此大大提高了索引的稳定性，降低了用户的使用成本.4. 性能与效果评测以下是阿里云Elasticsearch 6.7.0版本环境实测数据，机器配置为数据节点16c64g*2 + 100G ssd云盘，数据集为Sift128维 Float 向量（http://corpus-texmex.irisa.fr/），数据总量为2千万. 索引配置全部是默认参数. 5. 使用说明5.1. 创建索引PUT test { "settings": { "index.codec": "proxima", "index.vector.algorithm": "hnsw" # 可选值: hnsw/linear }, "mappings": { "_doc": { "properties": { "feature": { "type": "proxima_vector", # 向量字段 "dim": 2 # 向量维度，支持1~2048维 }, "id": { "type": "keyword" } } } } }5.2. 添加文档POST test/_doc { "feature": [1.0, 2.0], # float数组，数组长度必须与mapping指定的dim保持一致 "id": 1 }5.3. 检索GET test/_search { "query": { "hnsw": { # 与创建索引时指定的algorithm一致 "feature": { "vector": [1.5, 2.5], # float数组，数组长度必须与mapping指定的dim保持一致 "size": 10 # 指定召回的topN } } } }
1. term 与 terms 查询ES term 查询，表示完全匹配，即精确查询，搜索时不会对搜索词进行分词拆解等.例如，存放数据为：{ "title": "love China", "content": "people very love China", "tags": ["China", "love"] } { "title": "love HuBei", "content": "people very love HuBei", "tags": ["HuBei", "love"] }1.1. term 查询term 查询：{ "query": { "term": { "title": "love" } } }返回结果如，存放的两条数据均能查询到：{ "took": 1, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 2, "max_score": 0.6931472, "hits": [ { "_index": "test", "_type": "doc", "_id": "8", "_score": 0.6931472, "_source": { "title": "love HuBei", "content": "people very love HuBei", "tags": ["HuBei","love"] } }, { "_index": "test", "_type": "doc", "_id": "7", "_score": 0.6931472, "_source": { "title": "love China", "content": "people very love China", "tags": ["China","love"] } } ] } }可以发现，title里有关love的关键字都查出来了，但是只想精确匹配 love China，按照下面的写法看看能不能查出来：{ "query": { "term": { "title": "love China" } } }执行发现无数据，从概念上看，term属于精确匹配，只能查单个词.1.2. terms 查询想用term匹配多个词怎么做？可以使用 terms 来：{ "query": { "terms": { "title": ["love", "China"] } } }查询结果如：{ "took": 1, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 2, "max_score": 0.6931472, "hits": [ { "_index": "test", "_type": "doc", "_id": "8", "_score": 0.6931472, "_source": { "title": "love HuBei", "content": "people very love HuBei", "tags": ["HuBei","love"] } }, { "_index": "test", "_type": "doc", "_id": "7", "_score": 0.6931472, "_source": { "title": "love China", "content": "people very love China", "tags": ["China","love"] } } ] } }发现全部查询出来，为什么？因为terms里的 [ ] 多个是或者的关系，只要满足其中一个词就可以.想要通知满足两个词的话，就得使用bool的must来做，如下：{ "query": { "bool": { "must": [ { "term": { "title": "love" } }, { "term": { "title": "china" } } ] } } }可以看到，上面使用china是小写的。当使用的是大写的China 进行搜索的时候，发现搜不到任何信息。这是为什么了？title 这个词在进行存储的时候，进行了分词处理。这里使用的是默认的分词处理器进行了分词处理。可以看看如何进行分词处理的？1.3. 分词处理器GET test/_analyze { "text" : "love China" }结果为：{ "tokens": [ { "token": "love", "start_offset": 0, "end_offset": 4, "type": "", "position": 0 }, { "token": "china", "start_offset": 5, "end_offset": 10, "type": "", "position": 1 } ] }分析出来的为love和china的两个词. 而term只能完完整整的匹配上面的词，不做任何改变的匹配. 所以，使用China这样的方式进行的查询的时候，就会失败.2. match 和 match_parse 查询2.1. match 查询原理匹配查询match是核心查询语法，它的主要应用场景就是全文搜索，例如：GET /music/children/_search { "query": { "match": { "name": "wake" } } }ES 执行的步骤：[1] - 检索字段类型：match的字段 name 为 text 类型，是一个 analyzed 的字段，那么查询条件的字符串也应该被analyzed.[2] - 分析查询字符串：将查询字符串"wake"传入分词器中，因为只有一个单词，所以match最终执行的是单个底层的term查询.[3] - 查找匹配文档：用 term 倒排索引中查找 wake 然后获取一组包含该词的文档.[4] - 为每个文档评分：用term查询计算每个文档相关度评分，即TF、IDF、length norm 算法. 执行得到的结果，如：{ "took": 1, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 0.2876821, "hits": [ { "_index": "music", "_type": "children", "_id": "2", "_score": 0.2876821, "_source": { "id": "a810fad4-54cb-59a1-9b7a-82adb46fa58d", "author": "John Smith", "name": "wake me, shark me", "content": "don't let me sleep too late, gonna get up brightly early in the morning", "language": "english", "tags": "enlighten", "length": 55, "isRelease": true, "releaseDate": "2019-12-21" } } ] } }因为样本数据的问题，暂时只有一条文档匹配.再如：[1] - 搜索name中包含"you"或"sunshine"的文档GET /music/children/_search { "query": { "match": { "name": "you sunshine" } } }[2] - 搜索name中包含"you"和"sunshine"的文档GET /music/children/_search { "query": { "match": { "name": { "query": "you sunshine", "operator": "and" //使用 and 关键字 } } } }[3] - 搜索"you"、"my"、"sunshine"、"teeth" 4个关键字中，至少包含3个的文档GET /music/children/_search { "query": { "match": { "name": { "query": "you my sunshine teeth", "minimum_should_match": "75%" //指定至少匹配其中的多少个关键字 } } } }2.2. match 查询先用 love China来匹配：GET test/doc/_search { "query": { "match": { "title": "love China" } } }返回结果如：{ "took": 1, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 2, "max_score": 1.3862944, "hits": [ { "_index": "test", "_type": "doc", "_id": "7", "_score": 1.3862944, "_source": { "title": "love China", "content": "people very love China", "tags": [ "China", "love" ] } }, { "_index": "test", "_type": "doc", "_id": "8", "_score": 0.6931472, "_source": { "title": "love HuBei", "content": "people very love HuBei", "tags": [ "HuBei", "love" ] } } ] } }发现两个都查出来了，为什么？因为match进行搜索的时候，会先进行分词拆分，拆完后，再来匹配，上面两个内容，他们title的词条为： love china hubei ，搜索的为love China 进行分词处理得到为love china ，并且属于或的关系，只要任何一个词条在里面就能匹配到. 如果想 love 和 China 同时匹配到的话，怎么做？使用 match_phrase.2.3. match_phrase 查询match_phrase 称为短语搜索，要求所有的分词必须同时出现在文档中，同时位置必须紧邻一致.GET test/doc/_search { "query": { "match_phrase": { "title": "love china" } } }返回结果为：{ "took": 5, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 1.3862944, "hits": [ { "_index": "test", "_type": "doc", "_id": "7", "_score": 1.3862944, "_source": { "title": "love China", "content": "people very love China", "tags": [ "China", "love" ] } } ] } }2.4. match 查询之 minimum_should_matchminimum_should_match：当operator参数设置为or时，该参数用来控制应该匹配的分词的最少数量；{"query":{ "match":{ "字段名":{ "query":"查询内容", "operator":"or", "minimum_should_match":"70%" } } } }数字可以是负数，例如有4个term的匹配，当匹配度为-25%与75%，其意义是一样的，都是最少匹配三个，但处理5个term时，-25%表示至少匹配四个，而75%表示至少匹配三个term.3. 参考[1] - Elasticsearch系列---深入全文搜索 - 2020-03-03 - 知乎[2] - ES搜索 term与match区别 bool查询 - 2020.07.05
ES 支持类似于在SQL中使用 AND、OR 以及 NOT 的运算, 称之为布尔查询（Boolean Query）,如：[1] - must：文档必须符合其中所有的查询条件，包含多个条件时类似于SQL中的AND[2] - should：文档必须符合其中任意一个及以上查询条件（可由minimum_should_match指定需要满足的条件数量），包含多个条件时类似于SQL中的OR[3] - must_not：文档必须不符合其中所有的查询条件，类似于SQL中的NOT，且返回的结果的分值都为0[4] - filter：效果与使用must相同，但不影响查询结果的分值（score）ES 使用布尔查询时，需要将查询的条件写在bool查询语句中，且同个bool查询语句可以有多个不同的条件，如：{ "query": { "bool": { "must": { "term": { "age": 20 } }, "must_not": { "term": { "gender": "male" } } } } }该查询是，返回 age 值为 20，且 gender 值不为 male 的文档结果.其等同于：SELECT * FROM xxx WHERE age = 20 AND gender != "male";1. must 查询当使用 must查询时，文档必须符合其中包括的所有查询条件.当must查询只包括一个查询条件时，可在DSL(Domain Specific Language，即 ES 使用的结构化查询语言)中使用JSON对象的形式表示，如：{ "query": { "bool": { "must": { "term": { "age": 20 } } } } }其等同于：SELECT * FROM xxx WHERE age = 20;使用must时可以同时指定多个查询条件，在DSL中它以数组的形式表示，效果类似于SQL中的AND运算，如：{ "query": { "bool": { "must": [ { "term": { "age": 20 } }, { "term": { "gender": "male" } } ] } } }其等同于：SELECT * FROM xxx WHERE age = 20 AND gender = "male";2. fliter 查询使用 filter 查询时其效果等同于 must 查询，但不同于 must 查询的是，filter 查询不参与查询结果的分值计算，它返回的文档的分值始终为0. filter 的使用场景适合于过滤不需要的文档，但又不影响最终计算的得分.如：{ "query": { "bool": { "filter": { "term": { "status": "active" } } } } }该查询是，返回所有 status 的值为"active"的文档，其得分均为0.0.3. should 查询should 查询类似于SQL中的OR语句，当其中包括两个及两个以上的条件时，其查询的结果必须至少满足其中一个.当只有一个查询条件时，即结果必须满足该条件，如：{ "query": { "bool": { "should": [ { "term": { "age": 20 } }, { "term": { "gender": "male" } }, { "range": { "height": { "gte": 170 } } }, ] } } }其等同于：SELECT * FROM xxx WHERE age = 20 OR gender = "male" or height >= 170;should 查询与SQL中的OR运算较为不同的一点是，should 查询可以使用 minimum_should_match 参数指定至少需要满足几个条件. 例如，查询的结果需要满足两个或两个以上的查询条件：{ "query": { "bool": { "should": [ { "term": { "age": 20 } }, { "term": { "gender": "male" } }, { "term": { "height": 170 } }, ], "minimum_should_match": 2 } } }在同一个 bool 语句中若不存在 must 或 filter 时，minimum_should_match 默认的值为1，即至少要满足其中一个条件；但若有其它 must 或 filter 存在时，minimum_should_match 默认值为0。例如，所有返回的文档age值必定为20，但其中可能包括有status值不为"active"的文档. 若需要二者同时生效，可在bool查询中增加一个参数"minimum_should_match": 1.{ "query": { "bool": { "must": { "term": { "age": 20 }, }, "should": { "term": { "status": "active" } } } } }4. must_not 查询must_not查询类似于SQL语句中的NOT运算，它将只返回不满足指定条件的文档. 例如：{ "query": { "bool": { "must_not": [ { "term": { "age": 20 } }, { "term": { "gender": "male" } } ] } } }其等同于：SELECT * FROM xxx WHERE age != 20 AND gender != "male";另外，must_not与filter相同，采用过滤器执行而不需要计算文档的得分，所以返回的结果对应的分值为0.5. 布尔组合查询ES 也可以在各个查询中进行嵌套查询. 但需要注意的是，布尔查询必须包含在bool查询语句中，所以在嵌套查询中必须在内部再次使用bool查询语句. 如：{ "query": { "must": [ { "bool": { "should": [ { "term": { "age": 20 } }, { "term": { "age": 25 } } ] } }, { "range": { "level": { "gte": 3 } } } ] } }其等同于：SELECT * FROM xxx WHERE (age = 20 OR age = 25) AND level >= 3;再如：POST _search { "query": { "bool" : { "must" : { "term" : { "user.id" : "kimchy" } }, "filter": { "term" : { "tags" : "production" } }, "must_not" : { "range" : { "age" : { "gte" : 10, "lte" : 20 } } }, "should" : [ { "term" : { "tags" : "env1" } }, { "term" : { "tags" : "deployed" } } ], "minimum_should_match" : 1, "boost" : 1.0 } } }6.参考[1] - Elasticsearch布尔查询及其组合查询 - 2020.11.17[2] - Elasticsearch - Boolean query
Elasticsearch 是一个分布式、RESTful 风格的搜索和数据分析引擎，能够解决不断涌现出的各种用例.1. ElasticSearch 基本概念1.1. 文档(Document)JSON 文档：1.2. 文档的元数据1.3. 索引(index)索引的不同语意：1.4. REST API1.5. 倒排1.5.1. 图书和搜索引擎的类比1.5.2. 正排索引1.5.3. 倒排索引的核心组成1.5.4. 示例 - Elasticsearch1.5.5. 倒排索引2. Docker 中部署 ElasticSearch传统方式安装 ElasticSearch，一般是从官网下载安装包，在宿主机进行手工解压编译安装. 但还是稍显繁琐.这里采用 Docker 的方式. 以 ElasticSearch 7.9.2 为例.Install Elasticsearch with Docker拉取镜像docker pull elasticsearch:7.9.2 #docker pull docker.elastic.co/elasticsearch/elasticsearch:7.9.22.1. 开发模式运行创建用户定义网络，以便于连接到相同网络的其它服务的运行，如 Kibana：docker network create elasticsearch_network运行 ElasticSearch:docker run -d --name elasticsearch_dev --net elasticsearch_network -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:7.9.2其中，[1] - --name - 指的是创建的容器的名称 [2] - -d - 标识后台运行 -[3] - -net host - 指为这个容器指定的网络组为host [4] - -p - 标识端口映射，宿主机端口:容器端口 [5] - -e "discovery.type=single-node" - 指定单节点模式运行2.2. Docker 中插件安装https://www.elastic.co/guide/en/elasticsearch/plugins/current/intro.html插件安装需要在 ElasticSearch 容器内部进行，首先，进入容器：docker exec -it 镜像名 /bin/bash #或 docker exec -it [容器_ID] /bin/bash这里以 IK 分词器插件为例，Github-medcl/elasticsearch-analysis-ik 中，选择与 ElasticSearch 版本对应的插件版本.安装如，./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.9.2/elasticsearch-analysis-ik-7.9.2.zip安装成功后，可以查看安装的插件列表：./bin/elasticsearch-plugin list注：如果业务固定，更推荐基于 es 的镜像制作一个带插件的 es 镜像注 - 分词器：[1] - https://github.com/microbun/elasticsearch-thulac-plugin[2] - https://www.elastic.co/guide/cn/elasticsearch/guide/current/icu-tokenizer.html2.3. Docker 中部署 KibanaKibana 是 Elasticsearch优秀的可视化的开源分析工具.这里采用 Docker 方式部署 Kinaba. (注：Kibana 和 ElasticSearch 一定要保持版本一致.)Running Kibana on Docker拉取镜像：docker pull kibana:7.9.2kibana.yml:server.name: kibana server.host: "0" elasticsearch.hosts: [ "http://192.168.1.7:9200" ]运行镜像：docker run -d --name kibana_dev -v /path/to/kibana.yml:/usr/share/kibana/config/kibana.yml --net elasticsearch_network -p 5601:5601 kibana:7.9.2 docker run -d --name kibana_dev -e ELASTICSEARCH_URL=http://192.168.1.7:9200 -p 5601:5601 kibana:7.9.2http://192.168.1.7:5601/ 即可访问 kibana.3.ElasticSearch - curl所有其他语言可以使用 RESTful API 通过端口 9200 和 Elasticsearch 进行通信，可以用 web 客户端访问 Elasticsearch . 也可以使用 curl 命令来和 Elasticsearch 交互.Elasticsearch 为Groovy、JavaScript、.NET、 PHP、 Perl、 Python 和 Ruby 等编程语言提供了官方客户端，还有很多社区提供的客户端和插件，所有这些都可以在 Elasticsearch Clients 中找到. 一个 Elasticsearch 请求和任何 HTTP 请求一样由若干相同的部件组成：curl -X<VERB> '<PROTOCOL>://<HOST>:<PORT>/<PATH>?<QUERY_STRING>' -d ' '其中，<> 参数说明如：标识说明VERB适当的 HTTP 方法 或 谓词 : GET、 POST、 PUT、 HEAD 或者 DELETE。PROTOCOLhttp 或者 https（如果在 Elasticsearch 前面有一个 https 代理）HOSTElasticsearch 集群中任意节点的主机名，或者用 localhost 代表本地机器上的节点。PORT运行 Elasticsearch HTTP 服务的端口号，默认是 9200 。PATHAPI 的终端路径（例如 _count 将返回集群中文档数量）。Path 可能包含多个组件，例如：_cluster/stats 和 _nodes/stats/jvm 。QUERY_STRING任意可选的查询字符串参数 (例如 ?pretty 将格式化地输出 JSON 返回值，使其更容易阅读)BODY一个 JSON 格式的请求体 (如果请求需要的话)3.1. 文档的CRUD3.2. Create 一个文档3.3. Get 一个文档3.4. Index 文档3.5. Update 文档3.6. mget 批量读取3.7. msearch 批量查询示例如，计算集群中文档的数量：curl -XGET 'http://localhost:9200/_count?pretty' -d ' { "query": { "match_all": {} } } 'Elasticsearch 返回一个 HTTP 状态码（例如：200 OK）和（除HEAD请求）一个 JSON 格式的返回值。前面的 curl 请求将返回一个像下面一样的 JSON 体：{ "count" : 0, "_shards" : { "total" : 5, "successful" : 5, "failed" : 0 } }https://www.elastic.co/guide/cn/elasticsearch/guide/current/_talking_to_elasticsearch.html3.1. 示例 - IK 分词器使用https://github.com/medcl/elasticsearch-analysis-ik[1] - 创建索引(index)curl -XPUT http://localhost:9200/index[2] - 创建映射(mapping)curl -XPOST http://localhost:9200/index/_mapping -H 'Content-Type:application/json' -d' { "properties": { "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" } } }'[3] - 索引文档curl -XPOST http://localhost:9200/index/_create/1 -H 'Content-Type:application/json' -d' {"content":"美国留给伊拉克的是个烂摊子吗"} ' curl -XPOST http://localhost:9200/index/_create/2 -H 'Content-Type:application/json' -d' {"content":"公安部：各地校车将享最高路权"} ' curl -XPOST http://localhost:9200/index/_create/3 -H 'Content-Type:application/json' -d' {"content":"中韩渔警冲突调查：韩警平均每天扣1艘中国渔船"} ' curl -XPOST http://localhost:9200/index/_create/4 -H 'Content-Type:application/json' -d' {"content":"中国驻洛杉矶领事馆遭亚裔男子枪击 嫌犯已自首"} '[4] - 高亮查询curl -XPOST http://localhost:9200/index/_search -H 'Content-Type:application/json' -d' { "query" : { "match" : { "content" : "中国" }}, "highlight" : { "pre_tags" : ["<tag1>", "<tag2>"], "post_tags" : ["</tag1>", "</tag2>"], "fields" : { "content" : {} } } } '返回结果，如：{ "took": 14, "timed_out": false, "_shards": { "total": 5, "successful": 5, "failed": 0 }, "hits": { "total": 2, "max_score": 2, "hits": [ { "_index": "index", "_type": "fulltext", "_id": "4", "_score": 2, "_source": { "content": "中国驻洛杉矶领事馆遭亚裔男子枪击 嫌犯已自首" }, "highlight": { "content": [ "<tag1>中国</tag1>驻洛杉矶领事馆遭亚裔男子枪击 嫌犯已自首 " ] } }, { "_index": "index", "_type": "fulltext", "_id": "3", "_score": 2, "_source": { "content": "中韩渔警冲突调查：韩警平均每天扣1艘中国渔船" }, "highlight": { "content": [ "均每天扣1艘<tag1>中国</tag1>渔船 " ] } } ] } } 4. ElasticSearch - python通过 Pip 安装 ElasticSearch 客户端，如：pip install elasticsearch异步客户端：pip install elasticsearch[async]参考：[1] - Using Asyncio with Elasticsearch.[2] - https://github.com/MagicStack/asyncpg示例如：from elasticsearch import Elasticsearch esclient = Elasticsearch(['localhost:9200']) response = esclient.search( index='social-*', body={ "query": { "match": { "message": "myProduct" } }, "aggs": { "top_10_states": { "terms": { "field": "state", "size": 10 } } } } )常用命令如下：4.1. 文档索引建立文档索引，需要指定三个信息：index，idx 和 body.示例一，如：from datetime import datetime from elasticsearch import Elasticsearch es = Elasticsearch() body = { 'author': 'author_name', 'text': 'Interensting content...', 'timestamp': datetime.now(), } res = es.index(index="test-index", id=1, body=body) print(res['result'])示例2，如：#创建索引，若存在即忽略 #指定建立的类型，所用分词器，是否建立索引等，即预先定义好字段 body = { 'mappings': { 'properties': { 'uuid':{ 'type':'keyword', 'index':'true' }, 'url':{ 'type':'text', 'index':'false' }, 'title': { 'type': 'text', 'index':'true', 'analyzer': 'ik_max_word', 'search_analyzer':'ik_max_word' }, 'text':{ 'type': 'text', 'index':'true', 'analyzer': 'ik_max_word', 'search_analyzer':'ik_max_word' } } } } es.indices.create(index='test',ignore=400,body=body) #插入数据 actions={ 'uuid':data_id, 'url':data_url, 'title':data_title, 'text':data_text } es.index(index="test",body = actions)4.2. 文档读取文档读取，需要指定其 index 和 id。res = es.get(index="test-index", id=1) print(res['_source'])4.3. 索引刷新对索引进行刷新(refresh).es.indices.refresh(index="test-index")4.4. 文档搜索采用 search() 返回与查询相匹配的结果.示例一，如：res = es.search(index="test-index", body={"query": {"match_all": {}}}) print("Got %d Hits:" % res['hits']['total']['value']) for hit in res['hits']['hits']: print("%(timestamp)s %(author)s: %(text)s" % hit["_source"])示例二，如：body_query = { "query":{ "match":{ "text":"运动" } } } res=es.search(index="test",size='200') #按条件查询数目 query_str={'query':{'match':{'cgdsId': cgds_code}}} total_num_data= es.count(index=index, body=query_str)['count']4.5. 文档更新文档更新(update)，需要指定三个信息：index，id 和 body.from datetime import datetime from elasticsearch import Elasticsearch es = Elasticsearch() doc = { 'author': 'author_name', 'text': 'Interensting modified content...', 'timestamp': datetime.now(), } res = es.update(index="test-index", id=1, body=doc) print(res['result'])4.6. 文档删除文档删除，需要指定其 index 和 id.es.delete(index="test-index", id=1)参考[1] - Docker 中部署 ElasticSearch 以及安装 IK 分词器插件[2] - Elasticsearch 核心技术与实战 - 2021.03.04[3] - 在Docker容器中运行Elasticsearch, Kibana和Cerebro
又拍图片管家亿级图像搜图系统[转]
原图：Milvus 助力又拍图片管家亿级图像搜图系统 - 2020.06.12原文：The Journey to Optimize Billion-Scale Image Search - 2020.08.13又拍图片管家当前服务了千万级用户，管理了百亿级图片. 当用户的图库变得越来越庞大时，业务上急切的需要一种方案能够快速定位图像，即直接输入图像，然后根据输入的图像内容来找到图库中的原图及相似图，而以图搜图服务就是为了解决这个问题. 作者有幸独立负责并实施了整个以图搜图系统从技术调研、到设计验证、以及最后工程实现的全过程. 而整个以图搜图服务也是经历了两次的整体演进：从 2019 年初开始第一次技术调研，经历春节假期，2019 年 3、4 月份第一代系统整体上线；2020 年初着手升级方案调研，经历春节及疫情，2020 年 4 月份开始第二代系统的整体升级. 本文将会简述两代搜图系统背后的技术选型及基本原理.1. 基础概要1.1. 图像是什么与图像打交道，必须要先知道：图像是什么？答案：像素点的集合. 比如：左图红色圈中的部分其实就是右图中一系列的像素点. 再举例：假设上图红色圈的部分是一幅图像，其中每一个独立的小方格就是一个像素点（简称像素），像素是最基本的信息单元，而这幅图像的大小就是 11 x 11 px .1.2. 图像的数学表示每个图像都可以很自然的用矩阵来表示，每个像素点对应的就是矩阵中的一个元素.1.3. 二值图像二值图像的像素点只有黑白两种情况，因此每个像素点可以由 0 和 1 来表示. 比如一张 4 * 4 二值图像：0 1 0 1 1 0 0 0 1 1 1 0 0 0 1 01.4. RGB 图像红（Red）、绿（Green）、蓝（Blue）作为三原色可以调和成任意的颜色，对于 RGB 图像，每个像素点包含 RGB 共三个通道的基本信息，类似的，如果每个通道用 8 bit 表示即 256 级灰度，那么一个像素点可以表示为：([0 .. 255], [0 .. 255], [0 .. 255])比如一张 4 * 4 RGB 图像：图像处理的本质实际上就是对这些像素矩阵进行计算.1.5. 搜图的技术问题如果只是找原图，也就是像素点完全相同的图像，那么直接对比它们的 MD5 值即可. 然而，图像在网络的传输过程中，常常会遇到诸如压缩、水印等等情况，而 MD5 算法的特点是，即使是小部分内容变动，其最终的结果却是天差地别，换句话说只要图片有一个像素点不一致，最后都是无法对比的. 对于一个以图搜图系统而言，我们要搜的本质上其实是内容相似的图片，为此，需要解决两个基本的问题：[1] - 把图像表示或抽象为一个计算机数据，这个数据必须是可以进行对比计算的直接用专业点的话说就是：图像的特征提取[2] - 特征计算（相似性计算）2. 第一代搜图系统2.1. 特性提取 - 图像抽象第一代搜图系统在特征提取上使用的是 Perceptual hash 即 pHash 算法，这个算法的基本原理是什么？如上图所示，pHash 算法就是对图像整体进行一系列变换最后构造 hash 值，而变换的过程可以理解为对图像进行不断的抽象，此时如果对另外一张相似内容的图像进行同样的整体抽象，那么其结果一定是非常接近的.2.2. 特性计算 - 相似性计算对于两张图像的 pHash 值，具体如何计算其相似的程度？答案是 Hamming distance 汉明距离，汉明距离越小，图像内容越相似. 汉明距离又是什么？就是对应位置不同比特位的个数. 例如：第一个值： 0 1 0 1 0 第二个值： 0 0 0 1 1以上两个值的对应位置上有 2 个比特位是不相同的，因此它们的汉明距离就是 2 . OK ，相似性计算的原理我们知道了，那么下一个问题是：如何去计算亿级图片对应的亿级数据的汉明距离？简而言之，就是如何搜索？在项目早期其实我并没有找到一个满意的能够快速计算汉明距离的工具（或者说是计算引擎），因此我的方案进行了一次变通. 变通的思想是：如果两个 pHash 值的汉明距离是接近的，那么将 pHash 值进行切割后，切割后的每一个小部分大概率相等. 例如：第一个值： 8 a 0 3 0 3 f 6 第二个值： 8 a 0 3 0 3 d 8我们把上面这两个值分割成了 8 块，其中 6 块的值是完全相同的，因此可以推断它们的汉明距离接近，从而图像内容也相似. 经过变换之后，其实你可以发现，汉明距离的计算问题，变成了等值匹配的问题，我把每一个 pHash 值给分成了 8 段，只要里面有超过 5 段的值是完全相同的，那么我就认为他们相似. 等值匹配如何解决？这就很简单了，传统数据库的条件过滤不就可以用了嘛. 当然，我这里用的是 ElasticSearch（ ES 的原理本文就不介绍了，读者可以另行了解），在 ES 里的具体操作就是多 term 匹配然后 minimum_should_match 指定匹配程度. 为什么搜索会选择 ElasticSearch ？第一点，它能实现上述的搜索功能；第二点，图片管家项目本身就正在用 ES 提供全文搜索的功能，使用现有资源，成本是非常低的.2.3. 第一代系统总结第一代搜图系统在技术上选择了 pHash + ElasticSearch 的方案，它拥有如下特点：[1] - pHash 算法计算简单，可以对抗一定程度的压缩、水印、噪声等影响. [2] - ElasticSearch 直接使用了项目现有资源，在搜索上没有增加额外的成本. 当然这套系统的局限性也很明显：由于 pHash 算法是对图像的整体进行抽象表示，一旦我们对整体性进行了破坏，比如在原图加一个黑边，就会几乎无法判断相似性. 为了突破这个局限性，底层技术截然不同的第二代搜图系统应运而生.3. 第二代搜图系统3.1. 特征提取在计算机视觉领域，使用人工智能相关的技术基本上已经成了主流，同样，我们第二代搜图系统的特征提取在底层技术上使用的是 CNN 卷积神经网络. CNN 卷积神经网络这个词让人比较难以理解，重点是回答两个问题：[1] - CNN 能干什么？[2] - 搜图为什么能用 CNN ?AI 领域有很多赛事，图像分类是其中一项重要的比赛内容，而图像分类就是要去判断图片的内容到底是猫、是狗、是苹果、是梨子、还是其它对象类别. CNN 能干什么？提取特征，进而识物，把这个过程简单的理解为，从多个不同的维度去提取特征，衡量一张图片的内容或者特征与猫的特征有多接近，与狗的特征有多接近，等等等等，选择最接近的就可以作为我们的识别结果，也就是判断这张图片的内容是猫，还是狗，还是其它. CNN 识物又跟我们找相似的图像有什么关系？我们要的不是最终的识物结果，而是从多个维度提取出来的特征向量，两张内容相似的图像的特征向量一定是接近的. 具体使用哪种 CNN 模型？我使用的是 VGG16 ，为什么选择它？首先，VGG16 拥有很好的泛化能力，也就是很通用；其次，VGG16 提取出来的特征向量是 512 维，维度适中，如果维度太少，精度可能会受影响，如果维度太多，存储和计算这些特征向量的成本会比较高.3.1.1. 基于 Keras 实现from keras.applications.vgg16 import VGG16 from keras.preprocessing import image from keras.applications.vgg16 import preprocess_input import numpy as np #VGG16模型 model = VGG16(weights='imagenet', include_top=False) #图片加载 img_path = 'elephant.jpg' img = image.load_img(img_path, target_size=(224, 224)) #实际项目中，图片内容往往会先将图像转换为Bytes. import io from PIL import Image # img_bytes: image content bytes img = Image.open(io.BytesIO(img_bytes)) img = img.convert('RGB') img = img.resize((224, 224), Image.NEAREST) #图片预处理 x = image.img_to_array(img) x = np.expand_dims(x, axis=0) x = preprocess_input(x) #模型预测，特征提取 features = model.predict(x) #特征向量得到特征向量后，进行归一化：from numpy import linalg as LA #Normalization norm_feat = feat[0]/LA.norm(feat[0])图像，如截图，往往会存在一定的黑边，黑边是没有实际意义的，且增加了计算量，因此通用做法是移除黑边的操作.#!--*--coding: utf-8--*-- import numpy as np from keras.preprocessing import image def RemoveBlackEdge(img): ''' Args: img: PIL image instance Returns: PIL image instance ''' width = img.width img = image.img_to_array(img) img_without_black = img[~np.all(img == np.zeros((1, width, 3), np.uint8), axis=(1, 2))] img = image.array_to_img(img_without_black) return img3.2. 向量搜索引擎从图像提取特征向量的问题已经解决了，那么剩下的问题就是：[1] - 特征向量如何存储？[2] - 特征向量如何计算相似性，即如何搜索？对于这两个问题，直接使用开源的向量搜索引擎 Milvus 就可以很好的解决，截至目前，Milvus 在我们的生产环境一直运行良好.3.3. 项目实践及注意事项3.3.1. CPU 环境要求Milvus 的使用需要 CPU 支持 avx2 指令集. Linux 系统可以通过如下命令查看是否支持：cat /proc/cpuinfo | grep flags #或 cat /proc/cpuinfo | grep flags | grep avx2 3.3.2. 系统容量支撑计划(Capacity Planning)在设计系统时，首先要考虑的就是系统的容量支撑能力. 如：需要存储多少数据？数据需要多少内存和磁盘空间？假设向量的每一维是 float32 的，每个 float 占用 4 Bytes. 则 512 维的向量需要 2KB 存储. 类推：一千条 512维向量需要 2MB 存储；一百万条 512 维向量需要 2GB 存储；一千万条 512 维向量需要 20GB 存储；一亿条 512 维向量需要 200GB 存储；十亿条 512 维向量需要 2TB 存储.如果想要把所有数据存储到内存里，则系统至少需要对应的内存容量.推荐采用 Milvus sizing tool.实际上，在内存不足时，Milvus 会自动在磁盘刷新数据. 此外，除了考虑原始向量数据的存储，还需要考虑其他数据的存储，如日志数据等.3.3.3. 系统配置参考 Milvus 文档.成功启动 Milvus 服务后，可以在 home/$USER/milvus 的路径下看到 Milvus 的文件夹. 其中包含以下文件：milvus/db（数据库存储）milvus/logs（日志存储）milvus/conf（设置文件）server_config.yaml（服务设置）具体可见：https://milvus.io/cn/docs/v0.10.6/milvus_config.md.3.3.4. 数据库设计(Database Design)Collection 和 Partition：Collection，即表. 包含一组 entity，可以等价于关系型数据库系统（RDBMS）中的表Partition，分区. Collection 中的分片.集合、分区以及数据段的关系如下所示：Milvus 的 Collection 和 Partiation 的数据结构比较简单，即，ID+vector. 换句话说，表里只有两列数据， ID 和向量数据.注：ID 需要是整数.需要确保 collection 内 ID 的唯一性，而不是 partition 内.[1] - 条件过滤在使用传统数据库时，可以指定特定字段作为过滤条件. 而 Milvus 不支持的. 但可以通过使用 collections 和 partitions 进行简单的条件过滤. 例如，对于大规模的图像数据，且属于不同的用户，那么可以将数据根据用户进行分区. 则可以将用户作为过滤条件，其实际上是指定了数据分区.[2] - 结构化数据和向量映射Milvus 仅支持 ID + vector 的数据结构. 但在商业场景中，往往需要处理的具有商业意义的结构化数据. 换句话说，需要通过向量查询得到结构化化数据. 那么，就需要维护结构化数据与向量之间的 ID 映射关系.structured data ID <--> mapping table <--> Milvus ID[3] - 索引选择参考：[1] - 索引类型 https://milvus.io/cn/docs/v0.10.6/index.md[2] - 如何选择索引 https://medium.com/@milvusio/how-to-choose-an-index-in-milvus-4f3d15259212[3] - Milvus 最佳实践之如何选择索引类型 https://blog.csdn.net/weixin_44839084/article/details/1034710833.3.5. 搜索结果处理Milvus 的搜索结果是 ID + distance 的 collection.ID - collection 中的 ID.Distance - [0,1]区间的距离向量，表示相似度，值约小，两个向量约相似.[1] - 过滤 ID 是 -1 的数据当 collections 数量过小时，搜索结果中可能会包含很多 ID 是 -1 的数据. 需要手工过滤.[2] - 分页(Pagination)搜索向量是很不同的. 查询结果会根据相似性降序排列，选择最相似的 topK 的结果.Milvus 不支持分页. 需要根据商业场景实现分页函数. 例如，如果每页有 10 个搜索结果，且仅想显示到第三页，则需要指定 topK=30 且仅返回最后 10 个结果.[3] - 相似度阈值两张图像的特征向量间的距离是在 [0, 1] 区间的值. 如果在商业场景中，想要判断两张图像是否相似，需要指定相似度阈值. 如果距离小于阈值，则认为两张图像相似；如果距离大于阈值则认为不同. 需要结合商业场景调整阈值.3.3. 第二代系统总结第二代搜图系统在技术上选择了 CNN + Milvus 的方案，而这种基于特征向量的搜索在业务上也提供了更好的支持.