PostgreSQL Bài 18: Tìm kiếm văn bản toàn diện (Full-Text Search): tsvector, tsquery và GIN Index
Khi người dùng tìm kiếm trên thanh search box của website, giải pháp đơn giản nhất mà nhiều người thường nghĩ đến là dùng toán tử LIKE hoặc ILIKE:
SQL
SELECT * FROM articles WHERE content ILIKE '%database%';
Tuy nhiên, ILIKE '%...%' có hai nhược điểm chí mạng trong môi trường sản xuất:
-
Hiệu năng kém: Ký tự đại diện
%đứng đầu khiến B-Tree Index hoàn toàn vô dụng, ép database phải quét toàn bộ bảng (Full Table Scan). -
Không có tính ngôn ngữ (Linguistic Agnostic): Không hiểu được từ đồng nghĩa, số ít/số nhiều (tìm
"running"sẽ không ra"runs"hoặc"ran"), không loại bỏ được từ vô nghĩa (stop words như "the", "a", "is"), và không thể xếp hạng xem bài viết nào khớp liên quan hơn bài viết nào.
PostgreSQL tích hợp sẵn một cỗ máy Full-Text Search (FTS) hoàn chỉnh. Với FTS và GIN Index, bạn hoàn toàn có thể xây dựng tính năng tìm kiếm văn bản tốc độ cao trên hàng triệu bản ghi mà chưa cần dựng thêm cụm Elasticsearch hay Meilisearch cồng kềnh.
1. Kiến trúc FTS: Quy trình từ Văn bản thô đến Chỉ mục
Để máy tính hiểu và tìm kiếm văn bản tự nhiên, PostgreSQL chia quá trình xử lý làm 3 bước:
[ Văn bản thô (Raw Text) ]
│
▼ 1. Phân tích cú pháp (Parser)
[ Danh sách Token: từ ngữ, số, dấu câu, email... ]
│
▼ 2. Từ điển & Xử lý ngôn ngữ (Dictionary)
├── Loại bỏ Stop words ("the", "an", "is", "in"...)
└── Rút gọn gốc từ (Stemming: "jumping", "jumped" ──► "jump")
│
▼ 3. Đóng gói Lexemes
[ tsvector: Danh sách các từ gốc + Vị trí xuất hiện ]
Hai kiểu dữ liệu cốt lõi:
-
tsvector(Text Search Vector): Đại diện cho một tài liệu đã được chuẩn hóa thành danh sách các từ gốc (lexemes) kèm vị trí xuất hiện của chúng trong văn bản. -
tsquery(Text Search Query): Đại diện cho cụm từ tìm kiếm do người dùng nhập vào, kết hợp với các toán tử logic Boole (&- AND,|- OR,!- NOT,<->- Phép kế cận/Phrase Search).
2. Tìm hiểu sâu về tsvector và tsquery
2.1. Chuẩn hóa tài liệu với hàm to_tsvector()
Hãy chạy thử lệnh sau trong psql để xem cách PostgreSQL "tiêu hóa" một câu văn tiếng Anh:
SQL
SELECT to_tsvector('english', 'The quick brown foxes are jumping over the lazy dogs');
Kết quả:
Plaintext
to_tsvector
-----------------------------------------------------
'brown':3 'dog':9 'fox':4 'jump':6 'lazi':8 'quick':2
Quan sát những gì đã xảy ra:
-
Các stop words
"the","are","over"đã bị loại bỏ hoàn toàn vì chúng không mang ý nghĩa tìm kiếm đặc trưng. -
Từ số nhiều
"foxes"và"dogs"được đưa về từ gốc:'fox'và'dog'. -
Động từ thì tiếp diễn
"jumping"được chuyển thành dạng gốc'jump'. -
Số đi kèm phía sau (ví dụ:
:4,:6) chính là vị trí (offset) của từ đó trong câu ban đầu, phục vụ cho việc tính điểm liên quan và tìm kiếm cụm từ.
2.2. Xây dựng điều kiện tìm kiếm với to_tsquery() và plainto_tsquery()
Để tìm kiếm, chúng ta dùng toán tử khớp nối chuyên biệt: @@. Toán tử này kiểm tra xem một tsvector có thỏa mãn điều kiện của một tsquery hay không.
SQL
-- Cú pháp chuẩn: to_tsvector(...) @@ to_tsquery(...)
SELECT to_tsvector('english', 'PostgreSQL is an advanced relational database')
@@ to_tsquery('english', 'database & relational'); -- Trả về TRUE
Các hàm khởi tạo tsquery:
-
to_tsquery(): Yêu cầu cú pháp nghiêm ngặt với các toán tử logic (&,|,!). Nếu người dùng nhập khoảng trắng thuần, lệnh sẽ ném lỗi.SQL
to_tsquery('english', 'postgres & (database | storage)') -
plainto_tsquery(): Tự động biến đổi chuỗi nhập tự do của người dùng thành biểu thứcAND. Khoảng trắng được coi là dấu&, tự động bỏ stop words. Rất an toàn cho input từ người dùng:SQL
plainto_tsquery('english', 'the fast database') -- Tự sinh ra tsquery: 'fast' & 'databas' -
phraseto_tsquery(): Tìm kiếm theo đúng cụm từ liền kề (Phrase Search). Các từ phải xuất hiện cạnh nhau theo đúng thứ tự (sử dụng toán tử khoảng cách<->):SQL
phraseto_tsquery('english', 'relational database') -- Tự sinh ra: 'relat' <-> 'databas'
3. Trọng số (Weights) và Xếp hạng độ liên quan (ts_rank)
Trong một tài liệu, một từ xuất hiện trên Tiêu đề (Title) phải có giá trị quan trọng hơn từ đó xuất hiện mờ nhạt trong phần Bình luận (Comments).
PostgreSQL cho phép gán 4 mức trọng số (Weight) vào từng phần của tsvector:
-
A: Trọng số cao nhất (mặc định điểm là 1.0) - thường gán cho Title. -
B: Trọng số cao thứ nhì (mặc định điểm là 0.4) - tóm tắt, Subtitle. -
C: Trọng số trung bình (mặc định 0.2) - nội dung chính (Body text). -
D: Trọng số thấp nhất (mặc định 0.1) - tags, tác giả, comments.
3.1. Gán trọng số với setweight()
SQL
SELECT setweight(to_tsvector('english', 'PostgreSQL Internals'), 'A') ||
setweight(to_tsvector('english', 'A deep dive into storage engines and memory buffer...'), 'C');
3.2. Tính điểm và sắp xếp kết quả với ts_rank()
Hàm ts_rank(tsvector, tsquery) tính toán tần suất xuất hiện và trọng số của các từ khóa để trả về một điểm số thực (float). Điểm càng cao, bài viết càng phù hợp.
SQL
SELECT
title,
ts_rank(
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'C'),
query
) AS rank_score
FROM articles, to_tsquery('english', 'postgres & engine') query
WHERE (setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', body), 'C')) @@ query
ORDER BY rank_score DESC;
4. Đánh chỉ mục GIN (Generalized Inverted Index)
Nếu bạn chạy hàm to_tsvector() trực tiếp trên mệnh đề WHERE ở mỗi câu truy vấn, PostgreSQL sẽ phải parse lại văn bản của từng dòng trong bảng mỗi khi tìm kiếm (tốn CPU khủng khiếp).
Giải pháp chuẩn Production:
-
Tạo một cột riêng kiểu
tsvectorđể lưu trước dữ liệu đã được parse. -
Dùng tính năng Generated Column (Postgres 12+) để tự động cập nhật cột này mỗi khi bài viết thay đổi.
-
Tạo một chỉ mục GIN Index trên cột
tsvectorđó.
Mô hình Inverted Index (Chỉ mục đảo) của GIN:
Từ khóa (Lexeme) ──► Danh sách các dòng chứa nó (Posting List)
─────────────────────────────────────────────────────────────────
'databas' ──► Row #1, Row #4, Row #88, Row #1024
'engin' ──► Row #4, Row #12, Row #88
'postgres' ──► Row #1, Row #2, Row #4, Row #55
Khi tìm kiếm ('postgres' & 'engin'):
GIN chỉ cần lấy phần giao của hai danh sách: {Row #1, 4, 88} ∩ {Row #4, 12, 88} = {Row #4, Row #88}
──► Tốc độ trả kết quả trong vài mili-giây!
5. Thực hành: Xây dựng Search Engine cho Blog hoàn chỉnh
Tạo bảng và cấu hình FTS tối ưu từ A-Z:
SQL
CREATE SCHEMA IF NOT EXISTS fts_lab;
-- 1. Tạo bảng bài viết với Generated Column tự động lưu tsvector
CREATE TABLE fts_lab.articles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT,
content TEXT NOT NULL,
author_name TEXT,
-- Cột search_vector tự động tính toán dựa trên trọng số A, B, C:
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('english', coalesce(content, '')), 'C')
) STORED
);
-- 2. Đánh GIN Index trên cột search_vector
CREATE INDEX idx_articles_search_vector ON fts_lab.articles USING GIN (search_vector);
-- 3. Nạp dữ liệu mẫu
INSERT INTO fts_lab.articles (title, summary, content, author_name) VALUES
('PostgreSQL Indexing Deep Dive', 'Learn B-Tree and GIN indexes', 'In this article we explore how GIN indexes accelerate full text search queries...', 'Hieu Nguyen'),
('Cooking the Best Italian Pasta', 'Traditional bolognese recipe', 'To cook authentic pasta you need fresh tomatoes, ground beef, and olive oil...', 'Gordon'),
('PostgreSQL Memory Tuning', 'Optimize shared_buffers and work_mem', 'Tuning database buffer caches requires careful analysis of query plans and I/O metrics...', 'Hieu Nguyen');
Truy vấn tìm kiếm kèm Highlight từ khóa (ts_headline)
Trong thực tế, người dùng muốn thấy đoạn văn chứa từ khóa được bôi đậm (Highlight) giống như Google Search. PostgreSQL cung cấp hàm ts_headline() để làm việc này:
SQL
SELECT
id,
title,
ts_rank(search_vector, query) AS relevance_rank,
-- Trích xuất đoạn tóm tắt chứa từ khóa được highlight bằng thẻ <b>:
ts_headline(
'english',
content,
query,
'StartSel=<b>, StopSel=</b>, MaxWords=20, MinWords=10'
) AS snippet
FROM fts_lab.articles, plainto_tsquery('english', 'indexing database') query
WHERE search_vector @@ query
ORDER BY relevance_rank DESC;
Kết quả trả về:
Plaintext
-[ RECORD 1 ]--+-----------------------------------------------------------------------------------------
id | 1
title | PostgreSQL Indexing Deep Dive
relevance_rank | 0.6079271
snippet | explore how GIN <b>indexes</b> accelerate full text search queries... Tuning <b>database</b>
6. FTS trên PostgreSQL vs Dedicated Search Engine (Elasticsearch)
Khi nào bạn nên dùng PostgreSQL FTS? Khi nào bắt buộc phải dựng cụm Elasticsearch / OpenSearch?
| Tiêu chí | PostgreSQL Full-Text Search | Elasticsearch / OpenSearch |
|---|---|---|
| Độ phức tạp hạ tầng | Cực thấp. Tích hợp sẵn trong DB chính, không cần cài đặt hay vận hành thêm service riêng. | Cao. Cần dựng cụm riêng, theo dõi JVM, quản trị RAM, phân vùng shard/replica. |
| Tính đồng nhất dữ liệu | ACID tuyệt đối. Vừa INSERT/UPDATE xong là câu query FTS nhìn thấy ngay lập tức. | Chấp nhận trễ (Eventual Consistency). Cần đường ống CDC/ETL để sync dữ liệu sang, dễ lệch data. |
| Bộ nhớ & Tài nguyên | Nhẹ nhàng, tận dụng chung shared_buffers và Page Cache của OS. | Đòi hỏi RAM lớn (tối thiểu 4–8 GB cho JVM heap). |
| Khả năng mở rộng (Scale) | Phù hợp từ hàng trăm nghìn đến vài chục triệu bản ghi trên một máy chủ mạnh. | Khả năng mở rộng phân tán quy mô hàng tỷ documents, hàng trăm terabytes. |
| Tính năng tìm kiếm nâng cao | Tốt cho tìm kiếm văn bản cơ bản, từ đồng nghĩa, tiền tố, xếp hạng. | Rất mạnh: Phân tích cú pháp sâu, spell-check gợi ý thông minh, aggregation thống kê khổng lồ. |
7. Tóm tắt & Bài tiếp theo
-
Đừng dùng
ILIKE '%...%'cho các bài toán tìm kiếm văn bản lớn; hãy dùng FTS tích hợp của PostgreSQL. -
tsvectorchứa các từ gốc (lexemes) đã được chuẩn hóa, loại bỏ stop words và kèm vị trí. -
tsquerychứa biểu thức điều kiện tìm kiếm; kết hợp với toán tử@@. -
Dùng
setweight()để tăng điểm số cho các trường quan trọng (Tiêu đề > Nội dung) và sắp xếp kết quả bằngts_rank(). -
Luôn lưu
tsvectortrong một cột Generated Stored và đánh GIN Index để đạt tốc độ truy vấn tối đa.
Bài 19 xem tiếp: Regular Expressions và Fuzzy Search với extension
pg_trgm— nếu người dùng gõ sai chính tả (ví dụ gõ"postgre"hay"posgres"thay vì"postgresql"),tsvectorchuẩn sẽ không tìm thấy. Chúng ta sẽ tìm hiểu kỹ thuật chia 3 ký tự (Trigram) với extensionpg_trgmđể xử lý bài toán tìm kiếm mờ (Fuzzy search) và tăng tốc toán tửLIKE '%abc%'bằng GIN/GiST Index.
All rights reserved