PostgreSQL Bài 42: Full-Text Search (FTS) nâng cao và tối ưu Ranking
Tìm kiếm văn bản bằng toán tử LIKE '%keyword%' trên một cơ sở dữ liệu hàng triệu sản phẩm là một thảm họa về hiệu năng (gây Seq Scan) và vô dụng về mặt ngữ nghĩa (không thể bỏ qua giới từ, không hiểu các biến thể của từ).
PostgreSQL cung cấp một hệ thống tìm kiếm toàn văn (Full-Text Search - FTS) tích hợp sẵn, mạnh mẽ không kém gì Elasticsearch đối với các bài toán quy mô vừa và lớn. Bạn có thể phân tích văn bản, đánh trọng số (Title quan trọng hơn Description), xếp hạng độ chính xác (Ranking) và thậm chí làm nổi bật từ khóa (Highlighting).
1. Trái tim của FTS: tsvector và tsquery
Kiến trúc FTS của PostgreSQL hoạt động dựa trên việc chuyển đổi văn bản thô thành một định dạng tối ưu để máy tính có thể tra cứu cực nhanh. Điều này được thực hiện thông qua hai kiểu dữ liệu cốt lõi:
1.1. tsvector (Document)
Hàm to_tsvector() nhận vào một văn bản và thực hiện 3 tác vụ:
-
Tokenize: Cắt câu thành các từ đơn.
-
Stopwords Removal: Vứt bỏ các từ vô nghĩa (mạo từ, giới từ như a, the, in, on).
-
Stemming: Rút gọn từ về gốc từ (ví dụ: running, ran, runs run).
SQL
SELECT to_tsvector('english', 'The quick brown foxes jumped over the lazy dogs');
Kết quả:
Plaintext
'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
Nhận xét: Chữ "The" đã biến mất. "foxes" thành "fox", "dogs" thành "dog". Các con số (như 3, 9) chính là vị trí (offset) của từ đó trong câu gốc, phục vụ cho việc tính toán khoảng cách (Cover Density).
1.2. tsquery (Search Query)
Hàm to_tsquery() chuyển đổi từ khóa tìm kiếm của người dùng thành các gốc từ tương ứng, hỗ trợ các toán tử logic:
-
&(AND) -
|(OR) -
!(NOT) -
<->(Theo sát nhau - Cụm từ chính xác)
SQL
SELECT to_tsquery('english', 'foxes & jumping');
-- Kết quả: 'fox' & 'jump' (Tự động đưa về gốc từ để chuẩn bị khớp với tsvector)
1.3. Toán tử khớp (Match Operator: @@)
Để kiểm tra xem một truy vấn có khớp với tài liệu hay không, ta dùng @@:
SQL
SELECT to_tsvector('english', 'The quick brown foxes') @@ to_tsquery('english', 'fox');
-- Kết quả: TRUE
2. Thực hành: Xây dựng bộ máy tìm kiếm Sản phẩm Mỹ phẩm / E-commerce
Hãy áp dụng FTS vào một bảng sản phẩm thương mại điện tử đặc thù với hàng ngàn SKU cần tốc độ tìm kiếm nhanh và chính xác.
SQL
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE,
name TEXT NOT NULL,
description TEXT,
search_vector tsvector -- Cột lưu trữ vector tìm kiếm định sẵn
);
INSERT INTO products (sku, name, description) VALUES
('SP01', 'Kem chống nắng La Roche-Posay', 'Kem chống nắng kiểm soát nhờn SPF 50+ dành cho da mụn'),
('SP02', 'Sữa rửa mặt Cerave', 'Sữa rửa mặt làm sạch sâu, dịu nhẹ cho da nhạy cảm'),
('SP03', 'Serum Vichy Mineral 89', 'Tinh chất phục hồi, cấp ẩm sâu cho da mặt');
3. Đánh trọng số (Weighting): Tên sản phẩm quan trọng hơn Mô tả
Khi người dùng gõ "da mụn", nếu từ này xuất hiện trong tên sản phẩm (name), sản phẩm đó phải được xếp hạng cao hơn sản phẩm chỉ có từ "da mụn" nằm tít dưới phần mô tả (description).
Hàm setweight(vector, category) cung cấp 4 mức trọng số ưu tiên giảm dần: A, B, C, D.
- Mặc định: , , , .
Chúng ta sẽ xây dựng Trigger để tự động cập nhật cột search_vector mỗi khi có lệnh INSERT hoặc UPDATE. (Dùng dictionary simple vì tiếng Việt không có bộ stemming chuẩn mực trong PostgreSQL mặc định).
SQL
CREATE OR REPLACE FUNCTION update_product_search_vector() RETURNS trigger AS $$
BEGIN
-- Tên sản phẩm (Trọng số A - Cao nhất)
NEW.search_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.name, '')), 'A') ||
-- Mô tả sản phẩm (Trọng số B - Thấp hơn)
setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_search_vector
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION update_product_search_vector();
4. Thuật toán Xếp hạng (Ranking)
Sau khi tìm được 100 sản phẩm khớp từ khóa, làm sao để đưa sản phẩm liên quan nhất lên Top 1? Bạn cần tính điểm số (Score) bằng hàm Rank.
PostgreSQL cung cấp 2 thuật toán chấm điểm:
-
ts_rank(): Chấm điểm dựa trên tần suất xuất hiện (Term Frequency). Từ khóa xuất hiện càng nhiều lần trong văn bản, điểm càng cao. Trọng số (A, B, C, D) sẽ tác động mạnh vào hàm này. -
ts_rank_cd()(Cover Density): Chấm điểm dựa trên khoảng cách giữa các từ. Nếu người dùng gõ "sữa rửa", một văn bản chứa cụm "sữa rửa" liền kề nhau sẽ có điểm cao hơn văn bản chứa chữ "sữa" ở đầu câu và chữ "rửa" ở cuối câu.
Công thức mô phỏng tính điểm:
(Trong đó là trọng số A/B/C/D đã set).
Câu truy vấn thực chiến (Tìm kiếm + Xếp hạng):
SQL
SELECT
name,
ts_rank(search_vector, to_tsquery('simple', 'da & mụn')) AS rank_score
FROM products
WHERE search_vector @@ to_tsquery('simple', 'da & mụn')
ORDER BY rank_score DESC
LIMIT 10;
5. Tối ưu độ trễ bằng GIN Index
Nếu không có Index, toán tử @@ sẽ phải quét toàn bộ bảng (Seq Scan), phân tích từng cột search_vector vô cùng chậm chạp.
GIN Index là lựa chọn số 1 (và duy nhất hiệu quả) cho FTS:
SQL
CREATE INDEX idx_products_search ON products USING GIN (search_vector);
Mẹo Performance: Nếu bạn không muốn tạo thêm cột vật lý search_vector làm phình to bảng, bạn có thể tạo thẳng Expression GIN Index trên cụm biến đổi:
SQL
CREATE INDEX idx_products_fts_expr ON products
USING GIN (
(setweight(to_tsvector('simple', name), 'A') || setweight(to_tsvector('simple', description), 'B'))
);
Tuy nhiên, khi truy vấn, bạn bắt buộc phải viết chính xác toàn bộ biểu thức đó vào mệnh đề WHERE để Planner nhận diện được Index.
6. Làm nổi bật từ khóa (Highlighting) với ts_headline
Để hiển thị Snippet dạng chữ in đậm <b>...</b> trên UI (giống Google Search), thay vì để Frontend tự dùng Regex cắt chuỗi, PostgreSQL cung cấp hàm ts_headline() làm việc này chính xác dựa trên cấu trúc ngữ pháp.
SQL
SELECT
name,
ts_headline('simple', description, to_tsquery('simple', 'rửa & mặt'),
'StartSel = <b>, StopSel = </b>, MaxWords=15, MinWords=5'
) AS snippet
FROM products
WHERE search_vector @@ to_tsquery('simple', 'rửa & mặt');
Kết quả Snippet:
"<b>Sữa</b> <b>rửa</b> <b>mặt</b> làm sạch sâu, dịu nhẹ cho da..."
7. Giới hạn xử lý Tiếng Việt và Giải pháp
Vấn đề lớn nhất của FTS trong PostgreSQL là nó không tích hợp sẵn bộ từ điển (Dictionary) tách từ vựng Tiếng Việt.
Khi dùng to_tsvector('simple', 'sữa rửa mặt'), PostgreSQL chia khoảng trắng thành 3 token riêng biệt: sữa, rửa, mặt (thay vì hiểu đây là một danh từ ghép). Thêm vào đó, dấu câu Tiếng Việt (á, à, ả) tạo ra các biến thể khó khớp.
Quy trình chuẩn xử lý Tiếng Việt trên PostgreSQL:
-
Loại bỏ dấu (Unaccent): Sử dụng extension
unaccentđể loại bỏ dấu tiếng Việt khỏi cả văn bản nguồn và từ khóa tìm kiếm. -
Sử dụng Dictionary
simple: Kết hợp với chuỗi đã unaccent. -
Chuyển đổi từ khóa thành Prefix Search: Thay vì bắt người dùng gõ chính xác, tự động thêm
: *vào từ khóa cuối để tìm kiếm tiền tố. (Ví dụ:sua & rua & m:*).
SQL
-- Kích hoạt extension loại bỏ dấu
CREATE EXTENSION IF NOT EXISTS unaccent;
-- Tạo Index trên chuỗi đã bỏ dấu
CREATE INDEX idx_products_unaccent
ON products USING GIN (to_tsvector('simple', unaccent(name)));
-- Truy vấn
SELECT name FROM products
WHERE to_tsvector('simple', unaccent(name)) @@ to_tsquery('simple', 'sua & rua & mat');
Nếu nhu cầu tìm kiếm của hệ thống vượt quá giới hạn tách từ ghép (ví dụ cần xử lý đồng nghĩa Synonym "điện thoại" = "smartphone", xử lý gõ sai Typo), lúc đó bạn mới thực sự cần đẩy dữ liệu sang một công cụ chuyên biệt bên ngoài như Elasticsearch hoặc Typesense. Đối với các bài toán tra cứu E-commerce tiêu chuẩn, sự kết hợp giữa JSONB, GIN và FTS của PostgreSQL là quá đủ để phục vụ.
All rights reserved