pgvector 向量检索实战:用 PostgreSQL 给个人站做语义搜索与 RAG,嵌入、HNSW 索引与混合检索全流程

关键词搜索的尽头:为什么需要向量检索

个人站长给站点做站内搜索,传统做法是走 MySQL 的 LIKE '%关键词%' 或全文索引,好一点的用 Elasticsearch、Meilisearch 做倒排索引。这些方案对"关键词精确匹配"很有效,但有个根本局限:它们只认字面,不认语义。

举个例子:你的博客里有一篇文章标题是《如何让 Nginx 更快响应静态请求》,读者搜"网站加载速度慢怎么优化",关键词方案大概率搜不到——因为字面上没有任何一个词重合。而语义搜索能理解"加载速度慢"和"Nginx 响应快"讲的是同一件事,从而把这篇推出来。

支撑语义搜索的技术叫向量检索(Vector Search):把每段文本通过嵌入模型(Embedding Model)转成一串数字(向量),语义相近的文本,其向量在空间中的距离也相近。查询时同样把查询词转成向量,找距离最近的文本即可。pgvector 就是让 PostgreSQL 原生支持向量存储与检索的扩展。

本文讲清楚怎么用 pgvector 给个人站搭一套语义搜索,从概念、建表、写入到查询加速,完整走一遍。

什么是嵌入向量(Embedding)

一个嵌入模型(比如 OpenAI 的 text-embedding-3-small,或本地开源的 BGE、m3e)会把任意一段文本映射成一个固定维度的浮点数组。例如 1536 维的向量:

[0.023, -0.451, 0.881, 0.002, ..., -0.117]   # 共 1536 个浮点数

这些数字本身没有人类可读的含义,但它们构成一个高维空间。语义相近的文本,向量夹角小(余弦相似度高);无关的文本,向量方向差异大。搜索的本质就是:把查询向量放进这个空间,找离它最近的若干文本向量。

维度和模型强绑定:用哪个模型生成向量,就必须用同一个模型处理查询,否则空间不对齐,结果毫无意义。这一点后文还会强调。

安装 pgvector

pgvector 是一个 PostgreSQL 扩展。在 Docker 官方镜像里就自带:

docker run -d --name pgvector \
  -p 127.0.0.1:5432:5432 \
  -e POSTGRES_PASSWORD=your_strong_password \
  -e POSTGRES_DB=site \
  -v /data/pgvector:/var/lib/postgresql/data \
  pgvector/pgvector:pg16

在已有 PostgreSQL 上安装(Debian/Ubuntu):

apt install postgresql-16-pgvector
# 然后在目标数据库里启用
psql -d site -c "CREATE EXTENSION IF NOT EXISTS vector;"

建表:存向量

给文章表加一个向量列。维度要和你选的嵌入模型一致——这里以 1536 维为例:

CREATE TABLE articles (
  id        SERIAL PRIMARY KEY,
  slug      TEXT UNIQUE NOT NULL,
  title     TEXT NOT NULL,
  content   TEXT NOT NULL,
  embedding vector(1536)      -- 1536 维向量列
);

注意 vector(1536) 里的维度必须固定,pgvector 会校验写入向量的维度是否匹配。如果你以后换模型导致维度变化,要么改列定义(相当于重算所有向量),要么新建列。

写入向量(在应用侧生成)

pgvector 只负责存和查向量,不负责生成向量。生成工作由应用侧调用嵌入模型完成。下面是一个 Python 示例(以 OpenAI 兼容接口为例):

import psycopg2
from openai import OpenAI

client = OpenAI(api_key="sk-...", base_url="https://api.openai.com/v1")
conn = psycopg2.connect("host=127.0.0.1 dbname=site user=postgres password=...")
cur = conn.cursor()

def embed(text: str):
    resp = client.embeddings.create(model="text-embedding-3-small", input=text)
    return resp.data[0].embedding        # 返回 list[float],长度 1536

def index_article(slug, title, content):
    vec = embed(f"{title}\n{content[:2000]}")   # 标题+正文前段一起编码
    cur.execute(
        "INSERT INTO articles (slug, title, content, embedding) VALUES (%s, %s, %s, %s) "
        "ON CONFLICT (slug) DO UPDATE SET embedding = EXCLUDED.embedding",
        (slug, title, content, vec),
    )
    conn.commit()

