关于 postgres-hybrid-text-search
介绍在 PostgreSQL 中结合 BM25 关键词搜索与 pgvector 语义搜索的指南。内容涵盖 Reciprocal Rank Fusion、结果加权、可选重排及混合检索调优。
主要功能
- 01结合 BM25 与向量搜索
- 02使用 RRF 融合排名
- 03调整关键词与语义结果权重
- 04可选地重排候选结果
- 05检查索引和查询性能
适用场景
- 01检索同时匹配关键词与意图的文档
- 02平衡精确匹配和语义结果
- 03对检索候选集进行重排
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ timescale/ pg-aiguide --skill postgres-hybrid-text-search安装后在 Claude Code 中输入/postgres-hybrid-text-search确认已加载。
详细介绍
混合文本搜索
混合搜索将关键词搜索(BM25)与语义搜索(向量嵌入)结合起来,兼得两者的优势:精确匹配关键词和基于含义的检索。使用倒数排名融合(RRF)将两种方法的结果合并为一个排序列表。
本指南介绍如何结合使用 pg_textsearch(BM25)和 pgvector。需要安装这两个扩展。对于高流量配置、筛选或高级 pgvector 调优(二进制量化、HNSW 参数),请参阅 pgvector-semantic-search 技能。
pg_textsearch 是 PostgreSQL 的一款新型 BM25 文本搜索扩展,完全开源,可托管在 Tiger Cloud 上,也可用于自托管部署。它提供真正的 BM25 排名,与 PostgreSQL 内置的 ts_rank 相比,通常能提高相关性,并可在大规模场景下提供更好的性能。注意:pg_textsearch 目前处于预发布阶段,尚不建议用于生产环境。pg_textsearch 目前支持 PostgreSQL 17 和 18。
何时使用混合搜索
- 使用混合搜索:查询同时包含特定词语(产品名称、代码、专有名词)和概念意图
- 仅使用语义搜索:含义比精确措辞更重要(例如,“如何修复慢查询”应匹配“查询优化”)
- 仅使用关键词搜索:精确匹配至关重要(例如,错误代码、SKU、法律引文)
与单独使用任一方法相比,混合搜索通常能提高召回率,但会略微增加复杂度。
数据准备
将文档切分成较小的片段(通常为 500–1000 个 token),并为每个片段存储其嵌入向量。BM25 和语义搜索都基于相同的片段进行,这样可简化融合,因为比较的是同类结果。
黄金路径(默认设置)
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_textsearch;
-- Table with both indexes
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
content TEXT NOT NULL,
embedding halfvec(1536) NOT NULL
);
-- BM25 index for keyword search
CREATE INDEX ON documents USING bm25 (content) WITH (text_config = 'english');
-- HNSW index for semantic search
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops);
BM25 注意事项
- 负分数:
<@>运算符返回负值,值越低表示匹配越好。RRF 使用排名位置,因此这不会影响融合。 - 语言配置:更改
text_config以匹配内容语言(例如,'french'、'german')。请参阅 PostgreSQL 文本搜索配置。 - 调优:BM25 有
k1(词频饱和度,默认值为 1.2)和b(长度归一化,默认值为 0.75)参数。默认值效果良好;只有在相关性较差时才需要调优。CREATE INDEX ON documents USING bm25 (content) WITH (text_config = 'english', k1 = 1.5, b = 0.8); - 分区表:每个分区都维护本地统计信息。不同分区之间的分数无法直接比较——需要比较分数时,请分别查询各个分区。
RRF 查询模式
倒数排名融合会合并多个搜索的排名。每条结果的分数为 1 / (k + rank),其中 k 是一个常数(通常为 60)。将各次搜索的分数相加后重新排序。
请在客户端并行运行两个查询,以降低延迟,然后在客户端融合结果:
-- Query 1: Keyword search (BM25)
-- $1: search text
SELECT id, content FROM documents ORDER BY content <@> $1 LIMIT 50;
-- Query 2: Semantic search (separate query, run in parallel)
-- $1: embedding of your search text as halfvec(1536)
SELECT id, content FROM documents ORDER BY embedding <=> $1::halfvec(1536) LIMIT 50;
# Client-side RRF fusion (Python)
def rrf_fusion(keyword_results, semantic_results, k=60, limit=10):
scores = {}
content_map = {}
for rank, row in enumerate(keyword_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + 1 / (k + rank)
content_map[row['id']] = row['content']
for rank, row in enumerate(semantic_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + 1 / (k + rank)
content_map[row['id']] = row['content']
sorted_ids = sorted(scores, key=scores.get, reverse=True)[:limit]
return [{'id': id, 'content': content_map[id], 'score': scores[id]} for id in sorted_ids]
// Client-side RRF fusion (TypeScript)
type Row = { id: number; content: string };
type Result = Row & { score: number };
function rrfFusion(keywordResults: Row[], semanticResults: Row[], k = 60, limit = 10): Result[] {
const scores = new Map<number, number>();
const contentMap = new Map<number, string>();
keywordResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + 1 / (k + i + 1));
contentMap.set(row.id, row.content);
});
semanticResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + 1 / (k + i + 1));
contentMap.set(row.id, row.content);
});
return [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([id, score]) => ({ id, content: contentMap.get(id)!, score }));
}
RRF 参数
| 参数 | 默认值 | 说明 |
|---|---|---|
k | 60 | 平滑常数。值越大,排名差异越小;60 是标准值 |
| 每次搜索的候选项数 | 50 | 值越高,召回率越好,但计算量也越大 |
| 最终限制数量 | 10 | 融合后返回的结果数 |
如果遗漏了相关结果,请增加候选项数量。k=60 这个常数通常无需调优。
调整关键词与语义搜索的权重
若要提高某种方法的权重,可将其 RRF 贡献乘以相应系数:
# Weight semantic search 2x higher than keyword
keyword_weight = 1.0
semantic_weight = 2.0
for rank, row in enumerate(keyword_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + keyword_weight / (k + rank)
for rank, row in enumerate(semantic_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + semantic_weight / (k + rank)
// Weight semantic search 2x higher than keyword
const keywordWeight = 1.0;
const semanticWeight = 2.0;
keywordResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + keywordWeight / (k + i + 1));
});
semanticResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + semanticWeight / (k + i + 1));
});
建议从两种方法的权重均为 1.0 开始,再根据测得的相关性进行调整。
使用机器学习模型进行重排
为获得最高质量,可添加使用交叉编码器模型的重排步骤。交叉编码器(例如 cross-encoder/ms-marco-MiniLM-L-6-v2)比双编码器更准确,但速度太慢,不适合初始检索——仅对候选集使用。
使用与上文相同的并行查询,并提高 LIMIT(例如 100),然后:
# 1. Fuse results with RRF (more candidates for reranking)
candidates = rrf_fusion(keyword_results, semantic_results, limit=100)
# 2. Rerank with cross-encoder
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
pairs = [(query_text, doc['content']) for doc in candidates]
scores = reranker.predict(pairs)
# 3. Return top 10 by reranker score
reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:10]
import { CohereClientV2 } from 'cohere-ai';
// 1. Fuse results with RRF (more candidates for reranking)
const candidates = rrfFusion(keywordResults, semanticResults, 60, 100);
// 2. Rerank via API (example uses Cohere SDK; Jina, Voyage, and others work similarly)
const cohere = new CohereClientV2({ token: COHERE_API_KEY });
const reranked = await cohere.rerank({
model: 'rerank-v3.5',
query: queryText,
documents: candidates.map(c => c.content),
topN: 10
});
// 3. Map back to original documents
const results = reranked.results.map(r => candidates[r.index]);
重排是可选的——仅使用混合 RRF 就能显著提升效果,优于单一搜索方法。
性能注意事项
- 为两列都创建索引:在文本上创建 BM25 索引,在嵌入上创建 HNSW 索引
- 限制候选池:每种方法通常有 50–100 个候选项就足够了
- 并行运行查询:与顺序执行相比,客户端并行处理可以降低延迟
- 监控延迟:混合搜索会增加开销;确保两个索引都能放入内存
使用 pgvectorscale 扩展
对于大型数据集(1000 万个以上的向量)或带有选择性元数据筛选条件的工作负载,可考虑使用 pgvectorscale 的 StreamingDiskANN 索引替代 HNSW,用于语义搜索部分。
适用 StreamingDiskANN 的场景:
- HNSW 无法放入内存的大型数据集
- 按标签筛选的查询(例如 tenant_id、category、tags)
- 需要高性能筛选式向量搜索
**基于标签的筛选:**StreamingDiskANN 支持在 smallint[] 标签列上创建筛选索引。标签会与向量一起建立索引,从而实现高效的筛选式搜索,避免后置筛选造成的准确率损失。
-- Enable pgvectorscale (in addition to pgvector)
CREATE EXTENSION IF NOT EXISTS vectorscale;
-- Table with label column for filtering
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
content TEXT NOT NULL,
embedding halfvec(1536) NOT NULL,
labels smallint[] NOT NULL -- e.g., category IDs, tenant IDs
);
-- StreamingDiskANN index with label filtering
CREATE INDEX ON documents USING diskann (embedding vector_cosine_ops, labels);
-- BM25 index for keyword search
CREATE INDEX ON documents USING bm25 (content) WITH (text_config = 'english');
-- Filtered semantic search using && (array overlap)
SELECT id, content FROM documents
WHERE labels && ARRAY[1, 3]::smallint[]
ORDER BY embedding <=> $1::halfvec(1536) LIMIT 50;
有关筛选索引和调优参数的更多详情,请参阅 pgvectorscale 文档。
监控与调试
-- Force index usage for verification (planner may prefer seqscan on small tables)
SET enable_seqscan = off;
-- Verify BM25 index is used
EXPLAIN SELECT id, content FROM documents ORDER BY content <@> 'search text' LIMIT 10;
-- Look for: Index Scan using ... (bm25)
-- Verify HNSW index is used
EXPLAIN SELECT id, content FROM documents ORDER BY embedding <=> '[0.1, 0.2, ...]'::halfvec(1536) LIMIT 10;
-- Look for: Index Scan using ... (hnsw)
SET enable_seqscan = on; -- Re-enable for normal operation
-- Check index sizes
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes WHERE tablename = 'documents';
如果在设置 enable_seqscan = off 后,EXPLAIN 仍显示顺序扫描,请确认索引存在,并且查询使用了正确的运算符(BM25 使用 <@>,余弦相似度使用 <=>)。有关 pgvector 调试的更多指导,请参阅 pgvector-semantic-search 技能。
常见问题
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 缺少精确匹配 | 关键词搜索未返回这些结果 | 检查 BM25 索引是否存在;确认 text_config 与内容语言相匹配 |
| 语义搜索结果不佳 | 嵌入模型不匹配 | 确保查询嵌入使用与已存储嵌入相同的模型 |
| 查询缓慢 | 候选池过大或缺少索引 | 减小内层 LIMIT;确认两个索引都存在且正在使用(EXPLAIN) |
| 结果偏斜 | 一种方法占主导 | 调整 RRF 权重;确认两种搜索都能返回数量合理的候选项 |