跳转到主要内容

这是本节的多页打印视图。 .

返回本页常规视图.

应用案例

关于混合搜索、自适应检索、以图搜图、多租户架构、稀疏向量以及 RAG 问答系统的实战教程。

1 - 混合搜索

本节将向您展示如何使用 pgvecto.rs 实现混合搜索。

本节将向您展示如何使用 pgvecto.rs 实现混合搜索(Hybrid Search)。

Postgres 基于匹配操作符 @@ 提供了全文检索(Full Text Search)功能。当 tsvector(文档)与 tsquery(查询)匹配时,它会返回 true。这两种数据类型的书写先后顺序不影响结果:

SELECT 'a fat cat sat on a mat and ate a fat rat'::tsvector @@ 'cat & rat'::tsquery;
 ?column?
----------
 t

SELECT 'cat & rat'::tsquery @@ 'a fat cat sat on a mat and ate a fat rat'::tsvector;
 ?column?
----------
 t
提示

如上例所示,tsquery 与 tsvector 一样,并不仅仅是原始文本。tsquery 包含必须已经规范化为词位(lexeme)的搜索词项,并且可以使用 AND、OR、NOT 以及 FOLLOWED BY 操作符组合多个词项。在本例中,tsquery 'cat & rat' 与 tsvector 'a fat cat sat on a mat and ate a fat rat' 匹配,因为文档中同时包含 'cat' 和 'rat' 两个词项。

借助 pgvecto.rs,您可以在单条查询中同时结合使用全文检索和向量搜索。在本例中,我们创建一张名为 items 的表,包含两个字段:content 和 embedding。其中 content 列的类型为 text,embedding 列的类型为 vector(3)。我们向表中插入四条记录:

CREATE TABLE items (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(3) NOT NULL -- 3 维
);

INSERT INTO items (content, embedding) VALUES
  ('a fat cat sat on a mat and ate a fat rat', '[1, 2, 3]'),
  ('a fat dog sat on a mat and ate a fat rat', '[4, 5, 6]'),
  ('a thin cat sat on a mat and ate a thin rat', '[7, 8, 9]'),
  ('a thin dog sat on a mat and ate a thin rat', '[10, 11, 12]');

查询该表将得到如下内容:

postgres=# select * from items;
 id |                  content                   |  embedding   
----+--------------------------------------------+--------------
  2 | a fat cat sat on a mat and ate a fat rat   | [1, 2, 3]
  3 | a fat dog sat on a mat and ate a fat rat   | [4, 5, 6]
  4 | a thin cat sat on a mat and ate a thin rat | [7, 8, 9]
  5 | a thin dog sat on a mat and ate a thin rat | [10, 11, 12]
(4 rows)

首先,我们使用全文检索来查找 content 中同时包含单词 cat 和 rat 的记录。to_tsvector 函数会将文本转换为 tsvector,而操作符 @@ 则检查 tsvector 是否与 tsquery 匹配。

SELECT content, embedding
FROM items
WHERE to_tsvector('english', content) @@ 'cat & rat'::tsquery;
提示

to_tsvector 会将文本文档解析为词元(token),将词元归约为词位(lexeme),并返回一个列出所有词位及其在文档中出现位置的 tsvector。

SELECT to_tsvector('english', 'a fat  cat sat on a mat - it ate a fat rats');
                  to_tsvector
-----------------------------------------------------
 'ate':9 'cat':3 'fat':2,11 'mat':7 'rat':12 'sat':4

在上面的例子中可以看到,由于文本搜索配置设为了 english,生成的 tsvector 不包含停用词 a、on 或 it,单词 rats 被归约为 rat,标点符号 - 也被忽略了。

我们将得到以下结果:

                  content                   | embedding 
--------------------------------------------+-----------
 a fat cat sat on a mat and ate a fat rat   | [1, 2, 3]
 a thin cat sat on a mat and ate a thin rat | [7, 8, 9]
(2 rows)

接下来,我们使用向量搜索,按照每条记录的嵌入向量与查询向量之间的距离对结果进行排序:

SELECT content, embedding
FROM items
WHERE to_tsvector('english', content) @@ 'cat & rat'::tsquery
ORDER BY embedding <-> '[7, 8, 9]'::vector;

此时,嵌入向量为 [7, 8, 9] 的记录将排在第一位,而嵌入向量为 [1, 2, 3] 的记录排在第二位:

                  content                   | embedding 
--------------------------------------------+-----------
 a thin cat sat on a mat and ate a thin rat | [7, 8, 9]
 a fat cat sat on a mat and ate a fat rat   | [1, 2, 3]
(2 rows)

您可以对全文检索结果和向量搜索结果的排名进行归一化,然后将两者融合在一起:

-- 查询从名为 "semantic_search" 的公用表表达式(CTE)开始。
WITH semantic_search AS (
    SELECT id, content, embedding, 
    RANK () OVER (ORDER BY embedding <-> '[7, 8, 9]') AS rank
    FROM items
    ORDER BY embedding <-> '[7, 8, 9]'::vector
    ), 
-- 定义另一个名为 "full_text_search" 的 CTE。
    full_text_search AS (
    SELECT id, content, embedding, 
    RANK () OVER (ORDER BY 
        ts_rank(to_tsvector('english', content), 
            'cat & rat'::tsquery) DESC) AS rank
    FROM items
    WHERE to_tsvector('english', content) @@ 'cat & rat'::tsquery
    ORDER BY ts_rank(
        to_tsvector('english', content), 'cat & rat'::tsquery) DESC
    )
-- 主查询从两个 CTE 中选择列并融合排名。
    SELECT 
        COALESCE(semantic_search.id, full_text_search.id) AS id,
        COALESCE(semantic_search.content, full_text_search.content) AS content,
        COALESCE(semantic_search.embedding, full_text_search.embedding) AS embedding,
        COALESCE(1.0 / (1 + semantic_search.rank), 0.0) + 
        COALESCE(1.0 / (1 + full_text_search.rank), 0.0) AS rank
    FROM semantic_search FULL OUTER JOIN full_text_search USING (id)
    ORDER BY rank DESC;
提示

上述 SQL 查询对 items 表同时执行了语义搜索和全文检索,并根据相关性得分检索结果。以下是该查询的详细解析:

  • 查询以一个名为 semantic_search 的公用表表达式(CTE)开始。它选择 id、content、embedding 列,并使用测量 embedding 列与向量 '[7, 8, 9]' 之间距离的 <-> 操作符计算排名(rank),结果按该距离排序。
  • 定义另一个名为 full_text_search 的 CTE。它选择与前一个 CTE 相同的列,但结合全文检索查询使用 ts_rank 函数计算排名。to_tsvector 函数将 content 列转换为 tsvector,而 tsquery 'cat & rat' 则匹配 content 列中同时包含 "cat" 和 "rat" 的行,结果按全文检索相关性排名排序。
  • 主查询使用 COALESCE 函数从两个 CTE 中选取列,以处理其中一个 CTE 可能为空值的情况。同时,它将两个 CTE 排名的倒数相加,计算出综合得分(rank)。
  • 主查询在两个 CTE 之间基于 id 列执行全外连接(FULL OUTER JOIN)。
  • 最终结果按照综合得分降序排列。

