全文搜尋:PostgreSQL 內建的文字搜尋引擎 | PostgreSQL

2026/06/27
全文搜尋:PostgreSQL 內建的文字搜尋引擎 | PostgreSQL

全文搜尋(Full-Text Search)PostgreSQL 內建的文字搜尋引擎,透過 tsvectortsquery 兩種特殊資料型別,將原始文字轉換為正規化的詞素(lexeme),再以布林或相鄰運算進行高效匹配。無需 Elasticsearch 等外部服務,即可在資料庫內實現生產級的搜尋功能。

全文搜尋三階段處理流程

PostgreSQL 全文搜尋的核心是一個三階段的文字處理管線:

輸入文字: "The quick brown foxes jumped over lazy dogs"
          │
          ▼
  階段一:Tokenize(斷詞)
  Parser 將文字拆解為 Token 並分類
  結果: "The" "quick" "brown" "foxes" "jumped" "over" "lazy" "dogs"
          │
          ▼
  階段二:Normalize(正規化)
  Dictionary 對每個 Token 處理:
  • 詞幹化(Stemming):foxes → fox
  • 停用詞移除:the, over → 捨棄
  • 詞形還原:jumped → jump
          │
          ▼
  階段三:Lexeme(詞素輸出)
  產生排序的 lexeme 列表存入 tsvector
  結果: 'brown':3 'dog':8 'fox':4 'jump':5 'lazi':7 'quick':2

每個詞素保留了原始位置資訊(數字),用於片語搜尋與排名計算。

tsvector 內部結構

tsvector 是一個排序的詞素列表,每個詞素附帶位置資訊與權重標記:

-- 基本轉換
SELECT to_tsvector('english', 'The quick brown fox jumped over the lazy dog');
-- 結果: 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2

-- 帶權重的 tsvector(A=最高, B, C, D=最低)
SELECT setweight(to_tsvector('english', 'PostgreSQL Full-Text Search'), 'A')
    || setweight(to_tsvector('english', 'Database search engine'), 'B');
-- 結果: 'databas':4B 'engin':6B 'full':2A 'postgresql':1A 'search':3A,5B 'text':3A

-- tsvector 特性:
-- 1. 詞素按字母序排列
-- 2. 相同詞素的多個位置合併為一個條目
-- 3. 位置範圍 1-16383,用於片語搜尋
-- 4. 權重 A/B/C/D 用於 ts_rank 排名計算

tsquery 查詢語法

tsquery 儲存搜尋條件的詞素及布林關係:

-- 布林運算子
SELECT 'fat & rat'::tsquery;          -- AND:兩者都必須出現
SELECT 'fat | rat'::tsquery;          -- OR:任一出現即可
SELECT '!fat'::tsquery;               -- NOT:不得出現
SELECT 'fat & !rat'::tsquery;         -- 組合:有 fat 但無 rat

-- 相鄰運算子 <->(片語搜尋)
SELECT 'full <-> text'::tsquery;      -- full 緊接著 text(距離 1)
SELECT 'fat <2> rat'::tsquery;        -- fat 與 rat 之間相隔 1 個詞

-- 前綴搜尋
SELECT 'super:*'::tsquery;            -- 匹配所有以 super 開頭的詞素

-- 匹配運算子 @@
SELECT to_tsvector('english', 'The quick brown fox')
    @@ to_tsquery('english', 'fox & quick');
-- 結果: true

四種 tsquery 建構函式

函式輸入範例行為
to_tsquery'fat & rat'嚴格語法,支援所有運算子
plainto_tsquery'fat rat'自然語言,空格自動視為 AND
phraseto_tsquery'fat rat'片語搜尋,要求詞素按順序相鄰
websearch_to_tsquery'fat -rat "big cat"'Web 風格(PG11+),支援 -、引號、or
-- plainto_tsquery — 適合使用者搜尋框輸入
SELECT plainto_tsquery('english', 'postgresql full text search');
-- 結果: 'postgresql' & 'full' & 'text' & 'search'

