2023年12月4日

使用Supabase Vector进行语义搜索

本指南旨在演示如何将OpenAI嵌入存储在Supabase Vector(Postgres + pgvector)中,以实现语义搜索。

Supabase 是一个基于生产级SQL数据库Postgres构建的开源Firebase替代方案。由于Supabase Vector基于pgvector构建,您可以将嵌入向量存储在与其他应用数据相同的数据库中。结合pgvector的索引算法,向量搜索在大规模场景下仍能保持高速

Supabase 添加了一系列服务和工具生态系统,以尽可能快速地开发应用程序(例如自动生成的REST API)。我们将使用这些服务在Postgres中存储和查询嵌入向量。

本指南涵盖:

  1. 设置您的数据库
  2. 创建SQL表以存储向量数据
  3. 生成OpenAI嵌入向量 使用OpenAI的JavaScript客户端
  4. 存储嵌入向量到您的SQL表中,使用Supabase JavaScript客户端
  5. 执行语义搜索 通过使用Postgres函数和Supabase JavaScript客户端对嵌入向量进行操作

设置数据库

首先前往 https://database.new 配置您的Supabase数据库。这将在Supabase云平台上创建一个Postgres数据库。或者,如果您更倾向于在本地使用Docker运行数据库,可以按照本地开发选项进行操作。

在工作室中,跳转到SQL编辑器并执行以下SQL以启用pgvector:

-- Enable the pgvector extension
create extension if not exists vector;

在生产环境中,最佳实践是使用数据库迁移,以便所有SQL操作都能在源代码控制中管理。为了在本指南中保持简单,我们将直接在SQL编辑器中执行查询。如果您正在构建生产应用,可以随时将这些操作转移到数据库迁移中。

创建向量表

接下来我们将创建一个表来存储文档和嵌入向量。在SQL编辑器中运行:

create table documents (
  id bigint primary key generated always as identity,
  content text not null,
  embedding vector (1536) not null
);

由于Supabase基于Postgres构建,我们这里直接使用标准SQL。您可以根据应用需求自由修改此表。如果已有数据库表,只需在相应表中添加新的vector列即可。

关键需要理解的是vector数据类型,这是当我们之前启用pgvector扩展时新增的数据类型。向量的大小(此处为1536)表示嵌入的维度数量。由于本示例中我们使用OpenAI的text-embedding-3-small模型,因此将向量大小设置为1536。

让我们继续在这个表上创建一个向量索引,这样随着表的增长,未来的查询仍能保持高性能:

create index on documents using hnsw (embedding vector_ip_ops);

