PostgreSQL BM25 全文搜索深度解析:pg_search 與 pg_textsearch


0. 前言與生成說明

本文深入解析如何在 PostgreSQL 中實現現代搜索引擎級別的 BM25 排名搜索。

文章目標

讀完本文,你將理解:

  • BM25 算法的數學原理與實現
  • PostgreSQL 原生全文搜索的限制
  • pg_search (ParadeDB) 的架構設計
  • pg_textsearch (Timescale) 的實現方式
  • 如何選擇適合的搜索方案
  • 混合搜索(BM25 + 向量)的實現

1. 為什麼需要 BM25?

1.1 PostgreSQL 原生全文搜索的限制

PostgreSQL 內建的全文搜索使用 tsvectorts_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 的核心公式:

score(D,Q)=i=1nIDF(qi)f(qi,D)(k1+1)f(qi,D)+k1(1b+bDavgdl)\text{score}(D, Q) = \sum_{i=1}^{n} IDF(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{|D|}{avgdl})}

其中:

  • QQ:查詢,包含詞項 q1,q2,...,qnq_1, q_2, ..., q_n
  • DD:文檔
  • f(qi,D)f(q_i, D):詞項 qiq_i 在文檔 DD 中的頻率
  • D|D|:文檔 DD 的長度(詞數)
  • avgdlavgdl:所有文檔的平均長度
  • k1k_1:詞頻飽和參數(通常 1.2-2.0)
  • bb:長度正規化參數(通常 0.75)

2.2 IDF 計算

逆文檔頻率 (Inverse Document Frequency)

IDF(qi)=ln(Nn(qi)+0.5n(qi)+0.5+1)IDF(q_i) = \ln\left(\frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1\right)

其中:

  • NN:總文檔數
  • n(qi)n(q_i):包含詞項 qiq_i 的文檔數
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_searchParadeDBTantivyRustAGPL
pg_textsearchTimescale/Tiger Data自研RustPostgreSQL

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_searchpg_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

學習資源


參考資料