-- phraseto_tsquery — 精確片語搜尋
SELECT phraseto_tsquery('english', 'full text search');
-- 結果: 'full' <-> 'text' <-> 'search'

-- websearch_to_tsquery — 最適合 Web 搜尋場景(PG11+)
SELECT websearch_to_tsquery('english', 'postgresql -mysql "full text"');
-- 結果: 'postgresql' & !'mysql' & 'full' <-> 'text'

建立全文搜尋欄位

方法一:Generated Column(推薦)

-- PG12+ Generated Column 自動維護 tsvector
CREATE TABLE articles (
    id         SERIAL PRIMARY KEY,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    tsv        TSVECTOR GENERATED ALWAYS AS (
                   to_tsvector('english',
                       coalesce(title, '') || ' ' || coalesce(body, ''))
               ) STORED
);

-- 建立 GIN 索引
CREATE INDEX idx_articles_tsv ON articles USING GIN(tsv);

-- 搜尋(會走索引)
SELECT id, title
FROM articles
WHERE tsv @@ websearch_to_tsquery('english', 'postgresql search')
ORDER BY ts_rank(tsv, websearch_to_tsquery('english', 'postgresql search')) DESC;

方法二:Trigger(多欄位權重)

-- 標題權重 A > 內文權重 B > 標籤權重 C
CREATE TABLE articles (
    id    SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    body  TEXT NOT NULL,
    tags  TEXT[],
    tsv   TSVECTOR
);

CREATE OR REPLACE FUNCTION articles_tsv_trigger()
RETURNS TRIGGER AS $$
BEGIN
    NEW.tsv :=
        setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(NEW.body, '')),  'B') ||
        setweight(to_tsvector('english',
            coalesce(array_to_string(NEW.tags, ' '), '')), 'C');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trig_articles_tsv
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION articles_tsv_trigger();

CREATE INDEX idx_articles_tsv ON articles USING GIN(tsv);

-- 初次部署更新已存在資料
UPDATE articles SET tsv = tsv;

GIN vs GiST 索引

特性GINGiST
索引大小較大(精確倒排索引)較小(有損簽章摘要)
建立速度較慢較快
查詢速度快(無需回表驗證)慢(需回表過濾偽陽性)
更新成本較高較低
適用場景讀多寫少(推薦)極端空間限制
GIN 倒排索引內部結構:

  'brown' → {行ID: 1, 5, 23, 88}
  'fox'   → {行ID: 1, 5, 42}
  'jump'  → {行ID: 5, 88, 101}

  查詢 'fox & jump' → 取交集 → {5}

GiST 簽章索引(有損):

  每個葉節點儲存 tsvector 的位元圖摘要
  可能產生偽陽性 → 需回表重新驗證

結論:除非空間極度受限,否則一律使用 GIN 索引。

排名與高亮

-- ts_rank:基於詞素頻率的排名
SELECT id, title,
       ts_rank(tsv, query) AS rank
FROM articles,
     websearch_to_tsquery('english', 'postgresql search') AS query
WHERE tsv @@ query
ORDER BY rank DESC;

-- ts_rank_cd:Cover Density 排名(考慮詞素距離,通常更直觀)
SELECT id, title,
       ts_rank_cd(tsv, query, 32) AS rank  -- 32 正規化到 0~1
FROM articles,
     to_tsquery('english', 'postgresql & index') AS query
WHERE tsv @@ query
ORDER BY rank DESC;

-- ts_headline:搜尋結果關鍵字高亮
SELECT id,
       ts_headline('english', body, query,
           'StartSel=<mark>, StopSel=</mark>,
            MaxWords=35, MinWords=15,
            MaxFragments=3, FragmentDelimiter=" ... "'
       ) AS snippet
FROM articles,
     websearch_to_tsquery('english', 'postgresql search') AS query
WHERE tsv @@ query;