在这条查询中,我们先分别计算全文检索结果和向量搜索结果的排名,然后将两部分结果融合,最终得到如下输出:

 id |                  content                   |  embedding   |          rank          
----+--------------------------------------------+--------------+------------------------
  4 | a thin cat sat on a mat and ate a thin rat | [7, 8, 9]    | 1.00000000000000000000
  2 | a fat cat sat on a mat and ate a fat rat   | [1, 2, 3]    | 0.70000000000000000000
  3 | a fat dog sat on a mat and ate a fat rat   | [4, 5, 6]    | 0.33333333333333333333
  5 | a thin dog sat on a mat and ate a thin rat | [10, 11, 12] | 0.33333333333333333333
(4 rows)

2 - 自适应检索与缩短嵌入向量

OpenAI 最新的嵌入模型支持缩短嵌入向量,开发者只需移除序列末尾的部分数值,即可在保持有效文本表示的同时降低存储与计算开销。

OpenAI 最新的嵌入模型支持缩短嵌入向量(Shortening embeddings,即套娃嵌入 Matryoshka Embeddings),开发者只需简单地“移除序列末尾的部分数值”,依然能够保持有效的文本语义表示。

这带来了极其灵活的使用方式。例如,当使用仅支持最高 1024 维嵌入的向量数据存储时,开发者现在仍然可以使用我们最好的嵌入模型 text-embedding-3-large,只需将 API 参数 dimensions 指定为 1024,即可将嵌入从 3072 维缩短为 1024 维,通过牺牲少许精度来换取更小的向量体积。

缩短嵌入向量

OpenAI 的新嵌入模型 text-embedding-3-large 默认生成 3072 维的嵌入向量。

但您可以安全地移除序列末尾的部分数值,同时仍保留有效的文本语义表示。例如,您可以将嵌入向量缩短为 1024 维。

详情

您可能需要对截断后的向量进行归一化(Normalization),以确保缩短后的嵌入向量与点积等距离计算方式兼容。如果您直接通过 OpenAI 的 API 调用 text-embedding-3-large 生成低维嵌入(而非自行截断原始嵌入),API 会自动帮您完成归一化处理。

embedding_3072_dim_from_openai = [-0.00611, -0.00269, ...]
embedding_1024_dim_from_openai = [-0.01520, -0.03839, ...]

embedding_1024_dim_truncated_from_3072_dim = [-0.00611, -0.00269, ...]
embedding_1024_dim_normalized = [-0.01520, -0.03839, ...]

缩短嵌入向量有诸多优势:

  • 降低内存占用:缩短嵌入向量可以显著减少存储向量所需的内存。
  • 更低的维度:缩短嵌入向量可以降低向量维度,适配某些仅支持特定维度上限(例如 2000 维)的向量数据存储。
  • 更快的索引与搜索速度:较小的嵌入向量比大型嵌入向量的索引构建和搜索速度更快。
  • 性能与成本的权衡:缩短嵌入向量可以通过牺牲少许精度来换取更小的向量规模和更低的资源成本。

不过,缩短后的嵌入向量在精度(召回率)上不如完整维度的嵌入向量。

性能与成本的权衡

我们进行了一项实验,评估缩短后的嵌入向量与完整嵌入向量相比的实际效果。基准测试使用了 dbpedia-entities-openai3-text-embedding-3-large-3072-1M 数据集,并在规格为 n2-standard-8(8 vCPU、32 GB 内存)的 Google Cloud 虚拟机上运行。

我们创建了一张表,并向其中填充了 100 万条 3072 维的嵌入向量:

CREATE TABLE openai3072 (
  id bigserial PRIMARY KEY,
  text_embedding_3_large_3072_embedding vector(3072),
  text_embedding_3_large_3072_embedding bvector(3072)
);
详情

向表中插入 100 万条嵌入向量的代码如下:

from pgvecto_rs.sdk import PGVectoRs, Record
from pgvecto_rs.psycopg import register_vector
import psycopg

conn = psycopg.connect(conninfo='postgresql://postgres:mysecretpassword@localhost:5433/postgres', autocommit=True)

import json
import os

import numpy as np
import random
from datasets import load_dataset
from datasets.exceptions import DatasetNotFoundError
from tqdm import tqdm

MODEL_NAME, DIMENSIONS = "text-embedding-3-large", 3072
DATASET_NAME = f"Qdrant/dbpedia-entities-openai3-{MODEL_NAME}-{DIMENSIONS}-1M"
collection_name = f"dbpedia-{MODEL_NAME}-{DIMENSIONS}"
embedding_column_name = f"{MODEL_NAME}-{DIMENSIONS}-embedding"

dataset = load_dataset(
        DATASET_NAME,
        streaming=False,
        split="train",
    )

bs = 1000
for i, record in tqdm(enumerate(dataset)):
    if i % bs == 0:
        points = []
    points.append({
        "embedding": record[embedding_column_name]
    })
    if i % bs == bs - 1:
        batch_points = f", ".join([f"('{p['embedding']}')" for p in points])
        conn.execute(f"INSERT INTO openai3072 (text_embedding_3_large_3072_embedding) VALUES %s" % (batch_points))
    print(f"Inserted {i} records")

此外,我们分别为 256 维、1024 维和 3072 维的嵌入向量创建了索引,并针对每个维度执行了近似最近邻(ANN)查询。注意,我们无需单独创建 256 维或 1024 维的列,因为可以直接通过表达式索引截取 3072 维嵌入向量的前 256 个或前 1024 个维度:

-- 创建 256 维表达式索引
CREATE INDEX openai_vector_index_256 ON public.openai3072 USING vectors (((text_embedding_3_large_3072_embedding[0:256])::vector(256)) vector_l2_ops);

-- 创建 1024 维表达式索引
CREATE INDEX openai_vector_index_1024 on openai3072 using vectors((text_embedding_3_large_3072_embedding[0:1024]::vector(1024)) vector_l2_ops);

-- 创建 3072 维完整索引
CREATE INDEX openai_vector_index on openai3072 using vectors(text_embedding_3_large_3072_embedding vector_l2_ops);

同时,我们还为 3072 维嵌入构建了二值向量索引(Binary Vector Index),并使用该索引执行了 ANN 查询:

CREATE INDEX openai_vector_index_bvector ON public.openai3072 USING vectors (text_embedding_3_large_3072_bvector bvector_l2_ops);

我们针对要检索的最近邻数量设置了不同的 LIMIT 值(5、10、50、100)来运行 KNN 查询。