要点:编码时把标题和正文拼在一起,标题权重往往更重要。嵌入模型有输入长度上限(如 8191 token),超长文本要截断或分块。

查询:按向量距离排序

pgvector 提供几个距离运算符:

  • <=>:余弦距离(1 - 余弦相似度),最常用于文本语义搜索;
  • <->:欧氏距离(L2);
  • <#>:负内积(inner product 取负)。

距离越小越相似,所以查询用 ORDER BY embedding <=> 查询向量 LIMIT N:

import numpy as np

query = "网站加载速度慢怎么优化"
qvec = embed(query)

cur.execute(
    "SELECT slug, title, 1 - (embedding <=> %s::vector) AS similarity "
    "FROM articles "
    "ORDER BY embedding <=> %s::vector "
    "LIMIT 10",
    (qvec, qvec),
)
for slug, title, sim in cur.fetchall():
    print(f"{sim:.3f}  {title}")

这就是语义搜索的全部核心:把查询转成向量,按余弦距离排序取前 N。1 - 距离 得到相似度分数,越大越相关。

建索引:否则大数据量下会全表扫

上面的查询在数据量小时没问题,但每一行都要算一次距离(精确的暴力搜索),文章上千篇后就会变慢。pgvector 支持两类近似最近邻(ANN)索引:

-- HNSW 索引:查询快、召回率高,推荐首选
CREATE INDEX ON articles
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

或使用 IVFFlat(需要先有数据再建,建索引前最好已有一定数据量):

CREATE INDEX ON articles
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

两个参数说明:

  • HNSW 的 m:每个节点的连接数,越大越精确但索引越大、构建越慢,16 是常用默认值;
  • HNSW 的 ef_construction:构建时的候选列表大小,越大质量越高、越慢;
  • IVFFlat 的 lists:聚类中心数,经验值是 行数 / 1000(行数 < 100 万时),太多太少都影响召回。

索引选择要在"召回率"和"速度"间权衡。ANN 是近似的,可能漏掉个别真正的最近邻,但换来的是毫秒级响应。

查询时的调节参数

HNSW 查询时可以用 hnsw.ef_search 控制搜索广度,值越大越准越慢:

SET hnsw.ef_search = 100;   -- 默认 40,调大提升召回
SELECT slug, title FROM articles
ORDER BY embedding <=> %s::vector LIMIT 10;

混合检索:向量 + 关键词 一起用

纯语义搜索也有短板:搜精确的专有名词(如某个命令、某个报错代码)时,关键词匹配反而更准。生产级搜索常用混合检索,把两路结果融合。一个简单的做法是先各取一定数量再合并,或者用 PostgreSQL 的全文检索配合:

WITH semantic AS (
  SELECT id, 1 - (embedding <=> %s::vector) AS score
  FROM articles ORDER BY embedding <=> %s::vector LIMIT 20
),
keyword AS (
  SELECT id, ts_rank(to_tsvector('simple', title || content), plainto_tsquery('simple', %s)) AS score
  FROM articles
  WHERE to_tsvector('simple', title || content) @@ plainto_tsquery('simple', %s)
  LIMIT 20
)
SELECT a.id, a.title,
       COALESCE(s.score, 0) * 0.7 + COALESCE(k.score, 0) * 0.3 AS final_score
FROM articles a
LEFT JOIN semantic s ON a.id = s.id
LEFT JOIN keyword  k ON a.id = k.id
WHERE s.id IS NOT NULL OR k.id IS NOT NULL
ORDER BY final_score DESC
LIMIT 10;

这里用 simple 分词器是因为中文需要专门的全文配置;如果站点是英文,用 english 配置即可。权重 0.7/0.3 可调。

做 RAG:让搜索直接回答

语义搜索的一个自然延伸是 RAG(检索增强生成):先用 pgvector 检索出最相关的几段内容,再把这些内容作为上下文喂给大模型,让它基于你的站点内容回答用户问题。这对做"智能客服""文档问答"的个人站很实用。流程是:

def rag_answer(question):
    qvec = embed(question)
    cur.execute(
        "SELECT title, content FROM articles ORDER BY embedding <=> %s::vector LIMIT 3",
        (qvec,),
    )
    context = "\n\n".join(f"# {t}\n{c[:800]}" for t, c in cur.fetchall())
    prompt = f"根据以下资料回答问题,资料没有的不要编造。\n\n资料:\n{context}\n\n问题:{question}"
    # 调用大模型生成回答(略)

