LIKE '%từ khoá%' không dùng được chỉ mục B-tree, nên mọi tìm kiếm văn bản đều thành quét toàn bảng. GIN với tsvector là câu trả lời của PostgreSQL — nhưng nó không phải lúc nào cũng thắng, và với tiếng Việt có thêm vài chuyện phải xử lý.
Bảng thử: 500.000 bài viết tiếng Việt, 279 MB.
GIN thắng khi từ khoá hiếm, thua khi từ khoá phổ biến
| Tìm gì | LIKE (không chỉ mục) |
GIN trên tsvector |
|---|---|---|
| Từ hiếm — 100/500.000 bài | 105,60 ms | 0,14 ms (nhanh 754 lần) |
| Từ phổ biến — 125.000 bài (25%) | 83,34 ms | 150,03 ms (GIN chậm hơn) |
| Hai từ, một hiếm một phổ biến | — | 0,09 ms |
Dòng giữa lại là bài học của phần 10 ở một hình dạng khác: khi kết quả chiếm một phần tư bảng, đi qua chỉ mục rồi nhảy về bảng đắt hơn đọc thẳng.
Dòng cuối cho thấy sức mạnh thật của GIN: nó giữ danh sách bài cho từng từ, nên tìm nhiều từ cùng lúc chỉ là giao hai danh sách. Từ hiếm thu hẹp kết quả ngay, và từ phổ biến đi kèm không làm chậm thêm.
Nhân tiện, ILIKE '%từ%' mất 332,53 ms — chậm gấp 4 lần LIKE vì phải đổi hoa thường cho từng dòng, và không chỉ mục B-tree nào giúp được.
PostgreSQL không có cấu hình tiếng Việt
SELECT to_tsvector('simple', 'Cơ sở dữ liệu quan hệ mã nguồn mở');
'cơ':1 'dữ':3 'hệ':6 'liệu':4 'mã':7 'mở':9 'nguồn':8 'quan':5 'sở':2
Chín từ, tách theo khoảng trắng. Trong danh sách 29 cấu hình có sẵn — english, french, russian, vietnamese — không có tiếng Việt. Và to_tsvector('english', ...) cho kết quả y hệt simple, vì thuật toán rút gọn từ của tiếng Anh không nhận ra từ tiếng Việt nào.
Hệ quả thực tế: "cơ sở" là hai từ riêng biệt, không phải một khái niệm. Muốn tìm đúng cụm phải dùng toán tử liền kề:
tv @@ to_tsquery('simple', 'cơ <-> sở') -- hai tu dung canh nhau
tv @@ to_tsquery('simple', 'cơ & sở') -- hai tu cung xuat hien trong bai
Với tiếng Việt, simple thật ra là lựa chọn đúng: nó không cắt gọt gì, chỉ chuyển về chữ thường và tách theo khoảng trắng. Đừng dùng english chỉ vì nó nghe "đầy đủ hơn" — nó không làm gì thêm mà còn loại bỏ những từ nằm trong danh sách từ dừng của tiếng Anh.
Tìm không dấu: bài toán riêng của tiếng Việt
Người dùng gõ co so du lieu, dữ liệu là Cơ sở dữ liệu:
tim 'co & so' -> 0 ket qua
Không có gì bất ngờ, nhưng nó là yêu cầu gần như bắt buộc với mọi ứng dụng tiếng Việt.
Extension unaccent giải quyết phần chuyển đổi:
CREATE EXTENSION unaccent;
SELECT unaccent('Cơ sở dữ liệu'); -- Co so du lieu
Nhưng đánh chỉ mục lên nó thì không được:
CREATE INDEX ON bv USING gin (to_tsvector('simple', unaccent(noi_dung)));
ERROR: functions in index expression must be marked IMMUTABLE
Đúng cái bẫy phần 14 đã đo: unaccent là STABLE, vì nó đọc từ điển chuyển đổi có thể thay đổi. Kiểm tra: provolatile = 's'.
Cách vá chuẩn là bọc nó trong một hàm bạn tự khai là IMMUTABLE, chỉ định rõ từ điển:
CREATE OR REPLACE FUNCTION bo_dau(text) RETURNS text AS
$$ SELECT unaccent('unaccent', $1) $$
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE;
CREATE INDEX ix_ua ON bv USING gin (to_tsvector('simple', bo_dau(noi_dung)));
Đo được: chỉ mục 38 MB, dựng mất 13,7 s, và tìm không dấu chỉ còn 0,12 ms.
Lưu ý điều bạn đang hứa khi khai IMMUTABLE: nếu ai đó sửa từ điển unaccent sau này, chỉ mục sẽ sai và PostgreSQL không phát hiện được. Trong thực tế từ điển đó gần như không bao giờ đổi, nên đây là đánh đổi mọi người chấp nhận — nhưng nên biết mình đang chấp nhận cái gì.
GIN so với GiST cho văn bản
| Dung lượng | Thời gian dựng | |
|---|---|---|
| GIN | 43 MB | 3,2 s |
| GiST | 73 MB | 4,4 s |
Với tsvector, GIN nhỏ hơn và dựng nhanh hơn. GiST lưu dạng có mất mát nên phải kiểm lại kết quả ở bảng, còn GIN lưu chính xác.
Đổi lại GIN cập nhật chậm hơn — mỗi lần sửa văn bản là sửa danh sách của mọi từ trong đó. PostgreSQL có fastupdate để gom các thay đổi lại, bật sẵn theo mặc định.
Quy tắc: tìm kiếm toàn văn thì dùng GIN. GiST hợp với dữ liệu hình học và khoảng — phần 17.
Dựng cột tsvector thế nào
Cách gọn nhất là cột sinh, giống phần 14:
ALTER TABLE bv ADD COLUMN tv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', tieu_de || ' ' || noi_dung)) STORED;
CREATE INDEX ON bv USING gin (tv);
Trên 500.000 dòng, thêm cột mất 12,6 s và dựng chỉ mục mất 3,2 s.
Muốn tiêu đề có trọng số cao hơn nội dung thì dùng setweight:
setweight(to_tsvector('simple', tieu_de), 'A') ||
setweight(to_tsvector('simple', noi_dung), 'B')
Rồi xếp hạng bằng ts_rank. Với ứng dụng tiếng Việt thật, bạn thường cần hai chỉ mục — một có dấu để tìm chính xác, một không dấu để tìm khoan dung — và truy vấn OR cả hai.
Khi nào đừng dùng tìm kiếm toàn văn
- Từ khoá phổ biến trong dữ liệu nhỏ. Phép đo ở trên cho thấy quét bảng có thể nhanh hơn.
- Tìm theo tiền tố ngắn.
LIKE 'tien to%'dùng được B-tree bình thường, rẻ hơn nhiều. - Cần tìm gần đúng, sai chính tả. Đó là việc của
pg_trgm, không phảitsvector. - Yêu cầu xếp hạng phức tạp, gợi ý, đồng nghĩa. Đến ngưỡng đó thì Elasticsearch hay Meilisearch phù hợp hơn — và với tiếng Việt chúng có bộ tách từ thật, thứ PostgreSQL không có.
Thử ba mươi giây
Xem PostgreSQL tách văn bản của bạn thành những từ nào:
SELECT to_tsvector('simple', 'câu văn tiếng Việt của bạn');
Và đo xem từ khoá hay dùng nhất chiếm bao nhiêu phần dữ liệu:
SELECT word, ndoc, nentry
FROM ts_stat('SELECT tv FROM bang_cua_ban')
ORDER BY ndoc DESC
LIMIT 20;
Từ nào có ndoc gần bằng tổng số dòng thì GIN sẽ không giúp được gì khi tìm nó một mình — hãy thiết kế giao diện để người dùng luôn có ít nhất một từ hiếm trong câu tìm.
Phần sau đo GiST và BRIN: hai kiểu chỉ mục cho dữ liệu theo khoảng và theo thời gian.