深色模式
L2 向量数据库与多模态检索
目标
- 用 Docker 部署 PGVector 向量数据库,掌握向量的增删查和索引;
- 用 CLIP 把图片和文字映射到同一个向量空间;
- 实现一个“以文搜图”:输入一句中文,从图片库中找出最相关的图片。
背景
向量检索:模型把文本、图片编码成向量(embedding),语义相近的内容向量距离也近。向量数据库负责存储大量向量,并快速找出离查询向量最近的若干个(近似最近邻搜索,ANN)。RAG、推荐、以图搜图都建立在它之上。
PGVector 是 PostgreSQL 的开源扩展,在熟悉的关系数据库里直接存储和查询向量,支持 L2 距离、内积、余弦距离,以及 HNSW、IVFFlat 索引。数据量很大或需要分布式时,可以换成 Milvus。
CLIP 是 OpenAI 提出的图文对比学习模型,把图像和文本映射到同一个向量空间:
- 零样本能力:不需要针对任务训练,直接做图文匹配、分类、搜索;
- 跨模态对齐:图片向量和文本向量可以直接计算相似度;
- 用途广:图像分类、以文搜图、以图搜文、内容审核等。
中文场景用 Chinese-CLIP。
环境
- Docker
- Python 包:
uv pip install "psycopg[binary]" pgvector torch transformers pillow numpy - GPU 可选(CPU 也能跑,只是慢一些)
第 1 步:部署 PGVector
bash
docker run -d \
--name pgvector \
-e POSTGRES_PASSWORD=change-me \
-p 5432:5432 \
-v pgdata:/var/lib/postgresql/data \
pgvector/pgvector:pg17| 参数 | 作用 |
|---|---|
POSTGRES_PASSWORD | 数据库 postgres 用户的密码,换成你自己的 |
-p 5432:5432 | PostgreSQL 默认端口 |
-v pgdata:/var/lib/postgresql/data | 数据持久化,删除容器后数据还在 |
使用 PostgreSQL 18 时
pgvector/pgvector:pg18 镜像的数据卷挂载位置改成了 /var/lib/postgresql(不再是 .../data),照搬上面的写法会启动失败。用 pg18 时写 -v pgdata18:/var/lib/postgresql,并且不要和 pg17 共用同一个数据卷。
第 2 步:连接数据库
命令行(容器里自带 psql,不用另外安装):
bash
docker exec -it pgvector psql -U postgres图形客户端:推荐 DBeaver。新建 PostgreSQL 连接,主机填部署数据库的机器地址(本机填 localhost),端口 5432(或你映射的端口),用户名 postgres,密码为上面设置的密码。
第 3 步:向量的基本操作
在 psql 里执行(SQL 的注释是 --):
sql
-- 启用扩展(每个数据库执行一次)
CREATE EXTENSION IF NOT EXISTS vector;
-- 创建带向量字段的表,维度按实际模型修改
CREATE TABLE items (
id BIGSERIAL PRIMARY KEY,
embedding VECTOR(3),
text TEXT
);
-- 插入
INSERT INTO items (embedding, text) VALUES
('[1,2,3]', 'apple'),
('[4,5,6]', 'banana'),
('[7,8,9]', 'orange');
-- 查询与 [3,1,2] 最近的 2 条,<-> 是 L2 距离
SELECT id, text, embedding <-> '[3,1,2]' AS distance
FROM items
ORDER BY embedding <-> '[3,1,2]'
LIMIT 2;
-- 其他距离:<=> 余弦距离,<#> 负内积
-- 删除
DELETE FROM items WHERE id = 1;数据量大时建索引,查询会快很多(结果是近似的):
sql
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);索引的距离类型要和查询用的运算符一致,否则查询用不上索引:<-> 对应 vector_l2_ops,<=> 对应 vector_cosine_ops,<#> 对应 vector_ip_ops。上面的索引配合第 5 步的 <=>(余弦距离)查询使用。
第 4 步:用 CLIP 提取向量
先用英文 CLIP 体验零样本分类:
python
import torch
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
image = Image.open("test.jpg").convert("RGB")
labels = ["a diagram", "a dog", "a cat"]
inputs = processor(text=labels, images=image, return_tensors="pt", padding=True)
with torch.no_grad():
outputs = model(**inputs)
probs = outputs.logits_per_image.softmax(dim=1)[0] # 图片与每个文本的匹配概率
for label, p in zip(labels, probs):
print(f"{label}: {p:.3f}")再写中文版的特征提取函数,后面建库和检索都用它:
python
# clip_encoder.py
import torch
from PIL import Image
from transformers import ChineseCLIPModel, ChineseCLIPProcessor
# 可选规模:chinese-clip-vit-base-patch16(512 维)、chinese-clip-vit-large-patch14、
# chinese-clip-vit-large-patch14-336px、chinese-clip-vit-huge-patch14
MODEL_NAME = "OFA-Sys/chinese-clip-vit-base-patch16"
device = "cuda" if torch.cuda.is_available() else "cpu"
model = ChineseCLIPModel.from_pretrained(MODEL_NAME).to(device).eval()
processor = ChineseCLIPProcessor.from_pretrained(MODEL_NAME)
def as_tensor(out):
# transformers 5.x 返回一个对象,投影后的向量在 pooler_output 里;4.x 直接返回张量
return out if isinstance(out, torch.Tensor) else out.pooler_output
@torch.no_grad()
def encode_texts(texts):
inputs = processor(text=texts, return_tensors="pt", padding=True).to(device)
features = as_tensor(model.get_text_features(**inputs))
return torch.nn.functional.normalize(features, dim=-1).cpu().numpy()
@torch.no_grad()
def encode_images(paths):
images = [Image.open(p).convert("RGB") for p in paths]
inputs = processor(images=images, return_tensors="pt").to(device)
features = as_tensor(model.get_image_features(**inputs))
return torch.nn.functional.normalize(features, dim=-1).cpu().numpy()向量做了归一化,所以余弦相似度就是内积。
transformers 的版本差异
从 transformers 5.0 开始,get_text_features 和 get_image_features 返回的不再是张量,而是一个对象,向量在 .pooler_output 里。上面的 as_tensor 兼容两种版本。网上的旧代码直接对返回值做 normalize,在 5.x 下会报 AttributeError: 'BaseModelOutputWithPooling' object has no attribute 'norm'。
另外,transformers 4.57 的 Chinese-CLIP 文本编码有问题,会报 linear(): argument 'input' (position 1) must be Tensor, not NoneType。遇到时升级到 5.x,或者使用 4.56 及更早的版本。
第 5 步:以文搜图
准备一个文件夹 images/,放几十张图片(可以从 MS-COCO 验证集里取一部分),然后:
python
# search.py
from pathlib import Path
import psycopg
from pgvector.psycopg import register_vector
from clip_encoder import encode_images, encode_texts
conn = psycopg.connect("postgresql://postgres:change-me@localhost:5432/postgres", autocommit=True)
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
register_vector(conn)
conn.execute("""
CREATE TABLE IF NOT EXISTS images (
id BIGSERIAL PRIMARY KEY,
path TEXT UNIQUE,
embedding VECTOR(512)
)
""")
# 建库:分批提取图片向量并写入
paths = sorted(str(p) for p in Path("images").glob("*.jpg"))
for i in range(0, len(paths), 32):
batch = paths[i:i + 32]
for path, emb in zip(batch, encode_images(batch)):
conn.execute(
"INSERT INTO images (path, embedding) VALUES (%s, %s) ON CONFLICT (path) DO NOTHING",
(path, emb),
)
# 检索:用文本向量找余弦距离最近的 5 张图
query = "一只在草地上奔跑的狗"
q = encode_texts([query])[0]
rows = conn.execute(
"SELECT path, embedding <=> %s AS distance FROM images ORDER BY distance LIMIT 5",
(q,),
).fetchall()
for path, distance in rows:
print(f"{distance:.3f} {path}")预期结果
输入描述后,返回的前几张图片应该和描述明显相关。换几个查询词试试,看看模型在哪些描述上容易出错。
评测方法
检索系统要从效果和效率两方面评测:
- 效果:Recall@K(正确结果出现在前 K 个里的比例)、MRR、mAP。
- 效率:单次查询延迟、QPS、建索引耗时、内存占用。
- 近似检索的代价:建 HNSW 索引前后,对比查询速度和召回率的变化。
生成式模型(文生图、文生视频)的质量评测见 L5。
常见问题
- 连接被拒绝:检查容器是否在运行(
docker ps),端口映射是否正确。 type "vector" does not exist:当前数据库还没执行CREATE EXTENSION vector。- 维度不匹配:表定义的维度必须和模型输出一致,base 模型是 512 维,large 是 768 维。
延伸
- 文本检索可以换成专门的中文文本向量模型,例如 BGE 或 Qwen3-Embedding 系列。
- 了解多模态 RAG:ColPali 直接用页面截图做文档检索,跳过 OCR。
- 研究问题:边缘设备内存有限,向量库存不下全部数据时,哪些放边缘、哪些放云端?