AI语义检索

基于分词和倒排索引技术的传统中文检索归根结底还是依赖关键词匹配,这种方式有其局限性,它依赖是的词项的字面值匹配,无法理解语义层面的相似性。例如,用户搜索“笔记本电脑”,传统检索只能匹配到包含“笔记本”或“电脑”的文档,而“便携式计算机”、“MacBook”这种语义相近但字面差异较大的文档就可能被漏掉,即使我们使用同义词表也难以穷尽海量口语、别称、行业术语与衍生表述等,依旧无法突破词项字面值,实现真正的意图检索。而AI语义检索则试图通过向量化技术解决这个问题。它将文本、图片等内容转换成高维空间中的向量,然后通过计算向量之间的空间距离来衡量语义相似度。这让搜索引擎能够理解用户的真实意图,提升检索的相关性。

Elasticsearch从7.3版本开始引入了稠密向量(Dense Vector)字段类型,从8.x版本开始,ES对向量检索的支持大幅增强,包括内置了HNSW索引和原生KNN查询。这篇笔记我们将学习ES中和AI语义检索相关的内容。

什么是向量嵌入

在介绍具体的ES配置之前,我们得先简单理解什么是向量嵌入(Embedding)。

Embedding是将文本、图像等非结构化数据映射到一个高维数值向量空间的过程,经过Embedding的一段文本、一张图片等资源会变成一个由几百到几千个浮点数组成的数组,类似[0.12, -0.34, 0.87, ...]的形式,这个数组的大小就是向量的维度。向量嵌入过程需要相应的模型支持,而Embedding模型就是那个将文本、图像等非结构化数据转为稠密向量,以向量距离表征语义和内容相似度的编码模型,Embedding模型基于对大量自然语言文本的训练而具备了这些能力。

时至今日,无论文本还是图像,以Transformer架构为主的Embedding模型因为效果更好因此在实际生产环境中使用率更高。以文本为例,BGE、M3E,以及最新发布的Qwen3-Embedding等都是目前行业内主流的Embedding模型。部署这些模型都需要运行环境有较高的计算能力,能够运行这类模型的推理引擎也大多是基于C/C++和AVX等专用CPU指令集或CUDA这样的GPU高性能计算技术实现的。

对于本地的演示环境,我们可以部署一个规模较小的Embedding模型,我这里使用的模型是bge-base-zh-v1.5,推理使用Python3.10和PyTorch。首先创建Python虚拟环境并安装sentence-transformers依赖。

pip install sentence-transformers

相关的Python脚本如下,其中MODEL_PATH需要改成你的本地模型路径,脚本执行后,我们可以手动输入文本并获取768维的Embedding结果向量用于写入ES进行测试。

import json
from sentence_transformers import SentenceTransformer

MODEL_PATH = r"C:\Users\HUAWEI\workspace\models\bge-base-zh-v1.5"

print(f"Loading model from {MODEL_PATH} ...")
model = SentenceTransformer(MODEL_PATH)
print("Model loaded.\n")

while True:
    text = input("Input (exit to quit): ").strip()
    if not text:
        continue
    if text.lower() == "exit":
        print("Bye.")
        break
    embedding = model.encode(text, normalize_embeddings=True)
    print(json.dumps(embedding.tolist()))
    print()

ES中使用向量字段

ES中实现AI语义检索通常使用dense_vector字段,下面例子代码我们基于之前的products索引,对namedescription添加向量字段。

PUT /products
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "category": { "type": "keyword" },
      "brand": { "type": "keyword" },
      "price": { "type": "double" },
      "stock": { "type": "integer" },
      "tags": { "type": "keyword" },
      "is_deleted": { "type": "boolean" },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss"
      },
      "location": { "type": "geo_point" },
      "name_vector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      },
      "description_vector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

其中新增的name_vectordescription_vector字段用于存储商品名称和描述的向量表示,关键参数说明如下。

dims:向量维度,必须与你使用的Embedding模型输出维度完全一致,我们这里使用的是768维向量。一旦索引创建后维度不可修改,只能重建索引。

index:是否为该向量字段建立索引,设置为true后ES会使用HNSW算法构建近似最近邻索引,实现高效的KNN查询,后面相关检索的例子也是基于KNN的;设置为false表示只存储向量数据,这样查询时只能做暴力遍历,生产环境不推荐使用暴力搜索方式。注意ES7.x没有该配置也不支持索引,仅支持暴力搜索。

similarity:向量相似度计算方式,常用的有以下表格中列出的几种。注意该参数也是ES8.x才支持的,ES7.x中只能用cosineSimilaritydotProduct等函数做暴力匹配。

相似度算法 说明 适用场景
cosine 余弦相似度,计算向量夹角 常用于文本语义检索
dot_product 点积,要求向量已归一化 向量已归一化时性能略好于cosine
l2_norm 欧氏距离 常用于图像检索等场景

