PostgreSQL — Advanced & Scale
5/7
Bài 5 / 7~13 phútJSONB, full-text & pgvectorMiễn phí lượt xem

Full-text search tiếng Việt — tsvector, unaccent, GIN

LIKE '%deploy%' quét cả bảng và không match 'deployment'; 'khan cap' không ra 'khẩn cấp'. Bài dựng FTS PostgreSQL với tsvector, GIN và config unaccent cho tiếng Việt.

TL;DR: WHERE body LIKE '%deploy%' có hai bệnh: leading wildcard vô hiệu hoá B-tree nên quét cả bảng, và nó so khớp chuỗi con nên "deployment" không match "deploy", "khan cap" không match "khẩn cấp". Full-text search của PostgreSQL chữa cả hai: to_tsvector tách document thành token (có stem với tiếng Anh), lưu vào generated column, GIN index tra ngược từ token về row, và @@ so với tsquery từ câu người dùng gõ. Với tiếng Việt, PostgreSQL không có dictionary sẵn; bạn tự tạo config vietnamese = unaccent + simple, để cả document lẫn query đều bị bỏ dấu trước khi so. Lỗi gõ sai ("dploy") thì FTS chịu, phải thêm pg_trgm.

TaskFlow vừa ra mắt search box. Product manager demo trước khách: gõ "deploy", kết quả về sau 2,8 giây và task "deployment pipeline" không xuất hiện. Gõ "khẩn cấp" thì không có kết quả, dù có chục task urgent. Gõ "khan cap" không dấu, vẫn trắng. Demo thất bại.

Query hiện tại là WHERE body LIKE '%deploy%': leading wildcard chặn mọi B-tree, và LIKE so khớp chuỗi con nguyên văn nên không biết "deployment" chứa "deploy", không biết "khẩn" và "khan" là một. Bài này xây search cho TaskFlow hoàn toàn trong PostgreSQL, không cần Elasticsearch.

1. Analogy — thư viện và bảng tra từ khoá cuối sách

Cách tìmTương đương SQLĐặc điểm
Đọc từng cuốn từ đầu tới cuối tìm chuỗi "deploy"LIKE '%deploy%' Seq ScanChính xác nhưng chậm tuyến tính với số sách
Tra bảng từ khoá cuối sách ("deploy" → trang 45, 112)FTS với GINIndex từng token, không đọc toàn bộ
Gộp "deploy", "deployed", "deployment" vào một mụcStemmingNhóm các dạng của từ về gốc
Gộp "khẩn" và "khan" vào một mụcUnaccentCó dấu và không dấu về cùng entry
Tìm cuốn chứa chuỗi "dpl" (gõ sai)pg_trgmFuzzy, chịu typo, không phải FTS
💡 Cách nhớ

tsvector là bảng tra từ khoá của một document, làm một lần, tra nhiều lần. tsquery là câu hỏi bạn tra vào bảng đó. @@ là hành động tra, trả true/false.

2. Vấn đề LIKE với 1M row

SQL
EXPLAIN ANALYZE
SELECT id, title, body FROM tasks WHERE body LIKE '%deploy%';
Seq Scan on tasks  (cost=0.00..24532.00 rows=1000 width=312)
                   (actual time=0.051..2487.312 rows=3241 loops=1)
  Filter: (body ~~ '%deploy%'::text)
  Rows Removed by Filter: 996759
Execution Time: 2531.447 ms

Leading wildcard vô hiệu hoá B-tree nên PG đọc cả 1M row. Khoá Internals đã dùng pg_trgm GIN để cứu pattern như vậy, nhưng LIKE vẫn còn hai bệnh FTS chữa tốt hơn: không tokenize ("deployment" không match "deploy") và không normalize ("khẩn cấp" không match "khan cap").

3. tsvector + tsquery — giải phẫu FTS

tsvector là document đã tiền xử lý: text gốc thành danh sách (token, vị trí). tsquery là biểu thức boolean của token.

SQL
-- tsvector: preprocessed document
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
-- stopword (the, over) bi bo; jumps -> jump, lazy -> lazi (stem); giu vi tri de rank

-- tsquery
SELECT to_tsquery('english', 'deploy | deployment');
-- 'deploy' | 'deploy'  -- ca hai stem ve 'deploy'

