未记录事实核验 · 状态:维护中
记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。
warning 适用边界与维护说明
本文适合已有 PostgreSQL、pgvector 和高级 SQL 基础的读者参考混合检索、推荐及执行计划分析模式,不是可直接运行的完整工程或生产性能基准。示例中的省略号向量须替换为有效的 1536 维向量,并补齐用户、作者关联和阅读历史数据;窗口函数及 LATERAL 推荐还依赖建表中未定义的 users.interest_vector,query_vec 与冒号参数需由执行端提供或绑定。查询与入库向量应使用一致的模型和维度,随机向量不能验证语义相关性。“向量聚类”示例实际仅查找每篇文章的最近邻,不输出聚类分组;LATERAL、并行参数及预热等建议也不能视为性能保证。正文已明确索引选择由规划器决定,MATERIALIZED 示例已建立过滤结果边界,执行计划耗时仅为假设值。本次仅作全文结构审阅,未执行代码或进行外部事实核验。 已取消无法确定目标的可点击引用「../Qdrant/01-Qdrant概述与核心优势」,保留原提及;Link escapes vault: ../Qdrant/01-Qdrant概述与核心优势
pgvector SQL 向量查询实战
为什么这个话题值得单独一章
pgvector 最让人着迷的地方不是它的向量搜索有多快,而是它让你用 SQL 做向量搜索。
这句话听起来平淡,但对于一个已经写了五年 SQL 的数据工程师来说,这意味着什么?
- 你不需要学新的查询语言(Milvus 有自己的 Python API,Qdrant 有自己的 REST API)
- 你可以把向量搜索结果直接 JOIN 业务表——这在专用向量库里几乎做不到
- 你可以用 GROUP BY、窗口函数、CTE 组合出各种业务逻辑
- 你的 DBA 可以直接看懂和优化这些查询
这种"熟悉感"是无价的。它意味着团队无需招募专门的向量数据库专家,意味着快速迭代,意味着更低的运维成本。
本章从最基础的查询写起,一直到复杂的混合搜索、窗口函数、查询执行计划分析,每个例子都有完整的背景和解释。
目录
- 环境准备与示例数据
- 基础向量查询
- 三种距离算子详解
- 向量查询与业务表 JOIN
- 混合过滤:WHERE 子句 + 向量排序
- 全文检索与向量融合
- 聚合与分组
- 窗口函数
- CTE 与复杂查询组合
- EXPLAIN ANALYZE 读懂执行计划
- 常见陷阱与调试方法
- 生产级查询模板
环境准备与示例数据
在正式开始前,我们建立一套贯穿全文的示例数据,模拟一个知识库文章检索的真实场景。
数据库结构
-- 确保 pgvector 扩展已启用
CREATE EXTENSION IF NOT EXISTS vector;
-- 全文检索需要的语言配置(中文场景建议安装 zhparser)
-- 本文示例用英文演示全文检索语法
-- 用户表
CREATE TABLE users (
id bigserial PRIMARY KEY,
username text NOT NULL,
email text UNIQUE NOT NULL,
role text DEFAULT 'reader', -- reader, author, admin
created_at timestamptz DEFAULT now()
);
-- 文章分类表
CREATE TABLE categories (
id serial PRIMARY KEY,
name text NOT NULL,
slug text UNIQUE NOT NULL
);
-- 核心文章表(带向量)
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
content text NOT NULL,
summary text,
category_id integer REFERENCES categories(id),
author_id bigint REFERENCES users(id),
tags text[],
view_count integer DEFAULT 0,
is_published boolean DEFAULT true,
published_at timestamptz,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
-- 向量列:用 text-embedding-3-small 生成,1536 维
embedding vector(1536),
-- 全文检索列
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, ''))
) STORED
);
-- 用户阅读历史表
CREATE TABLE reading_history (
id bigserial PRIMARY KEY,
user_id bigint REFERENCES users(id),
article_id bigint REFERENCES articles(id),
read_at timestamptz DEFAULT now(),
duration_s integer, -- 阅读时长(秒)
UNIQUE(user_id, article_id)
);
-- 索引
CREATE INDEX idx_articles_hnsw ON articles
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX idx_articles_fts ON articles USING gin(search_vector);
CREATE INDEX idx_articles_category ON articles (category_id, is_published);
CREATE INDEX idx_articles_published ON articles (published_at DESC) WHERE is_published = true;
CREATE INDEX idx_reading_history_user ON reading_history (user_id, read_at DESC);
插入测试数据
-- 插入分类
INSERT INTO categories (name, slug) VALUES
('机器学习', 'machine-learning'),
('数据工程', 'data-engineering'),
('数据库', 'database'),
('大数据', 'big-data'),
('AI应用', 'ai-applications');
-- 插入用户(略,假设已有数据)
-- 插入文章(embedding 部分用随机向量代替,实际应用中由 Embedding 模型生成)
-- 这里展示结构,不展示具体向量值
INSERT INTO articles (title, content, category_id, embedding, published_at) VALUES
('Transformer 架构深度解析', '...', 1, '[...]'::vector, now()),
('Apache Kafka 实战指南', '...', 2, '[...]'::vector, now()),
-- ...
;
note 真实项目中如何生成向量
实际应用中,向量通常由应用层调用 Embedding API 生成后写入数据库:
import openai
import psycopg2
def embed_and_store(article_id, text, conn):
response = openai.embeddings.create(
model="text-embedding-3-small",
input=text
)
embedding = response.data[0].embedding
with conn.cursor() as cur:
cur.execute(
"UPDATE articles SET embedding = %s WHERE id = %s",
(embedding, article_id)
)
conn.commit()
基础向量查询
最简单的语义搜索
向量搜索的最基本形式就一句话:
-- 假设 query_embedding 是查询文本的向量表示
-- 找 10 篇与查询最相关的文章
SELECT
id,
title,
embedding <-> '[0.1, 0.2, ...]'::vector AS distance
FROM articles
ORDER BY embedding <-> '[0.1, 0.2, ...]'::vector
LIMIT 10;
逐部分解释:
embedding <-> '[0.1, 0.2, ...]'::vector
| 部分 | 说明 |
|---|---|
embedding |
表中存储的向量列名 |
<-> |
L2 欧氏距离算子 |
'[0.1, 0.2, ...]'::vector |
查询向量:你要找"像这个"的内容 |
ORDER BY ... ASC(距离从小到大,最相似的排在前面)LIMIT 10只取最相似的前 10 条- 距离值越小,表示越相似(L2 距离)
实际查询模式(应用层调用):
-- 使用参数化查询(推荐,防止 SQL 注入,且支持查询计划缓存)
PREPARE find_similar (vector) AS
SELECT id, title, $1 <-> embedding AS distance
FROM articles
WHERE is_published = true
ORDER BY embedding <-> $1
LIMIT 10;
-- 执行
EXECUTE find_similar('[0.1, 0.2, 0.3, ...]'::vector);
返回相似度分数而不是距离
"距离"和"相似度"是相反的概念——距离越小越相似。但有时业务层更习惯看一个 0 到 1 之间的相似度分数:
-- 余弦相似度:1 - 余弦距离
-- <=> 是余弦距离,值域 [0, 2]
-- 余弦相似度 = 1 - 余弦距离,值域 [-1, 1];若需要映射到 [0, 1],可使用 1 - 余弦距离/2,但该映射值不是原始余弦相似度
SELECT
id,
title,
1 - (embedding <=> query_vec) AS similarity_score
FROM articles
WHERE is_published = true
ORDER BY embedding <=> query_vec -- 仍然按距离排序
LIMIT 10;
tip 余弦相似度与余弦距离的换算
- pgvector 的
<=>返回余弦距离,值域[0, 2] - 对非零向量:余弦相似度 = 1 - 余弦距离;这一换算不要求向量事先完成 L2 归一化
- 大多数 Embedding 模型(如 OpenAI text-embedding-3)输出的向量已经归一化,可以直接用这个公式
三种距离算子详解
pgvector 提供三种常用距离算子,选错了不只是精度问题,还会导致索引失效。
-- L2 欧氏距离:<->
-- 适合:距离本身有物理意义的场景(如坐标、图像特征)
SELECT id, embedding <-> query_vec AS l2_distance FROM articles;
-- 余弦距离:<=>
-- 适合:关注方向不关注大小的场景(文本语义搜索的主流选择)
SELECT id, embedding <=> query_vec AS cosine_distance FROM articles;
-- 负内积:<#>
-- 适合:向量已经 L2 归一化的场景(结果是 -1 * 内积,值越小越相似)
SELECT id, embedding <#> query_vec AS neg_inner_product FROM articles;
-- pgvector 0.7+ 新增
-- Hamming 距离(仅用于 bit 类型):<~>
SELECT id, phash <~> query_phash AS hamming FROM images;
-- Jaccard 距离(仅用于 bit 类型):<%>
SELECT id, phash <%> query_phash AS jaccard FROM images;
如何选择?
| 算子 | 算法 | 最适合 |
|---|---|---|
<-> |
L2 欧氏距离 | 坐标系距离、图像特征 |
<=> |
余弦距离 | 文本语义搜索(最常用) |
<#> |
负内积 | 向量已归一化、推荐系统 |
warning 算子必须与索引操作符类匹配
若要让该 vector_cosine_ops 距离索引支持向量排序,查询需使用 <=> 算子;使用其他距离算子不能利用该索引完成相应排序,但仍可能使用其他索引过滤。算子匹配也不保证规划器一定选择该索引。
-- 索引:cosine
CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);
-- 查询:必须用 <=>
SELECT * FROM articles ORDER BY embedding <=> query_vec LIMIT 10; -- 算子匹配,可由规划器选择该距离索引
SELECT * FROM articles ORDER BY embedding <-> query_vec LIMIT 10; -- 不能使用上述 cosine 距离索引完成 L2 排序
操作符对照表:
| 操作符类 | 对应距离算子 |
|---|---|
vector_l2_ops |
<-> |
vector_cosine_ops |
<=> |
vector_ip_ops |
<#> |
向量查询与业务表 JOIN
这是 pgvector 相对于专用向量库最大的优势。 在 Milvus 里,你找到了 10 个向量 ID,还需要另一次查询去业务数据库拿具体信息。在 pgvector 里,一条 SQL 搞定。
基础 JOIN:关联分类和作者信息
SELECT
a.id,
a.title,
a.summary,
c.name AS category_name,
u.username AS author_name,
a.published_at,
a.embedding <=> query_vec AS similarity
FROM articles a
JOIN categories c ON c.id = a.category_id
JOIN users u ON u.id = a.author_id
WHERE a.is_published = true
ORDER BY a.embedding <=> query_vec
LIMIT 10;
业务场景: 用户在知识库搜索"Transformer 注意力机制",不仅要看到文章标题,还需要知道这篇文章属于哪个分类、是谁写的、什么时候发布的。
关联阅读历史:个性化排除已读
-- 找到用户还没读过的相似文章
SELECT
a.id,
a.title,
a.embedding <=> query_vec AS similarity
FROM articles a
LEFT JOIN reading_history rh
ON rh.article_id = a.id AND rh.user_id = 42
WHERE a.is_published = true
AND rh.id IS NULL -- 过滤掉已读文章
ORDER BY a.embedding <=> query_vec
LIMIT 10;
业务价值: 不向用户推荐他已经读过的内容,这在专用向量库里需要在应用层手动过滤,而在 pgvector 里一条 SQL 完成。
多表聚合:带统计信息的搜索结果
SELECT
a.id,
a.title,
c.name AS category,
a.view_count,
COUNT(rh.id) AS total_readers,
AVG(rh.duration_s) AS avg_read_duration_s,
a.embedding <=> query_vec AS similarity
FROM articles a
JOIN categories c ON c.id = a.category_id
LEFT JOIN reading_history rh ON rh.article_id = a.id
WHERE a.is_published = true
GROUP BY a.id, a.title, c.name, a.view_count, a.embedding
ORDER BY a.embedding <=> query_vec
LIMIT 10;
note GROUP BY 中向量列与主键函数依赖
非聚合表达式涉及的列通常需要出现在 GROUP BY 中;但 PostgreSQL 在 GROUP BY 包含基表主键时,可允许引用由该主键函数依赖确定的其他列。本例按 articles 的主键 a.id 分组,因此 a.embedding 不必另行列入 GROUP BY。
混合过滤:WHERE 子句 + 向量排序
这是实际业务中最常用的查询模式——既要语义相关,又要满足业务条件。
基础混合过滤
-- 在"机器学习"分类下,找最近一年发布的相关文章
SELECT
id,
title,
published_at,
embedding <=> query_vec AS similarity
FROM articles
WHERE category_id = 1 -- 指定分类
AND published_at > now() - INTERVAL '1 year' -- 时间范围
AND is_published = true -- 只看已发布
ORDER BY embedding <=> query_vec
LIMIT 10;
多条件过滤
-- 复杂过滤:多个分类、标签、时间
SELECT
a.id,
a.title,
a.tags,
a.embedding <=> query_vec AS similarity
FROM articles a
WHERE a.category_id = ANY(ARRAY[1, 2, 3]) -- 多个分类
AND a.tags && ARRAY['深度学习', 'NLP'] -- 包含指定标签之一
AND a.view_count > 1000 -- 热门文章
AND a.published_at BETWEEN '2024-01-01' AND '2025-12-31'
AND a.is_published = true
ORDER BY a.embedding <=> query_vec
LIMIT 20;
过滤时的性能陷阱
warning 过滤太严格会导致召回率下降
假设你有 100 万篇文章,但某个分类只有 1000 篇。当你加了 WHERE category_id = 99 后,pgvector 的 HNSW 索引仍然在 100 万个向量构成的图中导航,找到的候选集里大多数会被 WHERE 过滤掉,最终可能凑不够 LIMIT 要求的数量。
解决方案一: 为过滤列 + 向量创建局部索引
-- 只对机器学习分类的文章建索引
CREATE INDEX idx_hnsw_ml ON articles
USING hnsw (embedding vector_cosine_ops)
WHERE category_id = 1;
-- WHERE 条件可证明满足局部索引谓词时,规划器可以选择该索引,但不保证使用
SELECT * FROM articles
WHERE category_id = 1
ORDER BY embedding <=> query_vec
LIMIT 10;
解决方案二: 增大 ef_search 提高候选集
SET hnsw.ef_search = 200; -- 探索更多候选,提高过滤后的召回
SELECT * FROM articles WHERE category_id = 1 ORDER BY embedding <=> query_vec LIMIT 10;
使用 CTE 实现"先过滤再搜索"
对于过滤条件非常严格、能大幅减少数据集的情况,可在支持 MATERIALIZED CTE 的 PostgreSQL 版本中显式建立过滤结果边界,再对该结果计算距离并排序;普通可内联 CTE 不保证这一执行顺序,过滤阶段是否使用普通索引仍由规划器决定:
-- 策略:先物化过滤结果,再在该结果内计算向量距离并排序
WITH filtered_articles AS MATERIALIZED (
-- 第一步:过滤并物化结果;是否使用普通索引及耗时取决于实际执行计划
SELECT id, title, embedding
FROM articles
WHERE category_id = 1
AND is_published = true
AND published_at > '2024-01-01'
)
-- 第二步:在过滤后的子集上做精确向量搜索
SELECT
id,
title,
embedding <=> query_vec AS similarity
FROM filtered_articles
ORDER BY embedding <=> query_vec
LIMIT 10;
注意: 这种方式对物化后的候选集进行精确距离排序,通常适合过滤后数据量较小的情况;若仍有数十万条数据,应与使用 HNSW 索引带 WHERE 条件的方案比较实际耗时和召回效果,不能仅凭候选数量断定哪种方案更快。
全文检索与向量融合
这是 pgvector 作为 PostgreSQL 扩展的独特优势之一:可以把传统关键词搜索和语义向量搜索结合在一起,实现混合搜索(Hybrid Search)。
为什么需要混合搜索
| 搜索方式 | 优势 | 劣势 |
|---|---|---|
| 关键词搜索(BM25/全文检索) | 精确匹配、新词友好、可解释 | 无法理解同义词、语义变体 |
| 向量搜索(语义搜索) | 理解含义、跨语言、处理同义词 | 新词/专有名词可能找不到 |
| 混合搜索 | 两者兼得 | 需要融合策略、稍复杂 |
实例: 用户搜索"GPT-4o 图片理解"
- 纯关键词搜索:可以找到包含"GPT-4o"精确关键词的文章,不会漏掉新发布的内容
- 纯向量搜索:可以找到讨论"多模态大语言模型"的文章,即使没有提到"GPT-4o"
- 混合搜索:两者都能找到,结合排名取最优
PostgreSQL 内置全文检索
-- 查看文章的 tsvector 内容
SELECT id, to_tsvector('english', title || ' ' || content) AS ts
FROM articles
LIMIT 3;
-- 基础全文检索
SELECT id, title
FROM articles
WHERE search_vector @@ to_tsquery('english', 'transformer & attention')
ORDER BY ts_rank(search_vector, to_tsquery('english', 'transformer & attention')) DESC
LIMIT 10;
-- 更灵活的查询语法(plainto_tsquery 不需要手动加 & 和 |)
SELECT id, title,
ts_rank(search_vector, plainto_tsquery('english', 'transformer attention mechanism')) AS fts_rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'transformer attention mechanism')
ORDER BY fts_rank DESC
LIMIT 10;
混合搜索:RRF(Reciprocal Rank Fusion)方法
RRF 是目前最实用的混合搜索融合算法。原理简单:把两种搜索的结果分别排名,然后用一个公式把排名融合成综合分数。
RRF 公式: score = 1 / (k + rank_in_list),k 通常取 60。
-- 完整的混合搜索实现
WITH
-- 第一路:向量语义搜索
vector_search AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> query_vec) AS vector_rank
FROM articles
WHERE is_published = true
ORDER BY embedding <=> query_vec
LIMIT 50 -- 取 Top-50 候选
),
-- 第二路:全文关键词搜索
fts_search AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY ts_rank(search_vector, plainto_tsquery('english', :query_text)) DESC
) AS fts_rank
FROM articles
WHERE is_published = true
AND search_vector @@ plainto_tsquery('english', :query_text)
ORDER BY ts_rank(search_vector, plainto_tsquery('english', :query_text)) DESC
LIMIT 50 -- 取 Top-50 候选
),
-- 用 RRF 融合两路结果
rrf_scores AS (
SELECT
COALESCE(vs.id, fs.id) AS id,
COALESCE(1.0 / (60 + vs.vector_rank), 0) +
COALESCE(1.0 / (60 + fs.fts_rank), 0) AS rrf_score
FROM vector_search vs
FULL OUTER JOIN fts_search fs ON vs.id = fs.id
)
-- 最终结果:关联原始数据,按 RRF 分数排序
SELECT
a.id,
a.title,
a.summary,
rs.rrf_score
FROM rrf_scores rs
JOIN articles a ON a.id = rs.id
ORDER BY rs.rrf_score DESC
LIMIT 10;
解读:
FULL OUTER JOIN:确保只出现在其中一路的结果也被包含COALESCE(..., 0):如果某篇文章在某一路里没有出现,该路贡献 0 分- 同时出现在两路里的文章,RRF 分数更高(两路加分)
加权混合搜索
有时候你希望向量搜索贡献更大权重(比如用户意图模糊时),或者关键词搜索贡献更大权重(比如搜索特定产品名):
-- 可调权重的混合搜索(向量权重 0.7,关键词权重 0.3)
WITH
vector_search AS (
SELECT id,
1 - (embedding <=> query_vec) AS vector_score -- 转换为相似度(越大越好)
FROM articles
WHERE is_published = true
ORDER BY embedding <=> query_vec
LIMIT 100
),
fts_search AS (
SELECT id,
ts_rank_cd(search_vector, plainto_tsquery('english', :query_text)) AS fts_score
FROM articles
WHERE is_published = true
AND search_vector @@ plainto_tsquery('english', :query_text)
ORDER BY ts_rank_cd(search_vector, plainto_tsquery('english', :query_text)) DESC
LIMIT 100
),
-- 对两路分数分别归一化到 [0,1]
normalized AS (
SELECT
COALESCE(vs.id, fs.id) AS id,
COALESCE((vs.vector_score + 1) / 2.0, 0) AS norm_vector,
COALESCE(
fs.fts_score / NULLIF(MAX(fs.fts_score) OVER (), 0),
0
) AS norm_fts
FROM vector_search vs
FULL OUTER JOIN fts_search fs ON vs.id = fs.id
)
SELECT
a.id,
a.title,
n.norm_vector * 0.7 + n.norm_fts * 0.3 AS hybrid_score
FROM normalized n
JOIN articles a ON a.id = n.id
ORDER BY hybrid_score DESC
LIMIT 10;
聚合与分组
这是向量查询结合 SQL 能力的一个进阶用法,在"找每个类别中最相似的文章"这类需求中非常有用。
找每个分类中与查询最相关的 Top-1 文章
-- 使用 DISTINCT ON(PostgreSQL 特有语法)
SELECT DISTINCT ON (category_id)
a.category_id,
c.name AS category_name,
a.id AS article_id,
a.title,
a.embedding <=> query_vec AS similarity
FROM articles a
JOIN categories c ON c.id = a.category_id
WHERE a.is_published = true
ORDER BY a.category_id, a.embedding <=> query_vec -- 同一分类内按相似度排序
;
-- 结果:每个分类返回一篇最相关的文章
DISTINCT ON 的逻辑: 按 category_id 分组,每组只保留第一行(ORDER BY 决定了哪行是第一行)。这是 PostgreSQL 的非标准但极其实用的语法。
统计每个分类的平均相似度
-- 找出"整体上"和查询向量最接近的分类
SELECT
c.name AS category_name,
COUNT(a.id) AS article_count,
AVG(a.embedding <=> query_vec) AS avg_distance, -- 平均距离(越小越相关)
MIN(a.embedding <=> query_vec) AS min_distance -- 最相似的文章距离
FROM articles a
JOIN categories c ON c.id = a.category_id
WHERE a.is_published = true
GROUP BY c.id, c.name
ORDER BY avg_distance ASC
LIMIT 5;
业务场景: 用户搜索某个主题时,除了返回具体文章,还告诉用户"这个主题在哪个分类下最集中"。
向量聚类:找到数据中的自然分组
-- 找出每篇文章的"最相似邻居"(自连接)
-- 注意:这个查询在大数据量时很慢,仅用于分析
SELECT
a.id AS article_id,
a.title AS article_title,
b.id AS neighbor_id,
b.title AS neighbor_title,
a.embedding <-> b.embedding AS distance
FROM articles a
CROSS JOIN LATERAL (
SELECT id, title, embedding
FROM articles b
WHERE b.id != a.id
ORDER BY a.embedding <-> b.embedding
LIMIT 1
) b
WHERE a.id BETWEEN 1 AND 100 -- 只分析前 100 篇
ORDER BY a.id;
窗口函数
窗口函数是 SQL 的高级特性,结合向量查询可以解决很多"排名类"业务问题。
找每个用户最相关的 Top-3 推荐文章
-- 场景:给多个用户批量生成推荐(离线任务)
-- 假设每个用户有一个"兴趣向量"(由历史行为计算得到)
WITH user_recommendations AS (
SELECT
u.id AS user_id,
u.username,
a.id AS article_id,
a.title,
a.embedding <=> u.interest_vector AS relevance_distance,
ROW_NUMBER() OVER (
PARTITION BY u.id
ORDER BY a.embedding <=> u.interest_vector
) AS rank_for_user
FROM users u
CROSS JOIN articles a
WHERE a.is_published = true
AND NOT EXISTS (
SELECT 1 FROM reading_history rh
WHERE rh.user_id = u.id AND rh.article_id = a.id
)
)
SELECT
user_id,
username,
article_id,
title,
relevance_distance,
rank_for_user
FROM user_recommendations
WHERE rank_for_user <= 3 -- 每个用户最多 3 篇推荐
ORDER BY user_id, rank_for_user;
warning CROSS JOIN 的性能警告
CROSS JOIN 是笛卡尔积,用户数 × 文章数。如果有 1000 个用户和 100 万篇文章,这个查询要计算 10 亿个距离,会非常慢。实际使用时:
- 先用 WHERE 过滤掉明显不相关的文章
- 或者用 LATERAL 子查询(见下文)
- 或者在应用层批量处理
LATERAL 子查询:更高效的"每用户 Top-K"
-- 使用 LATERAL 子查询,更高效
SELECT
u.id AS user_id,
u.username,
recs.article_id,
recs.title,
recs.distance
FROM users u
CROSS JOIN LATERAL (
SELECT
a.id AS article_id,
a.title,
a.embedding <=> u.interest_vector AS distance
FROM articles a
WHERE a.is_published = true
AND NOT EXISTS (
SELECT 1 FROM reading_history rh
WHERE rh.user_id = u.id AND rh.article_id = a.id
)
ORDER BY a.embedding <=> u.interest_vector
LIMIT 3 -- 每个用户取 3 篇
) recs
ORDER BY u.id, recs.distance;
LATERAL 的优势: 子查询可以引用外层查询的列(u.interest_vector),并且 LIMIT 在子查询内部生效,大幅减少了中间结果的数量。
相对排名:文章在各自分类中的排名
-- 找出搜索结果中,每篇文章在自己分类内的相似度排名
SELECT
a.id,
a.title,
c.name AS category,
a.embedding <=> query_vec AS distance,
RANK() OVER (
PARTITION BY a.category_id
ORDER BY a.embedding <=> query_vec
) AS rank_in_category,
PERCENT_RANK() OVER (
PARTITION BY a.category_id
ORDER BY a.embedding <=> query_vec
) AS percentile_in_category
FROM articles a
JOIN categories c ON c.id = a.category_id
WHERE a.is_published = true
ORDER BY a.embedding <=> query_vec
LIMIT 50;
CTE 与复杂查询组合
完整的推荐系统查询(协同过滤 + 向量搜索)
-- 场景:给用户 42 推荐文章
-- 策略:找到"和用户 42 读过相同文章的人",看他们还读了什么
WITH
-- 第一步:找出用户 42 读过的文章
user_reads AS (
SELECT article_id
FROM reading_history
WHERE user_id = 42
),
-- 第二步:找到"口味相似的用户"(读过相同文章的人)
similar_users AS (
SELECT
rh.user_id,
COUNT(*) AS shared_articles -- 共同读过的文章数
FROM reading_history rh
JOIN user_reads ur ON ur.article_id = rh.article_id
WHERE rh.user_id != 42
GROUP BY rh.user_id
HAVING COUNT(*) >= 3 -- 至少 3 篇共同文章才算"相似"
ORDER BY shared_articles DESC
LIMIT 20 -- 取前 20 个相似用户
),
-- 第三步:找出这些相似用户读过但用户 42 没读过的文章
candidate_articles AS (
SELECT DISTINCT rh.article_id
FROM reading_history rh
JOIN similar_users su ON su.user_id = rh.user_id
WHERE NOT EXISTS (
SELECT 1 FROM user_reads ur WHERE ur.article_id = rh.article_id
)
),
-- 第四步:用向量搜索在候选文章中找最相关的
-- (需要一个"用户兴趣向量",这里假设已有)
user_interest AS (
-- 用用户历史阅读文章的向量平均值作为兴趣向量
SELECT AVG(a.embedding) AS interest_vector
FROM articles a
JOIN user_reads ur ON ur.article_id = a.id
WHERE a.embedding IS NOT NULL
)
SELECT
a.id,
a.title,
c.name AS category,
a.embedding <=> ui.interest_vector AS relevance,
COUNT(su_count.user_id) AS recommended_by_count
FROM candidate_articles ca
JOIN articles a ON a.id = ca.article_id
JOIN categories c ON c.id = a.category_id
CROSS JOIN user_interest ui
LEFT JOIN (
SELECT rh.article_id, su.user_id
FROM reading_history rh
JOIN similar_users su ON su.user_id = rh.user_id
) su_count ON su_count.article_id = a.id
WHERE a.is_published = true
AND a.embedding IS NOT NULL
GROUP BY a.id, a.title, c.name, a.embedding, ui.interest_vector
ORDER BY
a.embedding <=> ui.interest_vector, -- 主排序:语义相关性
COUNT(su_count.user_id) DESC -- 次排序:被相似用户推荐次数
LIMIT 10;
note 关于向量求平均
AVG(embedding) 对向量列做分量平均,pgvector 支持这个操作。得到的平均向量可以理解为"这批文章的语义重心",用它代表用户的兴趣是一个合理的近似。
EXPLAIN ANALYZE 读懂执行计划
这是诊断查询性能问题最重要的工具。向量查询有一些特有的现象,需要知道如何读懂。
基础用法
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, title
FROM articles
ORDER BY embedding <=> query_vec
LIMIT 10;
识别执行计划中的关键信息
情况一:使用 HNSW 索引的假设执行计划示例(以下数值仅用于演示阅读方法,不代表实测基准)
Limit (cost=0.26..2.80 rows=10 width=44) (actual time=1.234..1.267 rows=10 loops=1)
-> Index Scan using idx_articles_hnsw on articles (cost=0.26..13457.26 rows=48231 ...)
Order By: (embedding <=> '[0.1, 0.2, ...]'::vector)
Planning Time: 0.182 ms
Execution Time: 1.289 ms
关键标志:
Index Scan using idx_articles_hnsw:HNSW 索引被使用Execution Time: 1.289 ms:非常快Order By: (embedding <=> ...):排序操作在索引层完成
情况二:顺序扫描的假设执行计划示例(以下数值不构成与前例的受控性能对比)
Limit (cost=87234.56..87234.58 rows=10 width=44) (actual time=892.123..892.145 rows=10 loops=1)
-> Sort (cost=87234.56..88447.63 rows=485228 ...)
Sort Key: ((embedding <=> '[0.1, ...]'::vector))
-> Seq Scan on articles (cost=0.00..73821.58 rows=485228 ...)
Planning Time: 0.234 ms
Execution Time: 892.156 ms
关键标志:
Seq Scan on articles:没有使用索引,全表扫描Sort:执行排序;可能使用内存或磁盘,需结合 Sort Method、内存或磁盘用量及输入规模判断成本Execution Time: 892.156 ms:仅为假设示例中的耗时;不能据此认定顺序扫描比 HNSW 慢固定倍数
为什么会出现 Seq Scan?
-- 诊断 1:检查查询算子与索引类型是否匹配
-- 索引用了 cosine_ops,查询用了 <->(L2)
-- 解决:改用 <=>
-- 诊断 2:LIMIT 太大触发 planner 倾向顺序扫描
EXPLAIN (ANALYZE)
SELECT * FROM articles ORDER BY embedding <=> query_vec LIMIT 1000;
-- LIMIT 1000 时,planner 可能判断"顺序扫描更划算"
-- 强制使用索引(调试专用,不建议生产使用)
SET enable_seqscan = off;
EXPLAIN (ANALYZE)
SELECT * FROM articles ORDER BY embedding <=> query_vec LIMIT 1000;
SET enable_seqscan = on; -- 用完记得改回来!
-- 诊断 3:统计信息过旧
ANALYZE articles; -- 更新统计信息
-- 再次 EXPLAIN ANALYZE 查看是否改善
读懂 Buffers 信息
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM articles ORDER BY embedding <=> query_vec LIMIT 10;
-- 输出示例
-- Buffers: shared hit=1247 read=89
-- hit:从内存(shared_buffers)读取的块数
-- read:读入 PostgreSQL 共享缓冲区的块数,可能命中操作系统缓存,不等同于物理磁盘读取
-- read 较大时需结合相关计划节点及 I/O 时间判断,不能仅据此认定索引驻留不足或磁盘成为瓶颈
优化建议:
- 不以固定的
read占比直接决定调参;应结合工作集、操作系统缓存、I/O 时间和并发负载评估shared_buffers - 减小 HNSW 的
m可降低索引空间需求,但需要重建索引,并重新评估召回率与查询性能
使用 auto_explain 自动记录慢查询
-- 在 postgresql.conf 中启用
-- shared_preload_libraries = 'auto_explain'
-- auto_explain.log_min_duration = 1000 -- 超过 1 秒自动记录
-- 或者在会话中临时启用
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 100; -- 超过 100ms 就记录
SET auto_explain.log_analyze = true;
-- 执行你的查询,慢查询会自动出现在 PostgreSQL 日志中
常见陷阱与调试方法
陷阱 1:加了索引反而更慢
现象: 建了 HNSW 索引,但查询时间反而比没有索引时更长。
可能原因:
-- 原因一:索引没有加载进内存
-- 首次查询时需要从磁盘加载索引,"预热"后才快
-- 测试方法:连续执行同一个查询 5 次,看后几次的时间
SELECT id FROM articles ORDER BY embedding <=> query_vec LIMIT 10;
SELECT id FROM articles ORDER BY embedding <=> query_vec LIMIT 10;
SELECT id FROM articles ORDER BY embedding <=> query_vec LIMIT 10;
-- 如果第3次明显快于第1次,可能存在缓存预热效应;需结合 Buffers、I/O 时间和负载变化进一步判断
-- 解决方案:预热索引
-- 安装 pg_prewarm 扩展
CREATE EXTENSION pg_prewarm;
SELECT pg_prewarm('idx_articles_hnsw');
-- 原因二:ef_search 过高,探索节点太多
-- 检查当前 ef_search 值
SHOW hnsw.ef_search;
-- 尝试降低
SET hnsw.ef_search = 20; -- 默认是 40
陷阱 2:相似度结果不符合预期
现象: 搜索"机器学习",返回的结果和机器学习没什么关系。
排查清单:
-- 检查 1:向量是否正确写入
SELECT id, title,
array_length(embedding::float4[], 1) AS dimensions, -- 检查维度
embedding IS NULL AS is_null
FROM articles
LIMIT 10;
-- 检查 2:查询向量和数据库向量的维度是否一致
-- 如果维度不一致,PostgreSQL 会报错,所以通常能被发现
-- 检查 3:距离算子是否和 Embedding 模型匹配
-- OpenAI text-embedding-3 系列推荐用余弦相似度(<=>)
-- 而不是 L2 距离(<->)
-- 检查 4:查询向量本身是否正确(不要用全零向量测试)
SELECT embedding <=> '[0,0,0,...,0]'::vector AS dist_to_zero
FROM articles LIMIT 1;
-- 全零向量的余弦距离没有定义,不能用该结果是否接近 0 判断数据质量;应检查向量维度、是否为零向量,以及查询与入库所用模型是否一致
陷阱 3:NULL 向量导致结果缺失
-- 检查有多少条记录没有向量
SELECT COUNT(*) FILTER (WHERE embedding IS NULL) AS null_count,
COUNT(*) AS total_count
FROM articles;
-- 向量为 NULL 的行在 ORDER BY 时会被排到最后(NULL 排序规则)
-- 精确升序排序默认将 NULL 放在最后;若非 NULL 行少于 LIMIT,仍可能返回 NULL 行。向量索引不包含 NULL 向量
-- 过滤掉 NULL 向量的行
SELECT id, title
FROM articles
WHERE embedding IS NOT NULL
ORDER BY embedding <=> query_vec
LIMIT 10;
陷阱 4:高并发下向量查询性能下降
现象: 单次查询很快(< 10ms),但高并发时响应时间急剧升高。
诊断:
-- 查看锁等待情况
SELECT pid, query, state, wait_event_type, wait_event, query_start
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;
-- 查看慢查询
SELECT pid, now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '1 second'
ORDER BY duration DESC;
优化方向:
-- 1. 增加并行工作线程数
SET max_parallel_workers_per_gather = 4;
-- 2. 使用连接池(PgBouncer)减少连接开销
-- (连接池配置在 pgbouncer.ini 中,不在 SQL 中)
-- 3. 对只读查询使用只读副本
-- 在应用层路由:写操作 → 主库,读操作 → 副本
生产级查询模板
模板 A:使用预备语句的实时语义搜索(不缓存检索结果)
-- 使用 prepared statement 提高重复查询性能
PREPARE semantic_search (vector, integer, boolean) AS
SELECT
a.id,
a.title,
a.summary,
c.name AS category,
a.published_at,
a.embedding <=> $1 AS similarity_distance
FROM articles a
JOIN categories c ON c.id = a.category_id
WHERE a.is_published = $3
AND a.embedding IS NOT NULL
ORDER BY a.embedding <=> $1
LIMIT $2;
-- 调用
EXECUTE semantic_search(
'[0.1, 0.2, ...]'::vector, -- 查询向量
10, -- 返回条数
true -- 只返回已发布的
);
模板 B:RAG 应用的上下文检索
-- 场景:RAG 应用中检索相关文档片段
-- 此处只限制每篇文章的字符数;总 token 预算需在应用层使用目标模型的分词器计算并截断
SELECT
a.id,
a.title,
LEFT(a.content, 500) AS content_preview, -- 限制长度
a.embedding <=> query_vec AS relevance
FROM articles a
WHERE a.is_published = true
AND a.embedding IS NOT NULL
AND a.published_at > now() - INTERVAL '2 years' -- 只用近期内容
ORDER BY a.embedding <=> query_vec
LIMIT 5; -- RAG 通常取 3-5 个上下文
模板 C:相似内容推荐(基于当前文章)
-- 查看当前阅读文章的相关推荐
-- 输入:当前文章 ID
WITH current_article AS (
SELECT embedding, category_id
FROM articles
WHERE id = :current_article_id
)
SELECT
a.id,
a.title,
c.name AS category,
a.embedding <=> ca.embedding AS similarity
FROM articles a
JOIN categories c ON c.id = a.category_id
CROSS JOIN current_article ca
WHERE a.id != :current_article_id -- 排除自身
AND a.is_published = true
AND a.embedding IS NOT NULL
ORDER BY a.embedding <=> ca.embedding
LIMIT 6; -- 通常推荐 6 篇相关文章
模板 D:去重/聚类(找重复内容)
-- 找出数据库中高度相似的文章对(疑似重复)
-- 注意:这个查询复杂度 O(n²),只在小数据集或定期批量运行
WITH article_pairs AS (
SELECT
a1.id AS id1,
a1.title AS title1,
a2.id AS id2,
a2.title AS title2,
a1.embedding <=> a2.embedding AS distance
FROM articles a1
JOIN articles a2
ON a2.id > a1.id -- 避免重复对(id2 > id1)
AND a1.embedding <=> a2.embedding < 0.05 -- 余弦距离 < 0.05 视为高度相似
WHERE a1.embedding IS NOT NULL
AND a2.embedding IS NOT NULL
)
SELECT *
FROM article_pairs
ORDER BY distance ASC
LIMIT 50;
本章小结
本章覆盖了 pgvector 在实际业务场景中的主要 SQL 查询模式:
| 查询模式 | 核心要点 |
|---|---|
| 基础语义搜索 | ORDER BY embedding <=> query_vec LIMIT N,算子必须与索引匹配 |
| 业务表 JOIN | pgvector 最大优势,一条 SQL 融合向量结果与业务数据 |
| 混合过滤 | 结果需满足 WHERE 条件;HNSW 路径可能在获取候选后过滤,严格过滤时注意召回率 |
| 全文检索融合 | tsvector + pgvector,用 RRF 算法融合两路排名 |
| 窗口函数 | PARTITION BY 实现"每组 Top-K",LATERAL 提高性能 |
| EXPLAIN ANALYZE | 重点看 Seq Scan vs Index Scan,关注 Buffers hit/read |
| 常见陷阱 | 算子不匹配、NULL 向量、索引未预热、LIMIT 过大 |