详情

我们借助 pgbench 测量了每种查询的每秒查询数(QPS)和平均查询耗时:

export PGHOST="localhost"
export PGUSER="postgres"
export PGPASSWORD="mysecretpassword"
export PGDATABASE="postgres"
export PGPORT=5433
export VECTOR_DIM=3072
TEST_TIME=300
for clients in 2 4 8 16 32; do
    pgbench -n -T "${TEST_TIME}" \
      --file=query.sql \
      -c "${clients}" -j "${clients}"
done

query.sql 文件包含以下 SQL 查询:

\set random_id random(1, 1000000)

select id from openai3072 order by text_embedding_3_large_3072_embedding <-> (select text_embedding_3_large_3072_embedding from openai3072 where id = :random_id) limit 100;

实验结果如下所示:

不出所料,3072 维嵌入向量展现出最高的准确率;1024 维嵌入向量的准确率略低,约为 85%;256 维嵌入向量的准确率最低,约为 65%。另一方面,二值向量索引达到了约 80% 的准确率。

在每秒请求数(RPS)指标方面,二值向量索引表现出最高的性能效率,其次是 256 维嵌入、1024 维嵌入,最后是 3072 维嵌入。

详情
table

索引的内存占用是另一个需要重点考量的方面。为 100 万条 3072 维的二值向量建立索引仅需约 600MB 内存;相比之下,3072 维浮点向量索引则消耗了约 18GB 内存。两者在内存占用上存在巨大差异——3072 维浮点向量索引消耗的内存约为二值向量索引的 30 倍。

通过自适应检索提升准确率

与完整维度索引相比,低维索引和二值向量索引通过牺牲一定的准确率,换取了更高的 RPS 和更低的内存占用。而自适应检索(Adaptive Retrieval)技术可以将两者的优势结合起来,实现兼顾准确率、内存占用和查询速度的优化方案。

自适应检索背后的逻辑非常简单。以“获取前 100 个最佳候选结果”为例,我们可以分两步执行:

  1. 首先查询低维索引或二值向量索引,从 100 万条嵌入向量中快速召回前 200 个候选结果。这一步操作非常快。
  2. 使用全维度精确 KNN 查询对候选集进行重排序(Rerank),从中选出最终的前 100 个结果。由于精确 KNN 非常适合在小规模候选集上进行高精度相似度计算,因此在这里用于重排序再合适不过。

虽然重排序步骤会比单次粗召回略慢一点,但它仍然远快于直接查询完整维度的索引。在 pgvecto.rs 中可以通过如下 SQL 函数轻松实现:

CREATE OR REPLACE FUNCTION match_documents_adaptive(
  query_embedding vector(3072),
  match_count int
)
RETURNS SETOF openai3072
LANGUAGE SQL
AS $$
WITH shortlist AS (
  SELECT *
  FROM openai3072
  ORDER BY (text_embedding_3_large_3072_embedding[0:256])::vector(256) <-> (query_embedding[0:256])::vector(256)
  LIMIT match_count * 2
)
SELECT *
FROM shortlist
ORDER BY text_embedding_3_large_3072_embedding <-> query_embedding
LIMIT match_count;
$$;

match_documents_adaptive 函数接收查询向量 query_embedding 和匹配数量 match_count 作为输入参数。它首先使用 256 维索引从 100 万条记录中检索出 match_count * 2 条候选记录,随后使用 3072 维精确距离对这些候选记录进行重排序,返回最终的前 match_count 条记录。

基于二值向量索引和 1024 维索引的自适应检索函数也可以用完全相同的方式实现。

我们使用自适应检索技术再次运行了基准测试,并在下图中将其标记为 Adaptive Retrieval:

adaptive retrieval benchmark

可以看到,增加重排序步骤后,低维索引和二值向量索引的准确率得到了显著提升。

结合自适应检索的二值向量索引达到了约 95% 的准确率,而与不使用自适应检索相比,其 RPS 仅略有下降。更令人印象深刻的是,结合自适应检索的二值向量索引的内存占用依然只有约 600MB,与未启用自适应检索时完全相同(因为完整向量存储在磁盘表中,仅在重排序少量候选行时按需读取)。

详情
table

总结

缩短嵌入向量可以减少存储向量所需的内存占用,并加快索引构建和搜索的速度。然而,单纯使用缩短后的嵌入向量在准确率上不及完整嵌入向量。因此,我们需要结合自适应检索(两阶段检索与重排序)技术来提升低维索引和二值索引的准确率。

综合来看,对于 text-embedding-3-large 模型,结合自适应检索的二值向量索引是性价比最高的选择;如果您需要极高的准确率(例如 99%),也可以考虑使用结合自适应检索的 1024 维索引。

附注:本实验中使用的所有代码和数据均已在 pgvecto.rs-matryoshka-embeddings-benchmark 仓库中开源。

3 - 以图搜图与智能图像搜索

智能图像搜索(又称基于内容的图像检索)超越了传统的关键词搜索,让用户能够根据图像的视觉内容进行搜索。

智能图像搜索,又称基于内容的图像检索(Content-Based Image Retrieval),超越了传统的基于关键词的搜索方式,使用户能够根据图像的视觉内容搜索图像。例如,您可以直接搜索包含红蘑菇的图片(图片来自 immich):

image-search

这项功能看似神奇,因为它允许您在没有任何标签或关键词的情况下搜索图片。搜索引擎能够理解图像和视频的内容,并找出与查询语义相似的结果。

工作原理

智能图像搜索利用先进的计算机视觉技术和机器学习算法来理解图像中的视觉特征,从而使用户无需依赖特定的关键词或元数据即可执行自由形式的搜索。

第一步是从图像中提取有意义的特征,并生成每张图像的向量表示。这通常使用诸如 CLIP 之类的深度学习模型来完成。生成的向量表示被称为嵌入(Embedding),它是一个代表该图像视觉特征的数值列表。

随后,您可以使用像 pgvecto.rs 这样的向量数据库来存储这些嵌入向量,并执行高效的向量相似度搜索。

示例

我们将利用连接文本与图像的神经网络模型 CLIP 为图像生成嵌入向量。这些嵌入向量捕捉了图像中呈现的视觉概念。接着,我们将使用基于 Postgres 构建的可扩展向量搜索工具 pgvecto.rs 来存储这些嵌入并执行高效的向量搜索。通过结合 CLIP 和 pgvecto.rs 的强大能力,我们可以根据视觉内容检索相似图像。

生成嵌入向量

您可以使用 CLIP 为图像生成嵌入向量:

from transformers import CLIPProcessor, CLIPModel

processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")

def compute_image_embeddings(list_of_images):
    return model.get_image_features(**processor(images=list_of_images, return_tensors="pt", padding=True))

存储嵌入向量

假设您有一张名为 images 的表,包含三个列:id、author 和 embedding,其中 embedding 列的类型为 vector(512)。您可以按如下方式将图像数据插入表中:

