PostgreSQL BM25 全文搜索深度解析:pg_search 與 pg_textsearch
0. 前言與生成說明
本文深入解析如何在 PostgreSQL 中實現現代搜索引擎級別的 BM25 排名搜索。
文章目標
讀完本文,你將理解:
- BM25 算法的數學原理與實現
- PostgreSQL 原生全文搜索的限制
- pg_search (ParadeDB) 的架構設計
- pg_textsearch (Timescale) 的實現方式
- 如何選擇適合的搜索方案
- 混合搜索(BM25 + 向量)的實現
1. 為什麼需要 BM25?
1.1 PostgreSQL 原生全文搜索的限制
PostgreSQL 內建的全文搜索使用 tsvector 和 ts_rank,但有明顯限制:
| 特性 | PostgreSQL 原生 | BM25 擴展 |
|---|---|---|
| 排名算法 | 僅考慮 TF 和文檔長度 | 完整 BM25(TF + IDF + 長度正規化) |
| 詞頻權重 | 簡單計數 | 飽和函數 |
| 稀有詞處理 | 無 IDF 支援 | 完整 IDF 計算 |
| 性能 | 需全表掃描計算排名 | 索引原生支援排名 |
| 實時性 | 即時 | 即時(後台索引更新) |
1.2 BM25 的優勢
BM25 (Best Matching 25) 是現代搜索引擎(Elasticsearch、Solr)的標準排名算法:
flowchart LR
subgraph BM25["BM25 權重因素"]
TF["詞頻 (TF)<br/>文檔內出現次數"]
IDF["逆文檔頻率 (IDF)<br/>詞的稀有程度"]
DL["文檔長度正規化<br/>偏好短文檔"]
end
TF --> SCORE["BM25 分數"]
IDF --> SCORE
DL --> SCORE
2. BM25 算法深度解析
2.1 BM25 公式
BM25 的核心公式:
其中:
- :查詢,包含詞項
- :文檔
- :詞項 在文檔 中的頻率
- :文檔 的長度(詞數)
- :所有文檔的平均長度
- :詞頻飽和參數(通常 1.2-2.0)
- :長度正規化參數(通常 0.75)
2.2 IDF 計算
逆文檔頻率 (Inverse Document Frequency):
其中:
- :總文檔數
- :包含詞項 的文檔數
import math
def calculate_idf(total_docs: int, docs_with_term: int) -> float:
"""計算逆文檔頻率"""
return math.log(
(total_docs - docs_with_term + 0.5) /
(docs_with_term + 0.5) + 1
)
# 範例:10000 篇文檔中,"PostgreSQL" 出現在 100 篇
idf_postgresql = calculate_idf(10000, 100) # ≈ 4.60
# "the" 出現在 9000 篇
idf_the = calculate_idf(10000, 9000) # ≈ 0.05
2.3 詞頻飽和
BM25 的詞頻項使用 飽和函數,避免高頻詞過度主導:
xychart-beta
title "BM25 詞頻飽和曲線 (k1=1.2)"
x-axis "詞頻 (tf)" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y-axis "權重" 0 --> 1.2
line [0, 0.55, 0.73, 0.82, 0.88, 0.91, 0.94, 0.96, 0.97, 0.98, 0.99]
def tf_saturation(tf: int, k1: float = 1.2) -> float:
"""BM25 詞頻飽和函數"""
return (tf * (k1 + 1)) / (tf + k1)
# tf=1: 0.545, tf=5: 0.909, tf=100: 0.988
# 高頻收益遞減
2.4 長度正規化
短文檔獲得更高權重:
def length_normalization(doc_len: int, avg_len: float, b: float = 0.75) -> float:
"""文檔長度正規化因子"""
return 1 - b + b * (doc_len / avg_len)
# 短文檔 (100 詞,平均 500):0.40 → 提升權重
# 長文檔 (1000 詞,平均 500):1.75 → 降低權重
2.5 完整 BM25 實現
from collections import defaultdict
import math
class BM25:
def __init__(self, documents: list[list[str]], k1: float = 1.2, b: float = 0.75):
self.k1 = k1
self.b = b
self.documents = documents
self.N = len(documents)
# 計算文檔長度統計
self.doc_lens = [len(doc) for doc in documents]
self.avgdl = sum(self.doc_lens) / self.N
# 構建倒排索引並計算 DF
self.doc_freqs = defaultdict(int) # 詞 → 包含該詞的文檔數
self.term_freqs = [] # 每個文檔的詞頻字典
for doc in documents:
tf = defaultdict(int)
seen_terms = set()
for term in doc:
tf[term] += 1
if term not in seen_terms:
self.doc_freqs[term] += 1
seen_terms.add(term)
self.term_freqs.append(tf)
def idf(self, term: str) -> float:
n = self.doc_freqs.get(term, 0)
return math.log((self.N - n + 0.5) / (n + 0.5) + 1)
def score(self, query: list[str], doc_idx: int) -> float:
score = 0.0
doc_len = self.doc_lens[doc_idx]
tf_dict = self.term_freqs[doc_idx]
for term in query:
if term not in tf_dict:
continue
tf = tf_dict[term]
idf = self.idf(term)
# BM25 公式
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
score += idf * (numerator / denominator)
return score
def search(self, query: list[str], top_k: int = 10) -> list[tuple[int, float]]:
scores = [(i, self.score(query, i)) for i in range(self.N)]
scores.sort(key=lambda x: -x[1])
return scores[:top_k]
3. PostgreSQL BM25 擴展概覽
3.1 主要方案對比
| 擴展 | 開發者 | 底層引擎 | 語言 | 授權 |
|---|---|---|---|---|
| pg_search | ParadeDB | Tantivy | Rust | AGPL |
| pg_textsearch | Timescale/Tiger Data | 自研 | Rust | PostgreSQL |
3.2 架構對比
flowchart TB
subgraph pg_search["pg_search (ParadeDB)"]
PS_INDEX["BM25 Index<br/>(Tantivy)"]
PS_PGRX["pgrx<br/>Rust ↔ Postgres"]
PS_PG["PostgreSQL"]
PS_PG --> PS_PGRX --> PS_INDEX
end
subgraph pg_textsearch["pg_textsearch (Timescale)"]
PT_INDEX["BM25 Index<br/>(自研 LSM)"]
PT_VEC["pgvector<br/>向量搜索"]
PT_PG["PostgreSQL"]
PT_PG --> PT_INDEX
PT_PG --> PT_VEC
end
4. pg_search (ParadeDB) 深度解析
4.1 架構設計
pg_search 建立在 Tantivy(Rust 實現的 Lucene 替代品)之上:
flowchart TB
subgraph PostgreSQL["PostgreSQL Backend"]
TABLE["Heap Table<br/>用戶資料"]
INDEX["BM25 Index<br/>(Custom AM)"]
BGWORKER["Background Worker<br/>索引寫入"]
end
subgraph Tantivy["Tantivy Engine"]
INVERTED["倒排索引"]
COLUMNAR["列式存儲<br/>(Fast Fields)"]
SEGMENT["Segment Files"]
end
TABLE -->|"INSERT/UPDATE/DELETE"| BGWORKER
BGWORKER --> Tantivy
INDEX --> Tantivy
INVERTED --> SEGMENT
COLUMNAR --> SEGMENT
4.2 源代碼結構
paradedb/pg_search/src/
├── lib.rs # 擴展入口點
├── api/ # SQL 函數與運算符
├── index/ # 索引管理
│ ├── reader/ # 索引讀取
│ ├── writer/ # 索引寫入
│ ├── search.rs # 搜索邏輯
│ └── merge_policy.rs # 段合併策略
├── query/ # 查詢處理
│ ├── builder.rs # 查詢構建器
│ ├── pdb_query.rs # 自定義查詢類型
│ └── score.rs # 評分邏輯
├── postgres/ # PostgreSQL 整合
│ ├── customscan/ # 自定義掃描
│ └── options.rs # 索引選項
└── schema/ # Schema 定義
4.3 核心組件
4.3.1 索引創建
-- 創建 BM25 索引
CREATE INDEX search_idx ON products
USING bm25 (id, description, category, rating)
WITH (key_field='id');
對應的 Rust 代碼(簡化):
// src/index/writer/mod.rs
pub fn create_index(
relation: &PgSearchRelation,
schema: &Schema,
) -> Result<Index> {
// 創建 Tantivy 索引
let index_dir = relation.index_directory()?;
let index = Index::open_or_create(index_dir, schema.clone())?;
// 設置 tokenizer
setup_tokenizers(relation, &mut index)?;
// 設置段合併策略
let merge_policy = LogMergePolicy::default();
index.set_merge_policy(Box::new(merge_policy));
Ok(index)
}
4.3.2 查詢執行
-- 使用 @@@ 運算符進行搜索
SELECT id, paradedb.score(id)
FROM products
WHERE products @@@ 'description:keyboard OR category:electronics'
ORDER BY paradedb.score(id) DESC
LIMIT 10;
查詢處理流程:
sequenceDiagram
participant User as 用戶
participant PG as PostgreSQL
participant PDB as pg_search
participant TT as Tantivy
User->>PG: SELECT ... WHERE @@@ 'query'
PG->>PDB: 解析 @@@ 運算符
PDB->>PDB: 構建 PdbQuery
PDB->>TT: 執行搜索
TT->>TT: BM25 評分
TT->>PDB: 返回 (docid, score)
PDB->>PG: 返回結果集
PG->>User: 查詢結果
4.3.3 評分函數
// src/query/score.rs
#[pg_extern]
pub fn score(key_field: AnyElement) -> f32 {
// 從當前執行上下文獲取分數
let state = current_scan_state();
state.get_score(key_field)
}
4.4 後台索引寫入
pg_search 使用 後台工作進程 處理索引更新:
// src/lib.rs
unsafe extern "C-unwind" fn _PG_init() {
// 註冊後台工作進程
if !pg_sys::process_shared_preload_libraries_in_progress {
error!("pg_search must be loaded via shared_preload_libraries");
}
// 註冊自定義掃描
customscan::register_rel_pathlist(customscan::pdbscan::PdbScan);
}
flowchart LR
subgraph Transaction["用戶事務"]
INSERT["INSERT"]
UPDATE["UPDATE"]
DELETE["DELETE"]
end
subgraph WAL["Write-Ahead Log"]
WALLOG["WAL 記錄"]
end
subgraph BGWorker["Background Worker"]
CONSUME["消費 WAL"]
INDEXWRITE["寫入 Tantivy"]
end
INSERT --> WALLOG
UPDATE --> WALLOG
DELETE --> WALLOG
WALLOG --> CONSUME
CONSUME --> INDEXWRITE
4.5 查詢語法
pg_search 支援 Elasticsearch 風格的查詢語法:
-- 布林查詢
WHERE products @@@ 'description:keyboard AND category:electronics'
-- 範圍查詢
WHERE products @@@ 'price:[100 TO 500]'
-- 模糊查詢
WHERE products @@@ 'description:keybord~1'
-- 短語查詢
WHERE products @@@ 'description:"wireless keyboard"'
-- JSON 欄位查詢
WHERE products @@@ 'metadata.color:white'
4.6 混合搜索
結合 BM25 與向量搜索:
-- 創建向量欄位
ALTER TABLE products ADD COLUMN embedding vector(384);
-- 混合搜索(BM25 + 向量)
SELECT id,
paradedb.score(id) AS bm25_score,
1 - (embedding <=> query_vector) AS vector_score
FROM products
WHERE products @@@ 'description:laptop'
ORDER BY 0.5 * paradedb.score(id) +
0.5 * (1 - (embedding <=> query_vector)) DESC
LIMIT 10;
5. pg_textsearch (Timescale) 深度解析
5.1 設計目標
pg_textsearch 是 Timescale/Tiger Data 開發的 BM25 擴展,專注於:
- AI 應用:與 pgvector 深度整合
- 混合搜索:關鍵詞 + 向量一站式解決
- 操作簡單:無需外部依賴
5.2 核心特性
| 特性 | 描述 |
|---|---|
| 真正的 BM25 | 完整的 IDF 計算 |
| 實時索引 | 寫入即可查詢 |
| LSM 存儲 | 優化寫入性能 |
| 向量整合 | 與 pgvectorscale 協同 |
5.3 使用範例
-- 創建表
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT
);
-- 創建 BM25 索引
CREATE INDEX doc_search_idx ON documents
USING bm25 (title, content);
-- 搜索
SELECT id, title,
ts_rank_bm25(document, 'PostgreSQL search') AS score
FROM documents
WHERE document @@ to_tsquery('PostgreSQL & search')
ORDER BY score DESC
LIMIT 10;
5.4 與 pgvector 整合
-- 向量 + BM25 混合搜索
SELECT id, title,
0.5 * bm25_score + 0.5 * (1 - vector_distance) AS hybrid_score
FROM (
SELECT id, title,
ts_rank_bm25(document, 'machine learning') AS bm25_score,
embedding <-> query_embedding AS vector_distance
FROM documents
WHERE document @@ to_tsquery('machine & learning')
OR embedding <-> query_embedding < 0.5
) subquery
ORDER BY hybrid_score DESC
LIMIT 10;
6. 實現對比
6.1 索引結構對比
flowchart TB
subgraph pg_search_idx["pg_search 索引結構"]
direction TB
TANTIVY_SEG["Tantivy Segments"]
INV1["倒排索引"]
FAST1["Fast Fields<br/>(列式)"]
TANTIVY_SEG --> INV1
TANTIVY_SEG --> FAST1
end
subgraph pg_textsearch_idx["pg_textsearch 索引結構"]
direction TB
LSM["LSM Tree"]
INV2["倒排索引"]
BM25_STATS["BM25 統計"]
LSM --> INV2
LSM --> BM25_STATS
end
6.2 性能特性
| 場景 | pg_search | pg_textsearch |
|---|---|---|
| 小資料集 (<100K) | 優 | 優 |
| 中等資料集 (100K-10M) | 優 | 良 |
| 大資料集 (>10M) | 良(需調優) | 待驗證 |
| 高寫入負載 | 良(後台處理) | 優(LSM) |
| 複雜查詢 | 優(Tantivy) | 良 |
| 混合搜索 | 優 | 優 |
6.3 選擇建議
flowchart TB
START["選擇 BM25 擴展"]
Q1{"需要 Elasticsearch<br/>風格查詢語法?"}
Q2{"主要用於<br/>AI/向量混合?"}
Q3{"偏好<br/>成熟穩定?"}
PG_SEARCH["pg_search<br/>(ParadeDB)"]
PG_TEXTSEARCH["pg_textsearch<br/>(Timescale)"]
START --> Q1
Q1 -->|是| PG_SEARCH
Q1 -->|否| Q2
Q2 -->|是| PG_TEXTSEARCH
Q2 -->|否| Q3
Q3 -->|是| PG_SEARCH
Q3 -->|否| PG_TEXTSEARCH
7. 與原生全文搜索的整合
7.1 PostgreSQL 原生 vs BM25 擴展
-- PostgreSQL 原生全文搜索
SELECT id, ts_rank(to_tsvector(content), to_tsquery('PostgreSQL')) AS rank
FROM documents
WHERE to_tsvector(content) @@ to_tsquery('PostgreSQL')
ORDER BY rank DESC;
-- pg_search BM25
SELECT id, paradedb.score(id) AS rank
FROM documents
WHERE documents @@@ 'content:PostgreSQL'
ORDER BY rank DESC;
-- 效能差異:BM25 擴展使用索引計算排名,原生需全表掃描
7.2 遷移策略
flowchart LR
subgraph Before["遷移前"]
TSVECTOR["tsvector + GIN"]
TS_RANK["ts_rank()"]
end
subgraph After["遷移後"]
BM25_IDX["BM25 Index"]
SCORE["paradedb.score()"]
end
Before -->|"保留原索引"| After
Before -->|"逐步切換查詢"| After
8. 實戰案例
8.1 電商產品搜索
-- 創建產品表
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
description TEXT,
category TEXT,
price NUMERIC,
rating FLOAT
);
-- 創建 BM25 索引
CREATE INDEX products_search_idx ON products
USING bm25 (id, name, description, category)
WITH (key_field='id');
-- 帶 facet 的搜索
SELECT
id,
name,
category,
paradedb.score(id) AS relevance
FROM products
WHERE products @@@ 'description:wireless AND category:electronics'
ORDER BY relevance DESC
LIMIT 20;
8.2 日誌分析
-- 日誌表
CREATE TABLE logs (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ,
level TEXT,
message TEXT,
metadata JSONB
);
-- 創建索引(包含 JSON 欄位)
CREATE INDEX logs_search_idx ON logs
USING bm25 (id, message, metadata)
WITH (key_field='id');
-- 搜索錯誤日誌
SELECT id, timestamp, message,
paradedb.score(id) AS relevance
FROM logs
WHERE logs @@@ 'level:ERROR AND message:connection'
AND timestamp > NOW() - INTERVAL '1 day'
ORDER BY relevance DESC
LIMIT 50;
8.3 RAG 應用(混合搜索)
-- 知識庫表
CREATE TABLE knowledge_base (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT,
embedding vector(1536), -- OpenAI embedding
source TEXT,
updated_at TIMESTAMPTZ
);
-- 創建索引
CREATE INDEX kb_bm25_idx ON knowledge_base
USING bm25 (id, title, content)
WITH (key_field='id');
CREATE INDEX kb_vector_idx ON knowledge_base
USING hnsw (embedding vector_cosine_ops);
-- RAG 混合搜索函數
CREATE OR REPLACE FUNCTION hybrid_search(
query_text TEXT,
query_embedding vector(1536),
bm25_weight FLOAT DEFAULT 0.5,
top_k INT DEFAULT 10
) RETURNS TABLE (
id INT,
title TEXT,
content TEXT,
hybrid_score FLOAT
) AS $$
BEGIN
RETURN QUERY
SELECT
kb.id,
kb.title,
kb.content,
(bm25_weight * COALESCE(paradedb.score(kb.id), 0) +
(1 - bm25_weight) * (1 - (kb.embedding <=> query_embedding))) AS hybrid_score
FROM knowledge_base kb
WHERE kb @@@ format('content:%s', query_text)
OR kb.embedding <=> query_embedding < 0.8
ORDER BY hybrid_score DESC
LIMIT top_k;
END;
$$ LANGUAGE plpgsql;
9. 性能調優
9.1 索引配置
-- pg_search 索引配置
CREATE INDEX products_idx ON products
USING bm25 (id, name, description)
WITH (
key_field = 'id',
-- Tantivy 配置
writer_memory = '128MB', -- 索引寫入記憶體
max_positions = 10000, -- 每文檔最大位置數
indexed = true -- 是否建立倒排索引
);
9.2 查詢優化
-- 使用 EXPLAIN ANALYZE 分析
EXPLAIN ANALYZE
SELECT id, paradedb.score(id)
FROM products
WHERE products @@@ 'description:laptop'
ORDER BY paradedb.score(id) DESC
LIMIT 10;
-- 優化技巧
-- 1. 限制返回欄位
-- 2. 使用 LIMIT
-- 3. 避免複雜的 JOIN
-- 4. 考慮物化視圖
9.3 批量寫入優化
-- 批量插入時暫時禁用即時索引
BEGIN;
SET LOCAL pg_search.enable_realtime_index = false;
INSERT INTO products (name, description)
SELECT name, description FROM staging_products;
COMMIT;
-- 索引會在事務提交後異步更新
10. 總結
核心要點
| 方面 | 說明 |
|---|---|
| BM25 優勢 | 完整的 TF-IDF + 長度正規化,優於原生 ts_rank |
| pg_search | 基於 Tantivy,功能豐富,查詢語法強大 |
| pg_textsearch | 專注 AI 場景,與 pgvector 深度整合 |
| 選擇建議 | 複雜搜索選 pg_search,AI 場景選 pg_textsearch |