写入向量字段时,我们将其指定为数字数组即可。

POST /products/_doc
{
  "name": "李宁跑步鞋男款轻弹减震运动鞋",
  "description": "采用李宁䨻科技中底,轻弹减震,适合日常训练和半马比赛",
  "category": "运动鞋",
  "brand": "李宁",
  "price": 599.00,
  "stock": 200,
  "tags": ["跑步", "运动", "减震"],
  "is_deleted": false,
  "created_at": "2024-01-15 10:00:00",
  "name_vector": [0.12, -0.34, 0.87, ...],
  "description_vector": [0.08, -0.29, 0.91, ...]
}

这里由于篇幅限制,我们就不把每个向量的768维全粘帖出来了,实际这里你需要用之前的Python程序将文本进行向量嵌入,并将结果黏贴到对应的dense_vector字段下。

注意:Kibana DevTools在输入内容过长时似乎会将输入截断,上面例子的768维向量黏贴进去后在Kibana DevTools中运行其实会报错,这里建议用curl命令或Postman进行测试。

KNN向量检索

准备好数据后,我们可以使用KNN(K-Nearest Neighbor)来进行基于向量的语义检索,下面是一个最基础的例子。

GET /products/_search
{
  "knn": {
    "field": "name_vector",
    "query_vector": [0.11, -0.31, 0.85, ...],
    "k": 5,
    "num_candidates": 50
  },
  "_source": ["name", "description", "category", "price"]
}

query_vector是将用户输入的查询文本经过Embedding模型处理后得到的向量,k是返回最相似的前K个结果,num_candidates是HNSW每个分片候选集大小,值越大召回率越高但速度越慢,通常设置为k的5到10倍。这里我们还加了个_source配置,它规定返回只输出namedescriptioncategoryprice这几个字段,避免把巨大的向量字段也输出出来,方便我们观察结果。

小提示:bge-base-zh-v1.5生成检索用的向量时,根据官方文档需要给输入加个Prompt,否则在短Query检索长Passage(s2p)时检索效果可能较差,之前的Python脚本中,我们需要用下面字符串作为模型的输入,注意该Prompt是固定值为这个句子生成表示以用于检索相关文章:,不可随意修改。

query_with_prompt = f"为这个句子生成表示以用于检索相关文章:{user_query}"

返回结果中每个文档会有一个_score字段,它表示与查询向量的相似度得分,使用余弦相似度时得分范围在0到1之间,越接近1表示越相似。

纯语义检索在实际业务中往往还需要结合过滤条件,例如用户搜索“跑步鞋”时希望只在某个价格区间内检索或只查未删除的商品。ES支持在KNN查询中添加filter,过滤会在向量检索之前生效以提前减小搜索范围。下面是一个例子。

GET /products/_search
{
  "knn": {
    "field": "name_vector",
    "query_vector": [0.11, -0.31, 0.85, ...],
    "k": 5,
    "num_candidates": 50,
    "filter": [
      { "term": { "is_deleted": false } },
      { "range": { "price": { "gte": 300, "lte": 1000 } } },
      { "term": { "category": "运动鞋" } }
    ]
  },
  "_source": ["name", "description", "category", "brand", "price"]
}

混合检索

纯向量检索虽然更能理解语义,但它也有一些明显的短板,对于精确关键词(例如商品型号、品牌名称)的匹配效果反而不如传统倒排索引。混合检索(Hybrid Search)是将向量检索的语义理解能力与传统全文检索的精确匹配能力结合起来的方案,也是目前AI检索场景下的最佳实践。混合检索涉及对两种检索方式的结果融合与排序,乍一听融合两路检索结果我们可能想到直接将分数相加或者做加权平均,但这实际上是不行的,BM25分数和向量相似度分数的量纲完全不同,对分数做加权平均理论上没意义。对于这个问题目前RRF(Reciprocal Rank Fusion,倒数排名融合)是最常用的算法之一,RRF的核心思想其实很简单,它根据各自排名位置(而非BM25和余弦相似度分数)计算融合分数,有关这部分可以参考相关算法章节,本系列笔记主要介绍Elasticsearch因此就不展开介绍了;另一种融合方式是调用Rerank模型,它也是一种基于大量文本预训练的模型,但专用于基于语义相似度排序,相比RRF用Rerank模型在某些场景下效果更好,但显然调用模型的计算开销也大得多。

此外,ES从8.8版本开始其实内置支持了RRF排名算法来合并两路检索结果并原生支持混合检索,然而,RRF功能免费版不能使用,仅限Platinum/Enterprise级付费许可使用。实际开发中,如果我们使用的是免费版ES,其实也可以分两次查询数据,然后在应用侧进行RRF融合或调用Rerank模型重排序。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。