关键词搜索的尽头:为什么需要向量检索
个人站长给站点做站内搜索,传统做法是走 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 和熟悉的数据库,做一件看起来挺前沿的事。