该索引使用HNSW算法对存储在embedding列中的向量进行索引,特别是当使用内积运算符(<#>)时。我们稍后在实现匹配函数时会详细解释这个运算符。

让我们也遵循安全最佳实践,在表上启用行级安全:

alter table documents enable row level security;

这将防止通过自动生成的REST API未经授权访问此表(稍后会详细介绍)。

生成OpenAI嵌入向量

本指南使用JavaScript生成嵌入向量,但您可以轻松修改它以使用任何OpenAI支持的语言

如果您正在使用JavaScript,可以随意选择您偏好的服务器端JavaScript运行时环境(Node.js、Deno、Supabase边缘函数)。

如果你正在使用Node.js,首先安装openai作为依赖项:

npm install openai

然后导入它:

import OpenAI from "openai";

如果您正在使用Deno或Supabase Edge Functions,可以直接从URL导入openai

import OpenAI from "https://esm.sh/openai@4";

在这个示例中,我们从https://esm.sh导入,这是一个CDN服务,它会自动为您获取相应的NPM模块并通过HTTP提供服务。

接下来我们将使用text-embedding-3-small生成一个OpenAI嵌入向量:

const openai = new OpenAI();
 
const input = "The cat chases the mouse";
 
const result = await openai.embeddings.create({
  input,
  model: "text-embedding-3-small",
});
 
const [{ embedding }] = result.data;

请注意,您需要一个OpenAI API密钥才能与OpenAI API进行交互。您可以通过名为OPENAI_API_KEY的环境变量传递此密钥,或者在实例化OpenAI客户端时手动设置:

const openai = new OpenAI({
  apiKey: "<openai-api-key>",
});

请记住:切勿在代码中硬编码API密钥。最佳实践是将其存储在.env文件中,并使用类似dotenv的库加载,或从外部密钥管理系统加载。

将嵌入向量存储到数据库中

Supabase 提供了一个自动生成的 REST API,能为您的每张表动态构建 REST 端点。这意味着您无需直接建立到数据库的 Postgres 连接——只需通过 REST API 即可与之交互。这在运行短期进程的无服务器环境中尤为有用,因为每次重新建立数据库连接可能代价高昂。

Supabase 提供了多个客户端库来简化与 REST API 的交互。在本指南中我们将使用JavaScript 客户端库,但您可以根据需要切换至您偏好的编程语言。

如果你正在使用Node.js,请安装@supabase/supabase-js作为依赖项:

npm install @supabase/supabase-js

然后导入它:

import { createClient } from "@supabase/supabase-js";

如果您正在使用Deno或Supabase边缘函数,可以直接从URL导入@supabase/supabase-js

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

接下来我们将实例化Supabase客户端并进行配置,使其指向您的Supabase项目。在本指南中,我们会将Supabase URL和密钥的引用存储在.env文件中,但您可以根据应用程序处理配置的方式自由修改此设置。

如果您正在使用Node.js或Deno,请将您的Supabase URL和服务角色密钥添加到.env文件中。如果使用云平台,您可以在Supabase仪表板的设置页面找到这些信息。如果在本地运行Supabase,可以通过在终端执行npx supabase status命令来获取这些信息。

.env

SUPABASE_URL=<supabase-url>
SUPABASE_SERVICE_ROLE_KEY=<supabase-service-role-key>

如果您正在使用Supabase边缘函数,这些环境变量会自动注入到您的函数中,因此您可以跳过上述步骤。

接下来我们将把这些环境变量引入到我们的应用中。

在Node.js中,安装dotenv依赖项:

npm install dotenv

并从process.env中获取环境变量:

import { config } from "dotenv";
 
// Load .env file
config();
 
const supabaseUrl = process.env["SUPABASE_URL"];
const supabaseServiceRoleKey = process.env["SUPABASE_SERVICE_ROLE_KEY"];

在Deno中,使用dotenv标准库加载.env文件:

import { load } from "https://deno.land/std@0.208.0/dotenv/mod.ts";
 
// Load .env file
const env = await load();
 
const supabaseUrl = env["SUPABASE_URL"];
const supabaseServiceRoleKey = env["SUPABASE_SERVICE_ROLE_KEY"];

在Supabase Edge Functions中,直接加载注入的环境变量即可:

const supabaseUrl = Deno.env.get("SUPABASE_URL");
const supabaseServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");

接下来让我们实例化我们的 supabase 客户端:

const supabase = createClient(supabaseUrl, supabaseServiceRoleKey, {
  auth: { persistSession: false },
});

从这里开始,我们使用supabase客户端将文本和嵌入向量(之前生成的)插入到数据库中:

const { error } = await supabase.from("documents").insert({
  content: input,
  embedding,
});

在生产环境中,最佳实践是检查响应error以查看插入数据时是否存在任何问题,并相应地进行处理。

最后让我们对数据库中的嵌入向量执行语义搜索。此时我们假设您的documents表中已填充了多条可供搜索的记录。

让我们在Postgres中创建一个执行语义搜索查询的匹配函数。请在SQL编辑器中执行以下操作:

create function match_documents (
  query_embedding vector (1536),
  match_threshold float,
)
returns setof documents
language plpgsql
as $$
begin
  return query
  select *
  from documents
  where documents.embedding <#> query_embedding < -match_threshold
  order by documents.embedding <#> query_embedding;
end;
$$;

该函数接收一个query_embedding参数,表示由搜索查询文本生成的嵌入向量(稍后会详细说明)。同时它还接收一个match_threshold参数,用于指定文档嵌入向量的相似度阈值,只有达到该阈值时query_embedding才会被视为匹配成功。

在函数内部我们实现了查询功能,主要完成两件事:

  • 筛选文档,仅包含嵌入向量与上述match_threshold匹配的文档。由于<#>运算符执行的是负内积(而非正内积),我们在比较前需对相似度阈值取反。这意味着match_threshold值为1时表示最相似,-1表示最不相似。
  • 按负内积(<#>)升序排列文档。这使我们能够优先检索匹配度最高的文档。

由于OpenAI嵌入已归一化,我们选择使用内积(<#>)运算符,因为它的性能略优于余弦距离(<=>)等其他运算符。需要注意的是,这种方法仅在嵌入向量归一化的情况下有效——如果未归一化,则应使用余弦距离。

现在我们可以通过应用程序使用supabase.rpc()方法来调用这个函数:

const query = "What does the cat chase?";
 
// First create an embedding on the query itself
const result = await openai.embeddings.create({
  input: query,
  model: "text-embedding-3-small",
});
 
const [{ embedding }] = result.data;
 
// Then use this embedding to search for matches
const { data: documents, error: matchError } = await supabase
  .rpc("match_documents", {
    query_embedding: embedding,
    match_threshold: 0.8,
  })
  .select("content")
  .limit(5);

在这个示例中,我们将匹配阈值设为0.8。请根据您的数据效果调整此阈值。

请注意,由于match_documents返回一组documents,我们可以将此rpc()视为常规表查询。具体来说,这意味着我们可以向此查询链接其他命令,如select()limit()。这里我们仅从documents表中选择我们关心的列(content),并限制返回的文档数量(本例中最多5个)。

此时,您已获得一份基于语义关系匹配查询的文档列表,并按相似度从高到低排序。

后续步骤

你可以将此示例作为其他语义搜索技术的基础,比如检索增强生成(RAG)。

有关OpenAI嵌入的更多信息,请阅读Embedding文档。

有关Supabase Vector的更多信息,请阅读AI & Vector文档。