关键是把召回的原文塞进 prompt,让模型"照着念",减少幻觉。

长文章的切块策略

一篇几千字的文章如果整篇编码成一个向量,语义会被"平均"掉——读者搜文章里某个具体小节的内容,可能因为整篇向量被其他主题稀释而搜不到。更专业的做法是把长文切成若干块(chunk)分别编码,每块一行记录,检索时命中的是具体段落。切块有几个经验:

  • 按语义边界切:优先按段落、标题切,而不是机械地每 500 字一刀,避免把一个完整句子拦腰截断;
  • 块之间留重叠:相邻块重叠 10%~20%(比如 500 字一块、重叠 100 字),防止关键信息正好落在切口处丢失;
  • 块别太小:太碎的块会丢失上下文,几十到几百字是比较常见的范围。
-- 切块后每块一行,用 article_id 关联回原文
CREATE TABLE article_chunks (
  id         SERIAL PRIMARY KEY,
  article_id INT REFERENCES articles(id) ON DELETE CASCADE,
  chunk_no   INT NOT NULL,
  chunk_text TEXT NOT NULL,
  embedding  vector(1536)
);
CREATE INDEX ON article_chunks
  USING hnsw (embedding vector_cosine_ops);

检索时命中 chunk,再通过 article_id 回溯到原文;做 RAG 时直接把命中的 chunk 文本喂给大模型,比整篇更聚焦。切块是 RAG 效果好坏的关键一环,值得花时间调。

成本、隐私与本地模型

用云端 API 生成嵌入,成本其实很低——以 text-embedding-3-small 为例,编码一本几十万字的书也就几分钱,个人站全量重算一次向量通常在几分到几毛之间。但两点要注意:一是隐私,文章全文会被发送到第三方服务器;二是依赖,服务商的接口一旦限流或涨价,你的索引就成了"只能查不能更新"的死库。

如果在意这些,可以自建本地嵌入服务。BGE 系列(如 BAAI/bge-large-zh)是不错的中文嵌入模型,可以用 ONNX Runtime 或 Ollama 本地跑,配合 pgvector 完全离线:

# 用 Ollama 本地起一个嵌入服务(示例模型)
ollama pull nomic-embed-text
# 之后通过本地 HTTP 接口生成向量,不再依赖外部 API
curl http://127.0.0.1:11434/api/embeddings \
  -d '{"model":"nomic-embed-text","prompt":"网站加载速度优化"}'

本地模型的代价是需要一点内存/显存,且效果可能略逊于头部商用模型,但对个人站的内容规模来说足够。选本地还是云端,本质是"隐私与成本"对"效果与便利"的权衡。

几个必须知道的坑

1. 生成查询向量必须用和入库时同一个模型。 这是头号错误——换了模型,两套向量不在同一空间,结果完全乱套。换模型意味着全库重算向量。

2. 维度必须匹配。 模型输出 768 维,列却定义成 1536 维,插入直接报错。

3. 别用 vector 存超大维度后不建索引。 数据量上千行后,无索引查询会全表扫描,每次都要做 N 次距离计算,慢得离谱。生产环境必须建 HNSW/IVFFlat。

4. 余弦距离用 vector_cosine_ops,内积用 vector_ip_ops。 索引的运算符类必须和查询里用的运算符一致,否则索引不生效,退化为全表扫。

5. 嵌入模型的成本和隐私。 调用云端嵌入 API 有费用(虽然很便宜)和隐私顾虑——你的文章全文会被发送出去。个人站若在意隐私,可以用本地模型(如 BGE 系列),用 ONNX 或 Ollama 本地推理,配合 pgvector 完全离线运行。

6. 内容更新要重新生成向量。 文章改动了,向量必须重算,否则搜索还指向旧语义。可以在保存文章时触发重新嵌入。

小结

pgvector 让 PostgreSQL 一步跨入了语义检索领域:你不需要引入 Elasticsearch 这样的大件,也不必维护两套存储,直接在手边的 PostgreSQL 上加个扩展、加一个向量列、建一个 HNSW 索引,站点搜索就从"认字面"升级到了"懂语义"。对个人站长而言,它是把"关键词搜索"进化成"智能搜索"甚至"站内问答"成本最低的路径——用标准 SQL 和熟悉的数据库,做一件看起来挺前沿的事。

Last modification:October 11th, 2026 at 10:31 pm

Leave a Comment