-- @@ : tsvector match tsquery?
SELECT to_tsvector('english', 'deploy production') @@ to_tsquery('english', 'deployment');
-- true
AspectLIKE '%x%'FTS tsvector @@ tsquery
"deployment" match "deploy"KhôngCó (stemming)
IndexKhông (leading %)GIN
Xếp hạng theo độ liên quanKhôngts_rank
Highlight đoạn matchKhôngts_headline
Bỏ dấuKhôngCó (với unaccent)

4. Hàm nào để biến câu người dùng gõ thành tsquery?

SQL
-- plainto_tsquery: AND moi token, khong parse operator
SELECT plainto_tsquery('english', 'deploy production');
-- 'deploy' & 'product'

-- websearch_to_tsquery (PG 11+): cu phap kieu Google, khong nem loi voi input bat ky
SELECT websearch_to_tsquery('english', '"deploy production" -staging OR test');
-- 'deploy' <-> 'product' & !'stage' | 'test'

-- to_tsquery: parse day du (& | ! <->), dung khi app tu build query
SELECT to_tsquery('english', 'deploy & prod & !staging');
-- 'deploy' & 'prod' & !'stage'

Với search box nhận input tự do, websearch_to_tsquery là lựa chọn đúng: người dùng đã quen cú pháp Google, và hàm không ném lỗi khi họ gõ dấu ngoặc thừa.

5. GIN cho tsvector — workflow cơ bản

Cùng một GIN của bài 03, chỉ đổi element từ key-value JSONB sang token của tsvector. Setup với config English trước, tiếng Việt ở section sau:

SQL
-- Generated column: PG tu tinh lai khi body doi, luu tren disk
ALTER TABLE tasks
  ADD COLUMN body_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('english', coalesce(body, ''))) STORED;

CREATE INDEX idx_tasks_body_fts ON tasks USING gin(body_tsv);

-- Search: dat tsquery vao FROM de dung lai o ca WHERE lan SELECT
SELECT id, title, ts_rank(body_tsv, query) AS rank
FROM tasks, plainto_tsquery('english', 'deploy production') query
WHERE body_tsv @@ query
ORDER BY rank DESC
LIMIT 10;
Bitmap Heap Scan on tasks  (cost=32.45..1234.12 rows=312 width=124)
                            (actual time=1.203..4.892 rows=312 loops=1)
  Recheck Cond: (body_tsv @@ plainto_tsquery('english', 'deploy production'))
  ->  Bitmap Index Scan on idx_tasks_body_fts
        (actual time=1.101..1.101 rows=312 loops=1)
Execution Time: 5.234 ms

Từ 2531 ms xuống 5 ms trong demo này. Vì sao phải là generated column: tính to_tsvector('english', body) ngay trong WHERE thì PG không biết biểu thức đó trùng với cột đã index, GIN bị bỏ qua và mỗi row lại tokenize từ đầu.

6. Vì sao config 'english' không tìm ra "khẩn cấp" khi gõ "khan cap"?

PostgreSQL không có Vietnamese dictionary sẵn. Config 'english' chạy Snowball stemmer cho tiếng Anh và giữ nguyên ký tự có dấu. Trước khi chạy câu thứ hai, hãy đoán nó trả true hay false:

SQL
SELECT to_tsvector('english', 'Khẩn cấp triển khai sản phẩm');
-- 'cấp':2 'khai':4 'khẩn':1 'phẩm':6 'sản':5 'triển':3

SELECT to_tsvector('english', 'Khẩn cấp') @@ to_tsquery('english', 'khan & cap');
-- false

Token bên document còn dấu, token bên query không dấu, GIN tra hai chuỗi khác nhau. Tiếng Việt cần hai thứ: bỏ dấu để "khẩn" và "khan" về một token, và không stem, vì tiếng Việt là ngôn ngữ đơn lập, không chia động từ.

7. Config vietnamese = unaccent + simple

Extension unaccent bỏ dấu; dictionary simple chỉ lowercase, không stem. Ghép lại thành config đủ dùng cho production:

SQL
CREATE EXTENSION IF NOT EXISTS unaccent;

SELECT unaccent('Khẩn cấp triển khai sản phẩm');
-- Khan cap trien khai san pham

