详解在 Postgres 中融合 pgvector 语义搜索与全文搜索,通过 RRF 算法合并排名,可同时命中近义词和精确词,提升搜索质量。
向量搜索擅长查找语义相似的文本。
全文搜索擅长查找用户实际输入的词语。
一个搜索功能通常需要两者兼具。
想象开发者搜索:
JWT token expired after password reset
向量搜索可能找到关于身份验证会话、凭证过期或重置流程的文档,即使这些确切词语并未出现。
PostgreSQL 全文搜索可以精确匹配包含以下内容的文档:
JWT
token
password reset
expired
这些是各自不同的优势。
我们不必选择其中一种,而是可以从两种方式中检索候选结果并合并它们的排名。
这就是混合搜索。
本指南将使用以下技术构建它:
PostgreSQL 全文搜索
互惠排名融合(RRF)
租户和元数据过滤器
一个 Node.js 查询函数
一个小型搜索质量测试集
最终路径如下:
user query
↓
┌─────────────────┬─────────────────┐
│ keyword search │ semantic search │
│ PostgreSQL FTS │ pgvector │
└─────────────────┴─────────────────┘
↓
candidate ranks
↓
RRF
↓
final result list
本指南使用 PostgreSQL 16 语法和 pgvector 0.8.6。
pgvector 目前支持 PostgreSQL 13 及以上版本。
为什么要结合关键词搜索和向量搜索?
假设存储的文档说:
Reset links are invalid after fifteen minutes.
password recovery token expiration
语义搜索可以理解这些想法虽然措辞不同,但彼此相关。
现在想象用户搜索:
ERR_AUTH_2041
嵌入向量可能对这个标识符几乎一无所知。
全文搜索可以直接匹配它。
同样的情况也会发生在:
混合搜索让两种检索方法都能发挥作用。
在数据库中一次性启用扩展:
CREATE EXTENSION IF NOT EXISTS vector;
pgvector 将嵌入向量存储在普通的 PostgreSQL 列中。
本例使用余弦距离。
创建一个将文本、元数据、嵌入向量和全文表示存储在一起的表。
CREATE TABLE documents (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
title text NOT NULL,
content text NOT NULL,
category text,
published_at timestamptz,
embedding vector(1536) NOT NULL,
search_vector tsvector
GENERATED ALWAYS AS (
setweight(
to_tsvector(
'english',
coalesce(title, '')
),
'A'
)
||
setweight(
to_tsvector(
'english',
coalesce(content, '')
),
'B'
)
) STORED,
created_at timestamptz
NOT NULL
DEFAULT now()
);
1536 维度是示例。
使用你的嵌入模型返回的确切维度。
生成的 search_vector 给予标题比正文更高的权重:
title → weight A
content → weight B
这有助于将在标题中包含查询词的文档排名高于在正文深处才提到相同术语的文档。
全文搜索使用 GIN 索引:
CREATE INDEX documents_search_vector_gin
ON documents
USING gin (search_vector);
向量搜索使用余弦距离的 HNSW 索引:
CREATE INDEX documents_embedding_hnsw
ON documents
USING hnsw (
embedding vector_cosine_ops
);
为租户过滤器添加索引:
CREATE INDEX documents_tenant_id_idx
ON documents (tenant_id);
如果经常按 category 过滤:
CREATE INDEX documents_category_idx
ON documents (category);
此时我们在同一个数据库中拥有两个独立的搜索系统。
PostgreSQL 给了我们多种将文本转换为搜索查询的方法。
对于普通的产品搜索框,websearch_to_tsquery 很方便,因为它接受用户友好的输入,理解带引号的短语、OR 和减号风格的排除。
SELECT
id,
title,
ts_rank_cd(
search_vector,
websearch_to_tsquery(
'english',
'password reset token'
)
) AS lexical_score
FROM documents
WHERE
search_vector @@
websearch_to_tsquery(
'english',
'password reset token'
)
ORDER BY lexical_score DESC
LIMIT 10;
这给了我们关键词相关性。
PostgreSQL 也提供 ts_rank。
这里使用 ts_rank_cd 是因为它可以考虑匹配术语的紧密程度。
假设我们已经为用户查询准备好了嵌入向量。
语义查询要简单得多:
SELECT
id,
title,
embedding <=> $1::vector AS distance
FROM documents
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 10;
使用余弦距离时:
较小的距离 = 较近的向量
可以将余弦距离转换为相似度值:
1 - (embedding <=> $1::vector)
对于混合排名,我们不需要将这个原始值与文本搜索分数进行比较。
这也是 RRF 方便的原因之一。
你可能想这样做:
final score =
lexical score
+
vector similarity
问题是这些分数本质上不在同一尺度上。
0.78
与向量相似度的
0.78
含义并不相同。
你可以对两个分数进行归一化和调优,但这样排名系统就会对这些归一化选择变得敏感。
互惠排名融合使用排名位置代替。
这使得合并两个具有不同分数尺度的检索系统变得容易得多。
RRF 根据每个文档在各个排名列表中的位置为其加分。
RRF score = 1 / (k + rank)
如果文档出现在多个列表中,则将贡献分数相加。
k = 60
排名第一的文档贡献:
1 / 61
排名第十的文档贡献:
1 / 70
这个常数可以调优。
有用的特性是,在两种检索方法中都排在前面的文档会获得更强的综合分数。
下面是完整的混合查询。
WITH
params AS (
SELECT
websearch_to_tsquery(
'english',
$1
) AS text_query,
$2::vector AS query_embedding,
$3::bigint AS tenant_id
),
lexical AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
AND
d.search_vector @@ p.text_query
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
LIMIT 50
),
semantic AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
d.embedding <=>
p.query_embedding
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
ORDER BY
d.embedding <=>
p.query_embedding
LIMIT 50
),
fused AS (
SELECT
coalesce(
lexical.id,
semantic.id
) AS id,
coalesce(
1.0 / (
60 + lexical.rank
),
0.0
)
+
coalesce(
1.0 / (
60 + semantic.rank
),
0.0
)
AS rrf_score
FROM lexical
FULL OUTER JOIN semantic
ON lexical.id = semantic.id
)
SELECT
d.id,
d.title,
d.content,
d.category,
d.published_at,
fused.rrf_score
FROM fused
JOIN documents d
ON d.id = fused.id
ORDER BY
fused.rrf_score DESC
LIMIT 10;
这个查询创建两个候选列表:
top 50 lexical results
top 50 semantic results
然后将它们连接起来并计算 RRF 分数。
文档可以出现在:
仅词法结果中
仅语义结果中
两者都有
在两个列表中都排名靠前的文档往往会上移。
现在想象同一个表存储了:
documentation
support
blog
product
policy
将 category 添加到 params 中:
WITH
params AS (
SELECT
websearch_to_tsquery(
'english',
$1
) AS text_query,
$2::vector AS query_embedding,
$3::bigint AS tenant_id,
$4::text AS category
)
然后在两个检索分支中添加相同的条件:
AND (
p.category IS NULL
OR d.category = p.category
)
在词法搜索和语义查询中都这样做。
你不希望关键词搜索遵守过滤器而向量搜索悄悄忽略它。
这是看起来正确但可能返回比预期更少向量结果的地方之一。
使用近似 HNSW 索引时,过滤可以在从向量索引中提取候选行之后进行。
HNSW candidate pool = 40
only 10% belong to this tenant/category
最终可能只剩下少量可用的行。
pgvector 0.8.0 及更新版本支持迭代索引扫描,以帮助处理过滤后的近似搜索。
SET hnsw.iterative_scan = strict_order;
对于单个查询,将该设置保留在事务本地:
BEGIN;
SET LOCAL hnsw.iterative_scan = strict_order;
-- hybrid query here
COMMIT;
同时在有选择性的过滤列上保持常规索引。
在大规模场景下实现强租户隔离,pgvector 文档还建议使用分区或独立表等方案。
不要将向量过滤作为授权边界来对待。
应用程序在返回结果之前仍需强制执行租户访问控制。
npm install pg
src/search.js
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL,
});
function toVectorLiteral(values) {
if (!Array.isArray(values)) {
throw new TypeError(
"queryEmbedding must be an array"
);
}
if (
values.some(
(value) =>
typeof value !== "number" ||
!Number.isFinite(value)
)
) {
throw new TypeError(
"queryEmbedding contains an invalid value"
);
}
return `[${values.join(",")}]`;
}
const HYBRID_SEARCH_SQL = `
WITH
params AS (
SELECT
websearch_to_tsquery(
'english',
$1
) AS text_query,
$2::vector AS query_embedding,
$3::bigint AS tenant_id
),
lexical AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
AND
d.search_vector @@ p.text_query
ORDER BY
ts_rank_cd(
d.search_vector,
p.text_query
) DESC
LIMIT 50
),
semantic AS (
SELECT
d.id,
row_number() OVER (
ORDER BY
d.embedding <=>
p.query_embedding
) AS rank
FROM documents d
CROSS JOIN params p
WHERE
d.tenant_id = p.tenant_id
ORDER BY
d.embedding <=>
p.query_embedding
LIMIT 50
),
fused AS (
SELECT
coalesce(
l.id,
s.id
) AS id,
coalesce(
1.0 / (60 + l.rank),
0.0
)
+
coalesce(
1.0 / (60 + s.rank),
0.0
)
AS rrf_score
FROM lexical l
FULL OUTER JOIN semantic s
ON l.id = s.id
)
SELECT
d.id,
d.title,
d.content,
d.category,
fused.rrf_score
FROM fused
JOIN documents d
ON d.id = fused.id
ORDER BY
fused.rrf_score DESC
LIMIT $4;
`;
export async function hybridSearch({
queryText,
queryEmbedding,
tenantId,
limit = 10,
}) {
const vector =
toVectorLiteral(
queryEmbedding
);
const result =
await pool.query(
HYBRID_SEARCH_SQL,
[
queryText,
vector,
tenantId,
limit,
]
);
return result.rows;
}
嵌入提供者独立于这个函数。
搜索层只需要:
query text
query vector
tenant
后续更换嵌入提供者时无需重写 SQL 融合逻辑。
应用程序流程变为:
const queryText =
"password reset token expired";
const queryEmbedding =
await embedQuery(queryText);
const results =
await hybridSearch({
queryText,
queryEmbedding,
tenantId: 42,
limit: 10,
});
embedQuery() 返回的向量维度必须与 documents.embedding 列使用的维度一致。
vector(1536)
查询向量必须包含:
1536 dimensions
在应用边界处进行验证。
维度不匹配应在数据库查询之前失败。
不要在为新文档切换嵌入模型时悄无声息地进行,而旧行中仍然包含来自另一个模型的向量。
否则可能导致同一表中包含来自不同嵌入空间的向量。
一个简单的方法是存储:
embedding_model
embedding_version
ALTER TABLE documents
ADD COLUMN embedding_model text;
这样迁移到另一个模型时可以明确知道。
根据产品情况,你可能需要:
搜索层应该知道它正在查询哪个嵌入空间。
查询正确不等于查询快。
EXPLAIN (
ANALYZE,
BUFFERS
)
SELECT
id,
title
FROM documents
ORDER BY
embedding <=>
$1::vector
LIMIT 50;
EXPLAIN (
ANALYZE,
BUFFERS
)
SELECT
id,
title
FROM documents
WHERE
search_vector @@
websearch_to_tsquery(
'english',
$1
)
LIMIT 50;
查找预期的索引行为。
用足够大的数据集来做这个测试,以使索引的使用有意义。
在小型开发表上,PostgreSQL 可能正确地选择顺序扫描,因为读取整个表更便宜。
50
60
这些不是通用值。
创建一个小型评估集。
[
{
"query":
"password recovery link expired",
"expected":
[
"doc_104",
"doc_219"
]
},
{
"query":
"ERR_AUTH_2041",
"expected":
[
"doc_442"
]
},
{
"query":
"customer cannot access account after reset",
"expected":
[
"doc_104",
"doc_331"
]
}
]
lexical only
vector only
hybrid RRF
先从简单的指标开始:
预期的文档是否
出现在前 5 名中?
可以调整的内容:
搜索质量应根据用户实际输入的查询来调优。
创建一个包含以下内容的文档:
ERR_BILLING_4092
ERR_BILLING_4092
词法侧应该强烈地检索到该文档。
invoice was charged twice after retry
即使缺少这些确切词语,向量侧也可能找到同一文档。
一个健康的混合系统应该能处理两种风格。
Usage is returned when processing fails before completion.
Do I get my credits back if the job crashes?
如果你的文本没有足够的词汇重叠,纯关键词搜索可能难以应对。
语义侧可以建立以下连接:
credits back
↔
usage returned
job crashes
↔
processing fails
词法分支在出现精确的产品术语时仍然有帮助。
RRF 让两者都能贡献,而不必将它们的原始分数强行映射到同一尺度。
如果此搜索服务于 RAG 工作流,请保持各阶段清晰可见:
user query
↓
hybrid retrieval
↓
top documents
↓
optional reranker
↓
LLM context
↓
generated answer
不要仅凭最终 LLM 回答听起来是否好来判断检索质量。
模型有时可以从较弱的检索结果中生成令人信服的回答。
记录搜索层返回的文档。
当回答失败时,你就有地方可以调查。
在发布此搜索路径之前,我会检查以下方面。
query
lexical candidate IDs
semantic candidate IDs
final IDs
latency
是否在不过度存储敏感用户文本的情况下记录了必要信息?
你是否有多个已知预期文档的查询?
在更改以下内容时运行它们:
embedding model
text configuration
RRF parameters
index settings
document chunking
混合检索很有用,因为用户的搜索是杂乱的。
有时他们知道确切的术语。
有时他们只记得概念。
有时他们粘贴错误代码。
有时他们用与文档完全不同的词语描述问题。
PostgreSQL 已经为我们提供了强大的词法搜索。
pgvector 无需额外的向量数据库即可实现语义检索。
RRF 为两份排名列表提供了一种简单的融合方式。
对于已经使用 PostgreSQL 的产品而言,这可以是一个非常实用的起点,适用于:
内部知识搜索
AI 检索工作流
策略和文档搜索
从一个小型评估集开始。
观察词法搜索在哪些场景胜出。
观察向量搜索在哪些场景胜出。
然后让混合排名证明自己的价值。
pgvector: Hybrid search, HNSW, filtering, and iterative scans
PostgreSQL: Controlling Text Search
PostgreSQL: Text Search Functions and Operators
本文在发布前已对照当前 PostgreSQL 和 pgvector 文档,核对了 PostgreSQL 函数、pgvector 操作符、当前 pgvector 版本、HNSW 行为、过滤指南以及混合搜索推荐。SQL 和 Node.js 示例均为实现示例,应针对应用程序所使用的 PostgreSQL 版本、嵌入模型、schema 和工作负载进行测试。
如需进一步行动,你可以考虑屏蔽此人或举报滥用行为。