-- 创建表
CREATE TABLE images (
    id SERIAL PRIMARY KEY,
    author TEXT NOT NULL,
    vector vector(512) NOT NULL -- 512 维
);

INSERT INTO images (author, embedding) VALUES
  ('Allen', '[...]');

搜索相似图像

您可以像这样搜索相似的图像:

SELECT * FROM images ORDER BY embedding <-> '[...]' LIMIT 5;

假设您希望搜索距离最近的 64 条记录,且要求结果中不包含作者 Allen 的图片。

您可能会期望以下 SQL 能够正常工作:

SET vectors.hnsw_ef_search = 64;
SELECT * FROM images ORDER BY embedding <-> '[...]' WHERE author != 'Allen' LIMIT 64;

在传统的 basic 搜索模式下,您可能最终只得到了 32 条记录,因为 HNSW 算法在图搜索过程中会绕过过滤条件,仅依赖 PostgreSQL 在搜索完成后对固定数量的候选集进行后置过滤(Post-filtering)。

您可以使用 vbase 搜索模式来解决这一问题。在 vbase 模式下,搜索结果变成了一个迭代器(Iterator),可以持续不断地返回近似最近邻结果。数据库可以从中不断拉取结果,直到满足用户的过滤条件和返回数量要求为止。这与 faiss 等其他向量搜索库截然不同——后者只能返回固定大小的结果集,且该大小必须在搜索前预先确定。对于带有过滤条件的查询,事先无法预知需要检索多少候选集才能凑够目标数量,因此传统做法要么导致返回结果数量不足,要么因过度搜索而增加延迟。该设计思想源于论文 VBASE: Unifying Online Vector Similarity Search and Relational Queries via Relaxed Monotonicity。

您可以通过 SQL 语句 SET vectors.search_mode = vbase; 启用 vbase 模式,从而获得符合预期的行为。immich 的图像搜索功能便采用了这一模式。从 0.2 版本起,vbase 已成为默认搜索模式。

真实生产案例

开源相册应用 immich 使用了智能图像搜索功能,使用户能够根据视觉内容搜索照片。此处提供的代码片段源自 immich 中图像搜索功能的真实实现:

SELECT a.*, s.*, e.*
FROM asset_entity AS a
INNER JOIN smart_search AS s ON s.assetId = a.id
LEFT JOIN exif_info AS e ON e.assetId = a.id
WHERE a.ownerId IN (:...userIds)
  AND a.isArchived = false
  AND a.isVisible = true
  AND a.fileCreatedAt < NOW()
ORDER BY s.embedding <=> :embedding
LIMIT :numResults;

该查询根据指定的过滤条件和向量距离排序,从 asset_entity、smart_search 和 exif_info 三张表中检索数据。下面我们来逐步拆解这条查询:

SELECT a.*, s.*, e.*

查询的这一部分指定了要从 asset_entity、smart_search 和 exif_info 表中选择的列,并使用别名 a、s 和 e 分别引用这三张表。

FROM asset_entity AS a
INNER JOIN smart_search AS s ON s.assetId = a.id
LEFT JOIN exif_info AS e ON e.assetId = a.id

在这里,asset_entity 表别名为 a,smart_search 表别名为 s,exif_info 表别名为 e。查询使用 INNER JOIN 基于条件 s.assetId = a.id 关联 asset_entity 与 smart_search,并使用 LEFT JOIN 基于条件 e.assetId = a.id 引入 exif_info 表中的匹配记录。

详情

asset_entity 表的结构定义如下:

CREATE TABLE asset_entity (
  id UUID PRIMARY KEY,
  deviceAssetId VARCHAR NOT NULL,
  ownerId VARCHAR NOT NULL,
  libraryId VARCHAR NOT NULL,
  deviceId VARCHAR NOT NULL,
  type VARCHAR NOT NULL,
  originalPath VARCHAR NOT NULL,
  resizePath VARCHAR,
  webpPath VARCHAR DEFAULT '',
  thumbhash BYTEA,
  encodedVideoPath VARCHAR,
  createdAt TIMESTAMPTZ NOT NULL,
  updatedAt TIMESTAMPTZ NOT NULL,
  deletedAt TIMESTAMPTZ,
  fileCreatedAt TIMESTAMPTZ NOT NULL,
  localDateTime TIMESTAMPTZ NOT NULL,
  fileModifiedAt TIMESTAMPTZ NOT NULL,
  isFavorite BOOLEAN DEFAULT false,
  isArchived BOOLEAN DEFAULT false,
  isExternal BOOLEAN DEFAULT false,
  isReadOnly BOOLEAN DEFAULT false,
  isOffline BOOLEAN DEFAULT false,
  checksum BYTEA NOT NULL,
  duration VARCHAR,
  isVisible BOOLEAN DEFAULT true,
  livePhotoVideoId UUID,
  originalFileName VARCHAR NOT NULL,
  sidecarPath VARCHAR,
  stackId UUID,
  CONSTRAINT fk_asset_owner FOREIGN KEY (ownerId) REFERENCES user_entity (id) ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT fk_asset_library FOREIGN KEY (libraryId) REFERENCES library_entity (id) ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT fk_asset_live_photo_video FOREIGN KEY (livePhotoVideoId) REFERENCES asset_entity (id) ON DELETE SET NULL ON UPDATE CASCADE,
  CONSTRAINT fk_asset_stack FOREIGN KEY (stackId) REFERENCES asset_stack_entity (id) ON DELETE SET NULL ON UPDATE CASCADE
);
WHERE a.ownerId IN (:...userIds)
  AND a.isArchived = false
  AND a.isVisible = true
  AND a.fileCreatedAt < NOW()

在这一部分中,查询对 asset_entity 表应用了过滤条件:筛选出 ownerId 在指定的 userIds 列表中、isArchived 为 false、isVisible 为 true,且文件创建时间 fileCreatedAt 早于当前时间(NOW())的行。

ORDER BY s.embedding <=> :embedding

查询使用余弦距离操作符 <=> 将 smart_search 表的 embedding 列与传入的查询向量 :embedding 进行比较,并按距离从小到大排序。

LIMIT :numResults;

最后,将查询返回的行数限制为 :numResults 参数指定的值。

这是一个典型的真实生产环境案例,展示了如何在实际业务系统中结合关系查询、权限过滤与向量搜索来实现智能图像检索。

4 - 多租户架构

多租户对于 SaaS 应用至关重要,它允许单个实例服务多个用户或群组,同时确保数据隐私与安全。本文概述如何使用 pgvecto.rs 设计并实现一个简单的多租户向量搜索系统。

多租户(Multi-tenancy)对于 SaaS 应用至关重要,它允许单个数据库实例同时服务多个用户或团队,同时确保数据隐私与隔离安全。本文将介绍如何使用 pgvecto.rs 设计并实现一个简洁的多租户向量搜索系统。

