使用 Supabase Vector 的语义搜索

2023年12月4日
在 Github 中打开

本指南的目的是演示如何将 OpenAI 嵌入存储在 Supabase Vector (Postgres + pgvector) 中,以用于语义搜索。

Supabase 是一个开源的 Firebase 替代方案,构建于 Postgres 之上,这是一个生产级的 SQL 数据库。由于 Supabase Vector 构建于 pgvector 之上,您可以将嵌入存储在与应用程序其余数据相同的数据库中。当与 pgvector 的索引算法结合使用时,向量搜索在大规模数据下仍然快速

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

本指南涵盖

  1. 设置数据库
  2. 创建可以存储向量数据的 SQL 表
  3. 使用 OpenAI 的 JavaScript 客户端生成 OpenAI 嵌入
  4. 使用 Supabase JavaScript 客户端将嵌入存储在 SQL 表中
  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 Edge Functions)。

如果您使用 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 Edge Functions,您可以直接从 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 Edge Functions,这些环境变量将自动注入到您的函数中,因此您可以跳过上述步骤。

接下来,我们将把这些环境变量拉入我们的应用程序。

在 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 文档。