在 Elasticsearch 中存储嵌入向量

Elasticsearch 完全支持存储和检索向量,这使其成为处理嵌入向量的理想数据库。

字段类型

在本教程的全文搜索章节中,您学习了如何创建包含多个字段的索引。当时提到,Elasticsearch 大部分情况下可以根据数据本身自动确定每个字段的最佳类型。即使 Elasticsearch 8.11 能够自动映射某些向量类型,在本节中,您也将显式定义此类型,以便了解更多关于 Elasticsearch 中类型映射的信息。

检索类型映射

与索引中每个字段关联的类型是在称为映射的过程中确定的,它可以是动态的或显式的。在本教程的全文搜索部分中创建的映射都是由 Elasticsearch动态生成的

Elasticsearch 客户端提供了一个get_mapping方法,该方法返回对给定索引有效的类型映射。如果您想自己探索这些映射,请启动一个 Python shell 并输入以下代码。

from app import es
es.es.indices.get_mapping(index='my_documents')

get_mapping()方法的响应是一个字典,其中包含有关索引中每个字段的信息。为了方便起见,以下是本教程全文搜索部分创建的my_documents索引的此信息的格式良好的结构。

{
  "my_documents": {
    "mappings": {
      "properties": {
        "category": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "content": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "created_on": {
          "type": "date"
        },
        "name": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "rolePermissions": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "summary": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        },
        "updated_at": {
          "type": "date"
        },
        "url": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword",
              "ignore_above": 256
            }
          }
        }
      }
    }
  }
}

由此可见,created_onupdated_at字段被自动设置为date类型,而其他所有字段都被设置为text类型。在确定类型时,Elasticsearch 首先检查数据的类型,这有助于它将数字、布尔和对象类型分配给字段。当字段数据为字符串时,它还会尝试查看数据是否与日期模式匹配。如果需要,也可以为数字启用基于模式的字符串检测。

文本字段具有一个包含keyword条目的fields定义。这称为子字段,是在适当情况下可用的替代或辅助类型。在 Elasticsearch 中,动态类型的text字段会获得一个keyword子字段。您已经使用过category.keyword子字段来对给定类别执行精确搜索。为了避免添加子字段,可以提供textkeyword的显式映射,然后这将成为主要且唯一的类型。

向索引添加向量字段

让我们向索引添加一个新字段,用于存储每个文档的嵌入向量。

显式映射的结构与 Elasticsearch 客户端的get_mapping()方法返回的响应的mappings键匹配。只需要提供需要显式类型的字段,因为映射中未包含的任何字段将像以前一样继续动态类型。

下面您可以看到Search类的create_index()方法的新版本,添加了一个名为embedding的显式类型字段。请将此方法替换为**search.py**中的方法。

class Search:
    # ...

    def create_index(self):
        self.es.indices.delete(index='my_documents', ignore_unavailable=True)
        self.es.indices.create(index='my_documents', mappings={
            'properties': {
                'embedding': {
                    'type': 'dense_vector',
                }
            }
        })

如您所见,embedding字段的类型为dense_vector,这是存储嵌入向量时的适当类型。稍后您将了解另一种向量类型sparse_vector,它在其他类型的语义搜索应用程序中很有用。

dense_vector类型接受一些参数,所有这些参数都是可选的。

  • dims:将要存储的向量的尺寸。从 8.11 版本开始,在插入第一个文档时会自动分配维度。
  • index:必须设置为True才能指示应为搜索索引向量。这是默认值。
  • similarity:比较向量时使用的距离函数。两个最常见的函数是dot_productcosine。点积效率更高,但它需要对向量进行归一化处理。默认值为cosine

向文档添加嵌入向量

在上一节中,您学习了如何使用 SentenceTransformers 框架和all-MiniLM-L6-v2模型生成嵌入向量。现在是将模型集成到应用程序中的时候了。

首先,可以在Search类的构造函数中实例化模型。

# ...
from sentence_transformers import SentenceTransformer

# ...

class Search:
    def __init__(self):
        self.model = SentenceTransformer('all-MiniLM-L6-v2')
        self.es = Elasticsearch(cloud_id=os.environ['ELASTIC_CLOUD_ID'],
                                api_key=os.environ['ELASTIC_API_KEY'])
        client_info = self.es.info()
        print('Connected to Elasticsearch!')
        pprint(client_info.body)
      
    # ...

正如您在本教程的全文搜索部分所回忆的那样,Search类具有insert_document()insert_documents()方法,分别用于将单个和多个文档插入索引中。这两个方法现在需要生成与每个文档对应的嵌入向量。

下一个代码块显示了这两个方法的新版本,以及一个返回嵌入向量的新get_embedding()辅助方法。

class Search:
    # ...

    def get_embedding(self, text):
        return self.model.encode(text)

    def insert_document(self, document):
        return self.es.index(index='my_documents', document={
            **document,
            'embedding': self.get_embedding(document['summary']),
        })

    def insert_documents(self, documents):
        operations = []
        for document in documents:
            operations.append({'index': {'_index': 'my_documents'}})
            operations.append({
                **document,
                'embedding': self.get_embedding(document['summary']),
            })
        return self.es.bulk(operations=operations)

修改后的方法将新的embedding字段添加到要插入的文档中。嵌入向量是从每个文档的summary字段生成的。通常,嵌入向量是从句子或短段落生成的,因此在这种情况下,摘要是理想的字段。其他选择可能是包含文档标题的name字段,或者可能是文档body中的前几句话。

有了这些更改,就可以重建索引,以便它为每个文档存储一个嵌入向量。要重建索引,请使用以下命令。

flask reindex

如果您需要提醒,flask reindex命令在**app.py**中的reindex()函数中实现。它调用Search类的reindex()方法,该方法依次调用create_index(),然后将**data.json**文件中的所有数据传递给insert_documents()

准备好构建最先进的搜索体验了吗?

充分先进的搜索并非一人之力所能完成。Elasticsearch 由数据科学家、ML 运维工程师和其他许多对搜索同样充满热情的人员提供支持。让我们携手合作,构建神奇的搜索体验,帮助您获得想要的结果。

自己试试看