设计数据库表结构

在多租户系统中,基于用户的隔离能否成功,取决于结构良好的数据库 Schema 设计:

用户与文档(Users and Documents)

每个用户由唯一的 user_id 标识,该标识直接关联到其拥有的文档以实现私有访问控制。documents 表存储每个用户的文档,每篇文档具有唯一的 id、title(标题)和 content(内容)。还可以在 documents 表中加入其他元数据字段以增强搜索功能。为了更真实地还原生产场景,我们将 user_id 关联在 documents 表上,而不是直接冗余在切片嵌入(chunk embeddings)表中。

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    title TEXT,
    content TEXT,
    -- 其他元数据字段
);

文本切片(Chunks)

chunks 表用于存储文档切片的向量化表示(embedding),并通过外键 document_id 引用其所属的文档。

CREATE TABLE chunks (
    id SERIAL PRIMARY KEY,
    document_id INTEGER REFERENCES documents(id),
    embedding vector(512) NOT NULL
);

实体关系图(ER 图)

实现多租户查询

创建索引

在 chunks.embedding 列上创建向量索引以加速查询过程。这里我们使用点积(dot product)作为相似度度量方式:

CREATE INDEX idx_chunks_embedding ON chunks USING vectors (embedding vector_dot_ops);

按 user_id 执行带过滤的向量查询

SELECT chunks.id AS chunk_id, documents.id AS document_id, documents.title
FROM chunks
INNER JOIN documents ON chunks.document_id = documents.id
WHERE documents.user_id = 'alice' /* 替换为实际的 user_id */
ORDER BY chunks.embedding <-> '[3,2,1]' /* 替换为查询向量 */ LIMIT 5;

在这条查询中:

  • SELECT 子句包含了 chunks.id AS chunk_id、documents.id AS document_id 和 documents.title,分别返回切片 ID、文档 ID 和文档标题。
  • INNER JOIN 与 WHERE 条件确保只检索属于指定用户(如 'alice')的文档所关联的文本切片。
  • ORDER BY 子句计算切片嵌入向量与给定查询向量 '[3,2,1]' 之间的距离,并按相似度排序,最接近(最相似)的切片会排在最前面返回。
  • 在实际使用时,请将 '[3,2,1]' 替换为真实的查询嵌入向量,并将 'alice' 替换为目标用户的实际 ID。

性能优化建议

在带过滤条件的向量搜索过程中,索引会扫描额外的候选点并逐一评估过滤条件。因此,如果系统中的用户 ID 数量非常庞大,或者某个用户的过滤条件命中率极低(数据极其稀疏),查询速度可能会受到影响。为了进一步提升大规模多租户场景下的性能,建议结合使用 PostgreSQL 的表分区(Partitioned Table)功能按租户进行分区。

5 - 稀疏向量搜索

过去,混合搜索结合了传统的关键词搜索与基于向量的相似度搜索。如今,稀疏向量可以替代关键词搜索,通过纯向量搜索释放数据流水线的全部潜力。

过去,混合搜索(Hybrid Search)通常将传统的基于关键词的搜索与基于向量的相似度搜索结合在一起。然而,稀疏向量(Sparse Vector)可以作为关键词搜索的替代方案,通过纯向量搜索充分释放数据流水线的全部潜力。

提示

有关基于关键词的混合搜索的更多信息,请参阅混合搜索。

本文将探讨如何结合使用 BGE-M3 与 pgvecto.rs,同时利用稀疏向量和稠密向量发掘混合搜索的潜力。

下面让我们开始吧。

稀疏向量与稠密向量嵌入概览

稀疏向量是仅包含极少数非零值的高维向量,非常适合传统的信息检索场景。例如,一个拥有 32,000 个维度但只有 2 个非零元素的向量就是典型的稀疏向量。

S[1×32000]=[000.0150000⋯0.5430] S[1\times 32000]=\left[ \begin{matrix} 0 & 0 & 0.015 & 0 & 0 & 0 & 0 & \cdots & 0.543 & 0 \end{matrix} \right]

稠密向量(Dense Vector)则是来自神经网络的嵌入表示。它们由文本嵌入模型生成,绝大部分或所有元素均为非零值。它们的维度通常较低,例如 256 维或 1536 维,远低于稀疏向量。

D[1×256]=[0.3421.7740.0870.3210.6640.8700.001⋯0.5430.999] D[1\times 256]=\left[ \begin{matrix} 0.342 & 1.774 & 0.087 & 0.321 & 0.664 & 0.870 & 0.001 & \cdots & 0.543 & 0.999 \end{matrix} \right]

稠密向量和稀疏向量都用于表示文本片段的特征,但两者存在显著差异。稠密向量嵌入中的单个维度本身并没有明确的字面含义,因为它过于抽象;只有将所有维度组合在一起时,它们才能表达输入文本的整体语义。

相比之下,稀疏向量的维度等于词表(Vocabulary)的大小,其中每个元素代表对应词项在该文本中的权重。

通常情况下,权重越高的词在文本中越重要。稀疏向量简化了对文本与词表之间关系的分析和可视化;而若要对稠密向量进行可视化,则需要借助额外的降维算法,例如:

稠密向量(Dense Vector) 稀疏向量(Sparse Vector)
维度规模 数百至数千维 数万至数十万维
单个元素含义 无单独含义 词项权重(Word Weight)
可解释性 困难 容易
常见用途 计算向量距离 计算向量距离 / 可视化分析
语义敏感度 高 低

与稠密向量相比,稀疏向量在捕捉词与词之间的深层语义关系(尤其是同义词和相关概念)方面通常不如稠密向量有效。

例如,rocket(火箭)和 SPACE X 都与太空探索相关,稠密向量更有可能识别出它们之间的语义相似性,并为相应文本赋予更近的向量距离。

如何生成向量嵌入?

向量嵌入是输入数据在深度学习模型(即嵌入模型)中的内部表示。大多数嵌入模型(如 text-embedding-3-small)仅输出稠密嵌入。

而 SPLADE 和 BGE-M3 等模型则可以生成稀疏嵌入(有时也称为词法权重 lexical weights)。在本节中,我们将介绍如何使用 BGE-M3 模型同时生成稠密向量和稀疏向量。

BGE-M3 是一个多功能模型,能够同时执行嵌入模型的三种常见检索功能:稠密检索(Dense retrieval)、多向量检索(Multi-vector retrieval)和稀疏检索(Sparse retrieval)。下面我们来学习如何用它生成稠密嵌入和稀疏嵌入。

# pip install -U FlagEmbedding

from FlagEmbedding import BGEM3FlagModel

model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
sentences = ["What is BM25?", "Definition of BM25"]

output = model.encode(sentences, return_dense=True, return_sparse=True)