-- 完整搜尋結果查詢(排名 + 高亮)
WITH q AS (
    SELECT websearch_to_tsquery('english', 'postgresql full text') AS query
)
SELECT
    a.id,
    a.title,
    ts_rank_cd(a.tsv, q.query, 32) AS rank,
    ts_headline('english', a.body, q.query,
        'StartSel=<em>, StopSel=</em>, MaxFragments=3'
    ) AS snippet
FROM articles a, q
WHERE a.tsv @@ q.query
ORDER BY rank DESC
LIMIT 10;

中文搜尋

PostgreSQL 預設 Parser 不支援中文分詞(中文詞語無空格分隔),需安裝額外擴展:

-- 方案一:zhparser(基於 SCWS 分詞引擎)
CREATE EXTENSION zhparser;

CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
    ADD MAPPING FOR n, v, a, i, e, l WITH simple;
-- n=名詞, v=動詞, a=形容詞, i=成語, e=嘆詞, l=慣用語

-- 測試
SELECT to_tsvector('chinese', '自然語言處理是人工智慧的重要領域');
-- 結果: '人工智慧':4 '處理':3 '自然語言':1 '領域':6 '重要':5

-- 建立中文全文搜尋索引
ALTER TABLE articles ADD COLUMN tsv_zh TSVECTOR
    GENERATED ALWAYS AS (to_tsvector('chinese', coalesce(body, ''))) STORED;
CREATE INDEX idx_articles_tsv_zh ON articles USING GIN(tsv_zh);

-- 方案二:pg_jieba(基於結巴分詞,效果更好)
CREATE EXTENSION pg_jieba;
CREATE TEXT SEARCH CONFIGURATION jieba_cfg (PARSER = jieba);
ALTER TEXT SEARCH CONFIGURATION jieba_cfg
    ADD MAPPING FOR n, v, a, i, e, l WITH simple;

自訂字典

-- 同義詞字典
-- 檔案 my_synonyms.syn 放在 tsearch_data 目錄:
--   postgres  postgresql
--   pg        postgresql
--   rdbms     database

CREATE TEXT SEARCH DICTIONARY pg_synonyms (
    TEMPLATE = synonym,
    SYNONYMS = my_synonyms
);

-- 建立使用同義詞的配置
CREATE TEXT SEARCH CONFIGURATION my_english (COPY = english);
ALTER TEXT SEARCH CONFIGURATION my_english
    ALTER MAPPING FOR asciiword WITH pg_synonyms, english_stem;

-- 測試:搜尋 "pg" 能匹配 "postgresql"
SELECT to_tsvector('my_english', 'Learning PostgreSQL')
    @@ to_tsquery('my_english', 'pg');
-- 結果: true

-- Unaccent 擴展(去除重音符號,歐洲語言必備)
CREATE EXTENSION unaccent;
SELECT unaccent('Héllo Wörld');
-- 結果: Hello World

效能調校

-- GIN 索引的 fastupdate 設定
CREATE INDEX idx_articles_tsv ON articles USING GIN(tsv)
    WITH (fastupdate = false);
-- fastupdate=true(預設):批次更新,寫入快但偶發清理慢
-- fastupdate=false:即時更新,查詢效能更穩定

-- 強制清理 GIN pending list
SELECT gin_clean_pending_list('idx_articles_tsv'::regclass);

-- 大表優化:分區 + 部分索引
CREATE INDEX idx_articles_tsv_published ON articles USING GIN(tsv)
    WHERE status = 'published';
-- 僅索引已發布文章,更小更快

-- 分頁效能:Keyset Pagination 取代 OFFSET
-- 效能差
SELECT id, title, ts_rank(tsv, query) AS rank
FROM articles, to_tsquery('english', 'postgresql') AS query
WHERE tsv @@ query
ORDER BY rank DESC, id DESC
OFFSET 10000 LIMIT 10;

-- 效能好(Keyset Pagination)
SELECT id, title, ts_rank(tsv, query) AS rank
FROM articles, to_tsquery('english', 'postgresql') AS query
WHERE tsv @@ query
  AND (ts_rank(tsv, query), id) < (0.0832, 12345)
