mcpField
P
postgres-hybrid-text-search

postgres-hybrid-text-search

github.com/timescale数据与数据库1.9k375 次安装6 天前更新
代码仓库

关于 postgres-hybrid-text-search

介绍在 PostgreSQL 中结合 BM25 关键词搜索与 pgvector 语义搜索的指南。内容涵盖 Reciprocal Rank Fusion、结果加权、可选重排及混合检索调优。

主要功能

  1. 01结合 BM25 与向量搜索
  2. 02使用 RRF 融合排名
  3. 03调整关键词与语义结果权重
  4. 04可选地重排候选结果
  5. 05检查索引和查询性能

适用场景

  1. 01检索同时匹配关键词与意图的文档
  2. 02平衡精确匹配和语义结果
  3. 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 参数
参数默认值说明
k60平滑常数。值越大,排名差异越小;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 权重;确认两种搜索都能返回数量合理的候选项

评论

支持 Markdown