# 上述两段文本的稠密嵌入:
dense_embedding = output['dense_vecs']
# [[-0.02501617 -0.04525582 -0.01972856 ... -0.0099566   0.01677509
#   0.01292699]
# [-0.0106499  -0.04731942 -0.01477693 ... -0.0149255   0.01040097
#   0.00965083]

sparse_weight_id = output['lexical_weights']

# 您可以查看每个 token 对应的权重:
# print(model.convert_id_to_token(sparse_weight_id))
# 
# [{'What': 0.10430284, 'is': 0.10090457, 'BM': 0.2635918, '25': 0.3382988, '?': 0.052101523}, 
# {'Definition': 0.14298248, 'of': 0.076763615, 'BM': 0.2577639, '25': 0.33806682}]

对于所有句子和词项,可以构建如下的“文本-词项”矩阵:

文本 What is BM 25 ? Definition of …
What is BM25? 0.08 0.08 0.13 0.25 0.04 0 0 …
Definition of BM25 0 0 0.25 0.33 0 0.14 0.06 …

当包含整个词表时,该矩阵是高度稀疏的——大多数词只出现在少数几个句子中,只有这些位置会产生非零权重。

通过将文本输入到像 BGE-M3 这样的稀疏嵌入模型中,我们可以轻松获得文本的稀疏嵌入表示:

# pip install -U numpy

import numpy as np

# 提取文本的稀疏向量下标与对应权重值
sparse_indices = [[int(token_id) for token_id in text] for text in sparse_weight_id]
# [[4865, 83, 90017, 2588, 32], [155455, 111, 90017, 2588]]
sparse_values = [[float(text[token_id]) for token_id in text] for text in sparse_weight_id]
# [[0.10430284, 0.10090457, 0.2635918, 0.3382988, 0.052101523], [0.14298248, 0.076763615, 0.2577639, 0.33806682]]

# 在 pgvecto.rs 中,每个稀疏向量的下标必须按升序排列
for i in range(len(sparse_weight_id)):
    sparse_values[i] = [x for _, x in sorted(zip(sparse_indices[i], sparse_values[i]))]
    sparse_indices[i] = sorted(sparse_indices[i])

现在我们同时拥有了稀疏嵌入和稠密嵌入,接下来可以将它们插入向量数据库了。

在 pgvecto.rs 中存储向量

首先,向量存储和检索需要一个运行中的 pgvecto.rs 实例。可以通过官方的 pgvecto-rs Docker 镜像快速启动:

docker run \
  --name pgvecto-rs-demo \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d tensorchord/pgvecto-rs:pg16-v0.3.0

准备好数据库环境后,让我们创建一张表来存储向量。

pgvecto.rs 通过不同的数据类型分别支持稠密向量和稀疏向量:

  • vector:用于存储稠密向量
  • svector:用于存储稀疏向量

BGE-M3 模型的稠密嵌入维度为 1024,而稀疏嵌入维度为 250002(与分词器词表的大小一致)。

print(len(model.tokenizer))
# 250002

在本例中,我们创建一张名为 documents 的表,包含四个列:ID 列(id)、文本列(text)以及两个向量列(sparse 用于存储稀疏向量,dense 用于存储稠密向量)。

# pip install -U psycopg

import psycopg
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

with psycopg.connect(URL) as conn:
    conn.execute("DROP TABLE IF EXISTS documents;")
    conn.execute(f"""
        CREATE TABLE documents (
            id SERIAL PRIMARY KEY, 
            text TEXT NOT NULL,
            dense vector(1024) NOT NULL,
            sparse svector({len(model.tokenizer)}) NOT NULL);
    """
    )

表创建完成后,我们可以将嵌入向量插入表中,并为两个向量列分别创建索引:

# pip install -U pgvecto_rs

import psycopg
from pgvecto_rs.psycopg import register_vector
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

with psycopg.connect(URL) as conn:
    register_vector(conn)
    for text, dense, sparse_ind, sparse_val in zip(sentences, dense_embedding, sparse_indices, sparse_values):
        conn.execute(
            "INSERT INTO documents (text, dense, sparse) VALUES (%s, %s, to_svector(%s, %s::real[]::int[], %s::real[]));",
            (text, dense, len(model.tokenizer), sparse_ind, sparse_val),
        )
    conn.execute("""
        CREATE INDEX ON documents 
        USING vectors (sparse svector_dot_ops) 
        WITH (options = \"[indexing.hnsw]\");
    """)
    conn.execute("""
        CREATE INDEX ON documents 
        USING vectors (dense vector_l2_ops) 
        WITH (options = \"[indexing.hnsw]\");
    """)

对于稠密向量,vector_l2_ops 计算平方欧氏距离,这是最常用的距离度量:

DL2=Σ(xi−yi)2 D_{L2} = \Sigma (x_i - y_i) ^ 2

对于稀疏向量,svector_dot_ops 计算负点积,这种计算方式在稀疏表示下更加高效:

Ddot=−Σxiyi D_{dot} = - \Sigma x_i y_i

有关 pgvecto.rs 中索引类型的更多信息,请查看我们的索引文档。

检索与结果融合

将向量存入数据库后,我们便可以使用 pgvecto.rs 扩展高效地查询它们。为了找出与给定输入文本最接近的向量,我们需要使用相同的模型对查询文本进行编码。

该编码过程与上一节完全一致:

def embedding(sentence: str) -> tuple[np.ndarray, list[list[int]], list[list[float]]]:
    """
    从文本生成稠密嵌入和稀疏嵌入
    """
    output = model.encode(sentence, return_dense=True, return_sparse=True, return_colbert_vecs=False)
    dense_embedding = output['dense_vecs']
    sparse_weight_id = output['lexical_weights']
    sparse_indices = [int(token_id) for token_id in sparse_weight_id]
    sparse_values = [float(sparse_weight_id[token_id]) for token_id in sparse_weight_id]

    sparse_values = [x for _, x in sorted(zip(sparse_indices, sparse_values))]
    sparse_indices = sorted(sparse_indices)
    return dense_embedding, sparse_indices, sparse_values

借助 pgvecto.rs 扩展,只需通过简单的 SELECT 语句即可分别检索稀疏向量和稠密向量:

dense_embedding, sparse_indices, sparse_values = embedding("The text you want to search...")

with psycopg.connect(URL) as conn:
    register_vector(conn)
    cur = conn.execute(
        "SELECT text FROM documents ORDER BY sparse <#> to_svector(%s, %s::real[]::int[], %s::real[]) LIMIT 50;",
        (len(model.tokenizer), sparse_indices, sparse_values),
    )
    sparse_result = cur.fetchall()
      
    cur = conn.execute(
              "SELECT text FROM documents ORDER BY dense <-> %s LIMIT 50;",
              (dense_embedding,),
          )
    dense_result = cur.fetchall()
    
# 合并稀疏向量与稠密向量的搜索结果
mix_text = set([r[0] for r in dense_result]).union([r[0] for r in sparse_result])