-- Config rieng: copy simple, chen unaccent truoc simple trong pipeline
CREATE TEXT SEARCH CONFIGURATION vietnamese (COPY = simple);
ALTER TEXT SEARCH CONFIGURATION vietnamese
  ALTER MAPPING FOR hword, hword_part, word
  WITH unaccent, simple;

SELECT to_tsvector('vietnamese', 'Khẩn cấp triển khai sản phẩm');
-- 'cap':2 'khai':4 'khan':1 'pham':6 'san':5 'trien':3

-- Document co dau, query khong dau (va nguoc lai) deu match
SELECT to_tsvector('vietnamese', 'Khẩn cấp') @@ to_tsquery('vietnamese', 'khan & cap');
-- true
SELECT to_tsquery('vietnamese', 'khẩn & cấp');
-- 'khan' & 'cap'

Cơ chế: unaccent chạy trước simple trong pipeline, và pipeline này áp cho cả document lúc index lẫn query lúc tìm, nên hai bên luôn về cùng token.

Body của task và câu user gõ cùng đi qua config vietnamese gồm parser, unaccent và simple; một phía thành tsvector lưu vào GIN, phía kia thành tsquery tra GIN bằng @@ nên có dấu hay không dấu đều về cùng token

Ba giới hạn cần biết trước khi ship: simple tách token theo khoảng trắng, nên "trienkhai" viết liền không match "triển khai"; không có tokenizer phân tích ranh giới từ như tiếng Nhật/Trung, cần thật thì nhìn sang pgroonga hoặc Elasticsearch với analysis-icu; và simple không bỏ stopword tiếng Việt ("và", "là", "của"), các token này chiếm posting list lớn và lọt vào query, nên hoặc tạo stopword file riêng cho config, hoặc strip ở app trước khi gọi websearch_to_tsquery.

Thử ngẫmconfig vietnamese bạn vừa tạo không strip stopword "và", "là", "của". Nếu user TaskFlow gõ "task và deploy" vào ô search, kết quả sẽ thiếu những task nào?

8. ts_rank + ts_headline — xếp hạng và highlight

SQL
-- ts_rank: diem cang cao cang khop (tan suat token + vi tri + weight)
SELECT id, title, ts_rank(body_tsv, query) AS rank
FROM tasks, plainto_tsquery('vietnamese', 'khan cap deploy') query
WHERE body_tsv @@ query
ORDER BY rank DESC
LIMIT 20;

-- ts_headline: cat doan van chua match va boc the highlight
SELECT id,
       ts_headline('vietnamese', body, query,
                   'StartSel=<mark>, StopSel=</mark>, MaxWords=20, MinWords=10') AS snippet
FROM tasks, plainto_tsquery('vietnamese', 'khan cap') query
WHERE body_tsv @@ query;
-- snippet: "...task <mark>khẩn</mark> <mark>cấp</mark> cần deploy ngay..."
-- (highlight tren text goc con dau -- dung nhu mong doi)

ts_headline parse lại body gốc để tìm vị trí match, nên tốn CPU với body dài; giới hạn bằng MaxFragments và chỉ gọi trên trang kết quả đã LIMIT.

9. pg_trgm — typo và tìm chuỗi con

FTS lo stemming và dấu, nhưng không chịu được lỗi gõ: "dploy" hay "trienkha" không match. pg_trgm cắt chuỗi thành bộ ba ký tự và đo độ trùng:

SQL
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX idx_tasks_title_trgm ON tasks USING gin(title gin_trgm_ops);

-- % la similarity operator, nguong mac dinh 0.3
SELECT title, similarity(title, 'dploy') AS sim
FROM tasks
WHERE title % 'dploy'
ORDER BY sim DESC
LIMIT 10;
-- "deploy production" van match du thieu chu e
Nhu cầuFTS (tsvector)pg_trgm
Search nội dung dài, xếp hạngTốtKém
Stemming / bỏ dấuTốtKhông
Chịu typoKhôngTốt
Search-as-you-type (prefix)KémTốt
Chuỗi con bất kỳKhôngTốt

Pattern production: FTS là đường chính; khi FTS trả 0 kết quả, fallback sang title % $1 bằng trigram.

Thử ngẫmuser gõ "dploy" vào search box TaskFlow, thiếu một chữ e. tsvector @@ tsquery trả về gì, và operator nào trong bài này mới cứu được câu tìm kiếm đó?