ORDER BY rank DESC, id DESC
LIMIT 10;

混合搜尋策略

-- 全文搜尋 + 條件過濾
SELECT id, title, ts_rank(tsv, query) AS rank
FROM articles, to_tsquery('english', 'postgresql & index') AS query
WHERE tsv @@ query
  AND created_at >= NOW() - INTERVAL '30 days'
  AND status = 'published'
ORDER BY rank DESC
LIMIT 20;

-- 結合 pg_trgm 實現模糊 + 全文混合搜尋
CREATE EXTENSION pg_trgm;

-- 拼字錯誤容錯
SELECT id, title, similarity(title, 'postgress') AS sim
FROM articles
WHERE similarity(title, 'postgress') > 0.3
ORDER BY sim DESC;

-- 雙策略:全文搜尋 OR 相似度搜尋
SELECT id, title
FROM articles
WHERE tsv @@ plainto_tsquery('english', 'postgresql')
   OR similarity(title, 'postgresql') > 0.4;

與 Elasticsearch 比較

特性PostgreSQL FTSElasticsearch
部署複雜度低(內建)高(獨立叢集 + JVM)
資料一致性強一致(ACID)最終一致(~1 秒延遲)
搜尋功能基本~中等進階(模糊、相關性調校)
JOIN 支援原生 SQL JOIN不支援
中文支援需擴展(zhparser)內建分析器
規模中小(億級以內)大規模(百億級+)
維運成本
決策流程:

  每日搜尋量 < 1 億 且 資料量 < 1 億筆
  └── 搜尋與 SQL 業務緊密結合?
      ├── 是 → PostgreSQL FTS(維運成本最低)
      └── 否 → 評估 Elasticsearch

  每日搜尋量 > 1 億 或 需要複雜搜尋特性
  └── Elasticsearch / OpenSearch

版本演進

版本全文搜尋主要改進
PG 8.3從 contrib/tsearch2 整合進核心
PG 9.6phraseto_tsquery 片語搜尋
PG 11websearch_to_tsquery Web 風格語法
PG 12Generated Column 簡化 tsvector 維護
PG 13GIN 索引效能改進
PG 14to_tsvector 直接接受 json/jsonb
PG 17排名函式效能優化

常見陷阱

忘記建索引導致全表掃描

-- 錯誤:每次查詢重新計算 tsvector
SELECT * FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'search');
-- EXPLAIN 顯示 Seq Scan

-- 正確:使用預計算欄位 + GIN 索引
SELECT * FROM articles
WHERE tsv @@ to_tsquery('english', 'search');
-- EXPLAIN 顯示 Bitmap Index Scan

搜尋配置不匹配

-- 錯誤:建索引用 english,查詢用 simple
-- english 的 'running' → 'run'
-- simple 的 'running' → 'running'
-- 兩者不匹配,無法命中!

-- 正確:建索引和查詢必須使用相同配置

特殊字元語法錯誤

-- 錯誤:to_tsquery 不接受特殊字元
-- SELECT to_tsquery('english', 'C++');  -- 語法錯誤!

-- 正確:使用 websearch_to_tsquery 或 plainto_tsquery
SELECT websearch_to_tsquery('english', 'C++');
SELECT plainto_tsquery('english', 'C++');

總結

全文搜尋 是 PostgreSQL 內建的強大文字搜尋引擎:

  • tsvector + tsquery 將文字搜尋轉為高效的詞素匹配
  • Generated Column + GIN 索引是最推薦的部署方式
  • websearch_to_tsquery 最適合 Web 搜尋框場景
  • 中文搜尋需安裝 zhparserpg_jieba 分詞擴展
  • 中小規模場景(億級以內)可完全取代 Elasticsearch,省去額外基礎設施
  • 透過 pg_trgm 可實現模糊搜尋與全文搜尋的混合策略

下一篇,我們將深入探討 自訂型別——PostgreSQL 如何讓你定義 ENUM、複合型別、Domain 與 Range 型別來擴展資料模型。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。