为了对合并后的结果进行重排序(Rerank),您可以使用常见的融合算法,例如倒数排名融合(Reciprocal Rank Fusion, RRF)。不过,为了充分发挥 BGE-M3 生态的优势,我们可以引入配套的 Reranker(重排序)模型。与嵌入模型不同,Reranker 直接以查询文本和候选文本对作为输入,并输出它们之间的相关性得分。

我们可以初始化一个 Reranker 模型,并将合并后的候选文本输入其中。通过这种方式,只需按 Reranker 模型估算的得分进行排序,即可轻松完成重排序:

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)

# 获取相关性得分(得分越高表示相关性越强)
scores = reranker.compute_score([["The text you want to search...", candidate] for candidate in mix_text])
# 对结合了稠密嵌入与稀疏嵌入检索出的候选文本进行重排序
reranked_text = [t for _, t in sorted(zip(scores, mix_text))]

恭喜!至此,我们已经迈出了探索稀疏向量搜索世界的第一步。

6 - 生成式问答(Generative QA)

向量搜索可用于为 OpenAI GPT 系列等生成式模型提供上下文,从而提升输出质量。

向量搜索还可以用于为生成式模型(例如 OpenAI 的 GPT 系列)提供上下文,从而提升模型输出的质量。

本文将探讨如何使用 pgvecto.rs 构建向量知识库并从中检索信息,以增强生成式模型在问答(Question-Answering, QA)任务中的表现。知识库充当了生成式模型的“长期记忆”,为其提供持久化、经过整理且准确的信息作为上下文(Context),供模型在生成问题答案时参考。这样不仅能生成更准确的回答,减少模型产生“幻觉”(毫无事实依据的陈述)的可能性,还能增强用户对输出结果的信任。这项技术被称为检索增强生成(Retrieval-Augmented Generation,简称 RAG):更多信息请参阅这篇博文或相关论文。

问答任务概览

生成式模型的问答(QA)任务是一种**信息检索(Information Retrieval)**方法。模型接收一个问题,并根据给定的上下文(通常是模型可以查阅以获取回答问题所需信息的文档或其他信息源)生成答案。

在本案例中,我们将使用 Hugging Face 上的 vishnupriyavr/wiki-movie-plots-with-summaries-faiss-embeddings 数据集,为 OpenAI 的 gpt-3.5-turbo-instruct 模型提供上下文,以回答有关电影的问题。

整体工作流程如下所示:

无上下文的问答

首先,让我们尝试在不提供任何上下文的情况下让模型回答几个问题。

我们先定义一个函数,用于简化向模型发送提示词(Prompt)并获取响应的过程:

# pip install openai

import openai

openai.api_key = "YOUR_API_KEY_HERE"
def complete(prompt):
    result = openai.Completion.create(
        engine='gpt-3.5-turbo-instruct',
        prompt=prompt,
        temperature=0,
        max_tokens=400,
        top_p=1,
        frequency_penalty=0,
        presence_penalty=0,
        stop=None
    )
    return result['choices'][0]['text'].strip()
说明

获取 api_key 需要拥有 OpenAI 账号。登录后,可从右上角下拉菜单中申请 API 密钥,并将其粘贴替换代码中的 YOUR_API_KEY_HERE。

让我们先提问一部知名度较高的电影:

base_prompt = "What is the release year and overall plot of Skyfall?"
print(complete(base_prompt))
输出:

The release year of Skyfall is 2012. The overall plot follows James Bond as he investigates an attack on MI6 and uncovers a sinister plot by a former agent seeking revenge on M and the entire agency. Bond must confront his past and protect MI6 from destruction.

回答相当准确,尽管剧情简介略微笼统。但是,如果换作一部知名度低得多的电影呢?

base_prompt_2 = "What is the release year and overall plot of Cat Napping?"
print(complete(base_prompt_2))
输出:

The release year of Cat Napping is unknown. The overall plot of Cat Napping is about a group of cats who go on a mission to rescue their friend who has been kidnapped by a group of evil dogs. Along the way, they encounter various challenges and obstacles, but with their cleverness and teamwork, they are able to save their friend and defeat the dogs.

正确答案:

《Cat Napping》(猫咪午睡)实际上是 1951 年上映的一部《猫和老鼠》(Tom and Jerry)动画短片,讲述了汤姆和杰瑞争夺一张吊床的故事。

我们已经触及了模型自身“知识”的边界:它无法给出上映年份,并且仅凭片名编造了一段剧情。接下来,我们将通过构建一个供模型检索的知识库来解决这一问题。

要让模型更好地回答我们的问题,通常有两种选择:

  1. 使用目标领域的文本数据(本例中为电影数据)对模型进行微调(Fine-tune)。
  2. 采用检索增强生成(RAG),在流程中引入信息检索组件。这使我们能够检索相关信息并将其作为辅助信息源输入给模型。

在本文中,我们将采用第二种方案。

构建知识库

让我们使用 Hugging Face 上的 vishnupriyavr/wiki-movie-plots-with-summaries-faiss-embeddings 数据集构建一个电影知识库,以便从中检索相关信息。该数据集包含 33,155 部电影的片名、上映年份、演员阵容、维基百科页面、剧情简介/长度,以及这些信息的向量嵌入(Vector Embeddings)。每部电影还包含一个综合了片名、上映年份、演员阵容和剧情简介的 text 字段。在本应用中,我们将使用其中的 text 和 embeddings 数据。

加载数据

首先加载数据集:

# pip install -U datasets
from datasets import load_dataset
dataset = load_dataset("vishnupriyavr/wiki-movie-plots-with-summaries-faiss-embeddings", split='train')

texts = dataset['train']['text']
# texts[0]
# 'Alice in Wonderland \n  starring May Clark \n  released in the year1903 \n  with the following plot  \n Alice follows a large white rabbit down a "Rabbit-hole". She finds a tiny door. When she finds a bottle labeled "Drink me", she does, and shrinks, but not enough to pass through the door. She then eats something labeled "Eat me" and grows larger. She finds a fan when enables her to shrink enough to get into the "Garden" and try to get a "Dog" to play with her. She enters the "White Rabbit\'s tiny House," but suddenly resumes her normal size. In order to get out, she has to use the "magic fan."\r\nShe enters a kitchen, in which there is a cook and a woman holding a baby. She persuades the woman to give her the child and takes the infant outside after the cook starts throwing things around. The baby then turns into a pig and squirms out of her grip. "The Duchess\'s Cheshire Cat" appears and disappears a couple of times to Alice and directs her to the Mad Hatter\'s "Mad Tea-Party." After a while, she leaves.\r\nThe Queen invites Alice to join the "ROYAL PROCESSION": a parade of marching playing cards and others headed by the White Rabbit. When Alice "unintentionally offends the Queen", the latter summons the "Executioner". Alice "boxes the ears", then flees when all the playing cards come for her. Then she wakes up and realizes it was all a dream.'