10. Pitfall tổng hợp

⚠️ Pitfall — 3 lỗi thực chiến với FTS

Pitfall 1 — tính to_tsvector trong WHERE thay vì generated column:

SQL
-- Sai: GIN tren body_tsv khong duoc dung, moi row tokenize lai
SELECT * FROM tasks WHERE to_tsvector('vietnamese', body) @@ query;

-- Dung: generated column STORED + GIN tren column do
ALTER TABLE tasks
  ADD COLUMN body_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('vietnamese', coalesce(body, ''))) STORED;

Pitfall 2 — tưởng FTS là substring search:

SQL
SELECT to_tsvector('vietnamese', 'apple watch') @@ to_tsquery('vietnamese', 'apl');
-- false: GIN tra token 'apl', khong co entry

Cần chuỗi con hay autocomplete thì dùng pg_trgm, FTS chỉ so token nguyên.

Pitfall 3 — không đo kích thước GIN trước khi ship:

GIN trên tsvector có thể lớn, và lớn bao nhiêu phụ thuộc số token phân biệt và độ dài body của dữ liệu thật. Đừng ước lượng bằng hệ số; nạp dataset thật rồi đo pg_total_relation_size('tasks') trước và sau khi tạo index, rồi tính vào storage và backup.

11. Applied — search box TaskFlow production-ready

SQL
-- Generated column ghep title (weight A) + body (weight B): ts_rank uu tien title
ALTER TABLE tasks
  ADD COLUMN search_tsv tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('vietnamese', coalesce(title, '')), 'A') ||
      setweight(to_tsvector('vietnamese', coalesce(body, '')),  'B')
    ) STORED;

CREATE INDEX idx_tasks_search ON tasks USING gin(search_tsv);

-- /api/search?q=khan+cap+deploy  ($1 = input, $2 = project_id)
SELECT id, title,
       ts_headline('vietnamese', coalesce(body, ''), query,
                   'StartSel=<mark>, StopSel=</mark>, MaxWords=20, MaxFragments=2') AS snippet,
       ts_rank(search_tsv, query) AS rank
FROM tasks, websearch_to_tsquery('vietnamese', $1) query
WHERE search_tsv @@ query
  AND project_id = $2
ORDER BY rank DESC, created_at DESC
LIMIT 20;

12. Deep Dive

📚 Deep Dive — FTS internals và unaccent

13. Liên hệ các bài khác

14. Tóm tắt

  • Checklist ship search: generated column STORED + GIN, config đúng ngôn ngữ ở cả hai phía, websearch_to_tsquery cho input người dùng, LIMIT trước khi ts_headline.
  • Debug "search không ra": chạy to_tsvector(config, doc)to_tsquery(config, q) riêng, so token hai bên; khác dấu hoặc khác config là thủ phạm.
  • Tiếng Việt: unaccent + simple, không stem; stopword tự lo; từ viết liền không match.
  • setweight A cho title, B cho body khi cần title thắng trong ts_rank.
  • Typo, prefix, chuỗi con: pg_trgm với gin_trgm_ops, dùng làm fallback khi FTS trả 0.

15. Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Tại sao `WHERE body LIKE '%deploy%'` không dùng được B-tree dù có index trên `body`? FTS giải bài toán này khác LIKE ở đâu?
  2. Q2
    Giải thích cơ chế config `unaccent + simple`. Tại sao 'khan cap' match được document chứa 'Khẩn cấp'?
  3. Q3
    Vì sao dùng `GENERATED ALWAYS AS (...) STORED` cho tsvector thay vì tính `to_tsvector(body)` trong WHERE?
  4. Q4
    Làm sao để match ở `title` được rank cao hơn match ở `body`? `setweight` hoạt động thế nào?
  5. Q5
    FTS và `pg_trgm` đều dùng GIN nhưng giải bài toán khác nhau. Khi nào chọn cái nào, khi nào cần cả hai?
  6. Q6
    GIN cho tsvector có write tax như GIN cho JSONB không? Sau khi thêm `search_tsv` và GIN, INSERT đổi thế nào và kiểm tra ra sao?

Bài tiếp theo: pgvector và semantic search — embedding, cosine similarity

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

pgvector — semantic search với embedding và HNSW trong PostgreSQL