生成向量嵌入

由于该数据集自带了向量嵌入,您可以跳过生成步骤,直接使用现成的嵌入数据:

embeddings = dataset['train']['embeddings']

如果您希望从文本数据生成向量嵌入,可以使用 OpenAI、Cohere 或其他嵌入 API。下面我们提供一个使用 Cohere API 的示例(您可以在此处获取 Cohere API 密钥):

# pip install cohere
import getpass
import os
import cohere

if not os.getenv("COHERE_API_KEY"):
    os.environ["COHERE_API_KEY"] = getpass.getpass("Enter your Cohere API key: ")

co = cohere.Client()
# 嵌入模型可参考此文档:https://docs.cohere.com/docs/cohere-embed
embed = co.embed(
    texts=texts,
    input_type="search_query",
    model="embed-english-v3.0"
)
embeddings = embed.embeddings
说明

本案例中的嵌入为稠密向量(Dense Vectors)。如需创建稀疏向量(Sparse Vectors),请参阅稀疏向量章节。

将数据插入数据库

向量存储与检索需要部署一个 pgvecto.rs 实例。我们可以使用官方的 pgvecto-rs Docker 镜像:

docker run \
  --name pgvecto-rs-demo \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d tensorchord/pgvecto-rs:pg16-v0.3.0

安装用于与 Postgres 交互的 psycopg 库,并确保扩展已启用:

#pip install "psycopg[binary,pool]"
import psycopg

URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"
# 启用扩展
with psycopg.connect(URL) as conn:
    conn.execute("DROP EXTENSION IF EXISTS vectors;")
    conn.execute("CREATE EXTENSION vectors;")

现在,我们创建一张名为 movies 的表,包含三个列:ID 列(id)、文本列(text)和向量列(embedding)。

import psycopg
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

# 创建表
with psycopg.connect(URL) as conn:
    conn.execute("DROP TABLE IF EXISTS movies;")
    conn.execute(f"""
        CREATE TABLE movies (
            id SERIAL PRIMARY KEY, 
            text TEXT NOT NULL,
            embedding vector(768) NOT NULL);
    """
    )
说明

由于我们处理的是稠密向量嵌入,因此使用 vector(n) 类型,其中 n 是每个嵌入向量的维度(本例中为 768)。

表创建完成后,我们可以将数据集中的嵌入插入表中:

# 可选安装,但推荐使用以显示进度条:
# pip install tqdm
import psycopg
from tqdm import tqdm
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

# 插入数据到表中
with psycopg.connect(URL) as conn:
    with conn.cursor() as cursor:    
        for text, embedding in tqdm(zip(texts, embeddings), total=len(texts)):
            cursor.execute(
                """INSERT INTO movies (text, embedding) 
                    VALUES (%s, %s::real[]);""",
                (text, embedding),
            )
        conn.commit()
警告

根据待插入数据量的大小,此过程可能需要花费一些时间。

创建索引

import psycopg
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

# 为 embedding 列创建索引
with psycopg.connect(URL) as conn:
    cursor = conn.cursor()
    cursor.execute("""
        CREATE INDEX ON movies 
        USING vectors (embedding vector_l2_ops) 
        WITH (options = \"[indexing.hnsw]\");
    """)
    conn.commit()
说明

回顾一下,对于稠密向量,vector_l2_ops 计算的是平方欧氏距离(Squared Euclidean distance),这也是最常用的距离度量方式:

>DL2=Σ(xi−yi)2> > D_{L2} = \Sigma (x_i - y_i) ^ 2 >

结合上下文的问答

为了利用我们的知识库,首先需要对查询语句进行编码,以确定应当检索哪些文本:

# pip install sentence-transformers

from sentence_transformers import SentenceTransformer

# 可选模型:'paraphrase-mpnet-base-v2', 'all-mpnet-base-v2'
encoder = SentenceTransformer('all-mpnet-base-v2')

def encode_query(query): # query 为字符串
    # 预处理查询语句
    query = query.lower()
    query = query.split()
    query = [word for word in query]
    query = ' '.join(query)
    
    # 对查询进行编码
    vector = encoder.encode(query)
    return vector

接下来,我们定义一个函数,自动将检索到的上下文拼接到初始提示词中:

import psycopg
URL = "postgresql://postgres:mysecretpassword@localhost:5432/postgres"

def add_context(prompt):
    search_vector = encode_query(prompt).tolist()
    # 构造包含检索上下文的提示词
    prompt_start = (
        "Answer the question based on the following context:\n\n"
    )
    prompt_end = (
        f"\n\nQuestion: {prompt}\nAnswer:"
    )
    with psycopg.connect(URL) as conn:
        cursor = conn.cursor()
        # 根据希望提供给模型的电影文本数量调整 LIMIT 值
        cursor.execute("SELECT text FROM movies ORDER BY embedding <-> %s::real[] LIMIT 3;", 
                  (search_vector,),)
        result = cursor.fetchall()
    context = ""
    for r in result:
        context += r[0] + "\n\n"
    return prompt_start + context + prompt_end
说明

回顾一下距离操作符的选项:

  • <->:平方欧氏距离(Squared Euclidean distance)
  • <#>:负点积(Negative dot product)
  • <=>:余弦距离(Cosine distance)

有关详细定义,请参阅概览。

现在,让我们附上检索到的上下文,再次尝试之前的提问:

base_prompt_2 = "What is the release year and overall plot of Cat Napping?"

context_prompt_2 = add_context(base_prompt_2)
context_response_2 = complete(context_prompt_2)
print(context_response_2)
输出:

The release year of Cat Napping is 1951 and the overall plot involves Tom and Jerry fighting over a hammock, with Jerry ultimately getting revenge on Tom.

现在模型给出了准确的回答!我们甚至可以针对细节进一步追问:

base_prompt_3 = "In Cat Napping, what does Tom do to Jerry?"
print(complete(add_context(base_prompt_3)))
输出:

Tom tries to send Jerry sliding into a nearby pond by unhooking the hammock he is sleeping in. He also tries to whack the hammock to send Jerry flying into the air, but Jerry lands in a bird’s nest. Tom then picks Jerry up on a spatula and places him onto a walking army of ants, causing Jerry to wake up as he bumps his head on a sprinkler. Tom also tries to chase Jerry with a lawn mower and a baseball bat.

正如我们在前文中所看到的,大语言模型(LLM)本身虽然非常强大,但在面对冷门或特定领域的问题时往往力不从心,容易产生不易察觉的“幻觉”。引入向量知识库能够显著提升回答的事实准确性,进而增强用户对生成结果的信任。

更多应用场景

当然,这种方法不仅限于电影问答。其他应用场景还包括提取科学论文摘要(不妨试试这个数据集!),或者基于企业内部文档库回答有关公司规章制度的问题。