Tìm kiếm tiếng Việt nâng cao trong PostgreSQL và MySQL: Collation, Unaccent và tối ưu Index

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Thách thức khi tìm kiếm văn bản tiếng Việt

Tiếng Việt có sáu thanh điệu khác nhau áp dụng trên nhiều dạng nguyên âm, tạo ra hơn 60 ký tự có dấu độc lập. Sự phức tạp này gây ra một vấn đề cụ thể: câu lệnh SELECT * FROM table WHERE name LIKE '%nguyen%' thông thường sẽ không khớp với “Nguyễn”. Hai chuỗi này trông có vẻ liên quan với mắt người, nhưng với database engine chúng là hai chuỗi byte hoàn toàn khác nhau.

Tôi đã mất sáu tháng để tinh chỉnh các cấu hình này trên một hệ thống production xử lý hơn 200.000 bản ghi người dùng trên cả MySQL lẫn PostgreSQL. Bài viết này ghi lại những gì thực sự hoạt động — và những gì âm thầm phá hủy hiệu năng ở quy mô lớn.

Bắt đầu nhanh: Giải pháp 5 phút

Không có thời gian cho lý thuyết? Chọn database của bạn và chạy các lệnh sau.

MySQL: Dùng đúng Collation

MySQL kiểm soát độ nhạy dấu thông qua Collation. Chuyển cột của bạn sang utf8mb4_unicode_ci hoặc phiên bản mới hơn utf8mb4_0900_ai_ci là tìm kiếm không phân biệt dấu sẽ hoạt động ngay mà không cần thay đổi code ứng dụng.

-- Thay đổi collation cho bảng đã có sẵn
ALTER TABLE users 
MODIFY COLUMN full_name VARCHAR(255) 
CHARACTER SET utf8mb4 
COLLATE utf8mb4_unicode_ci;

Sau thao tác này, tìm kiếm 'nguyen' sẽ tự động khớp với 'Nguyễn'.

PostgreSQL: Extension Unaccent

PostgreSQL đi theo hướng khác. Thay vì xử lý ở tầng collation, nó dùng một extension loại bỏ dấu diacritics ngay tại thời điểm truy vấn.

-- Bật extension (yêu cầu quyền superuser)
CREATE EXTENSION IF NOT EXISTS unaccent;

-- Thử nghiệm
SELECT unaccent('Tiếng Việt có dấu'); 
-- Kết quả: 'Tieng Viet co dau'

Tìm hiểu sâu: Tìm kiếm tiếng Việt trong MySQL

MySQL giữ mọi thứ đơn giản. Hậu tố trong tên collation nói lên tất cả: _ci nghĩa là Không phân biệt hoa thường, _ai nghĩa là Không phân biệt dấu.

Tại sao nên dùng utf8mb4_0900_ai_ci?

Trên MySQL 8.0+, utf8mb4_0900_ai_ci là lựa chọn tốt nhất. Nó triển khai Unicode Collation Algorithm (UCA) 9.0.0. Engine xử lý đúng khi coi ‘a’, ‘á’ và ‘ạ’ là cùng một ký tự gốc — cho cả sắp xếp lẫn tìm kiếm.

Tuy nhiên có một lưu ý. LIKE '%keyword%' với bất kỳ collation nào vẫn kích hoạt full table scan. Trên bảng 500.000 hàng, điều đó có nghĩa là mỗi truy vấn mất vài giây. Hãy dùng Full-Text Index thay thế.

-- Thêm Full-Text index
ALTER TABLE articles ADD FULLTEXT(title, content);

-- Tìm kiếm dùng Boolean Mode
SELECT * FROM articles 
WHERE MATCH(title, content) AGAINST('nguyen' IN BOOLEAN MODE);

Tìm hiểu sâu: Tìm kiếm tiếng Việt trong PostgreSQL

PostgreSQL cho bạn khả năng kiểm soát nhiều hơn đáng kể. Nhưng sự đánh đổi cũng lớn hơn. Hàm unaccent rất mạnh — nếu dùng sai cách, nó trở thành thảm họa về hiệu năng.

Cái bẫy hiệu năng

Đừng bao giờ chạy lệnh này trên bảng có nhiều hơn vài nghìn hàng:

-- ĐỪNG LÀM THẾ NÀY trên bảng lớn
SELECT * FROM users 
WHERE unaccent(full_name) ILIKE unaccent('%Nguyễn%');

PostgreSQL tính toán unaccent() trên từng hàng trước khi so sánh. Trên bảng users có 200.000 hàng, truy vấn này chạy mất 4,2 giây trong các bài test của tôi. Hoàn toàn không thể dùng được cho ô tìm kiếm. Giải pháp là dùng Functional Index.

Tối ưu với Functional Index

Index kết quả của hàm, không phải giá trị cột thô. Sau đó truy vấn bằng đúng biểu thức đó.

-- Tạo index trên dữ liệu đã bỏ dấu
CREATE INDEX idx_users_unaccent_name 
ON users (unaccent(lower(full_name)));

-- Truy vấn dùng đúng biểu thức đó
SELECT * FROM users 
WHERE unaccent(lower(full_name)) LIKE unaccent(lower('Nguyễn%'));

Truy vấn đó giảm từ 4,2 giây xuống dưới 10 mili giây. Một chi tiết quan trọng: index chỉ kích hoạt khi biểu thức trong mệnh đề WHERE khớp chính xác — chỉ cần một biến thể nhỏ là index bị bỏ qua hoàn toàn.

Nâng cao: Full-Text Search (FTS)

Truy vấn LIKE trên một cột đáp ứng được phần lớn các trường hợp. Với tìm kiếm nhiều cột, hoặc khi cần xếp hạng kết quả theo độ liên quan, bạn cần Full-Text Search.

GIN Index trong PostgreSQL

GIN (Generalized Inverted Index) xử lý hiệu quả các truy vấn nhiều từ và xếp hạng theo độ liên quan. Kết hợp với unaccent là nó xử lý được cả dấu tiếng Việt.

-- Tạo cột search vector (tùy chọn nhưng khuyến nghị để tăng tốc)
ALTER TABLE articles ADD COLUMN search_vector tsvector;

-- Cập nhật với nội dung đã bỏ dấu
UPDATE articles SET search_vector = 
  to_tsvector('simple', unaccent(title || ' ' || content));

-- Tạo index
CREATE INDEX idx_articles_fts ON articles USING GIN (search_vector);

Từ điển 'simple' bỏ qua hoàn toàn việc stemming. Đây là điều cố ý. Ngữ pháp tiếng Việt không có stemming như tiếng Anh, và áp dụng stemming chung sẽ làm hỏng nhiều truy vấn hơn là giải quyết được.

Kinh nghiệm thực tế từ 6 tháng chạy production

1. Đánh đổi giữa dung lượng lưu trữ và CPU

Tính toán unaccent() lúc truy vấn vẫn ổn với bảng dưới 50.000 hàng. Vượt qua ngưỡng đó, thời gian truy vấn bắt đầu tăng — khoảng 300ms trên phần cứng của chúng tôi với sequential scan. Giải pháp của tôi: thêm một cột name_search riêng lưu giá trị đã xử lý sẵn (chữ thường, không dấu). Tốn thêm dung lượng đĩa, đúng. Nhưng truy vấn trở thành index lookup đơn giản, không cần gọi hàm nào lúc runtime.

2. Xử lý ký tự ‘đ’

Cái này đã khiến chúng tôi bị lỗi trên production. Các ký tự ‘đ’ và ‘Đ’ không thể phân tách — chúng không có diacritics kết hợp, đây là các codepoint Unicode độc lập. Các từ điển unaccent cũ bỏ qua chúng hoàn toàn. Cách sửa trong PostgreSQL là thêm quy tắc dịch tùy chỉnh:

-- Quy tắc unaccent tùy chỉnh (file unaccent.rules)
'đ' 'd'
'Đ' 'D'

Hãy kiểm tra kỹ trước khi triển khai. Dễ bỏ sót vì ‘d’ và ‘đ’ trông khá giống nhau khi nhìn qua.

3. Xử lý TSearch cho tiếng Việt

PostgreSQL đi kèm từ điển cho tiếng Anh, Pháp, Đức và khoảng 30 ngôn ngữ khác. Tiếng Việt không có trong danh sách đó. Từ điển simple kết hợp với unaccent xử lý đáng tin cậy cho hầu hết ứng dụng. Các thư viện stemming bên ngoài như vntoolkit có tồn tại, nhưng độ phức tạp thêm vào hiếm khi đáng công.

4. Tiền xử lý phía client

Code ứng dụng của bạn phải phản ánh đúng những gì database index thực hiện. Nếu index được xây dựng trên unaccent(lower(column)), thì câu truy vấn tìm kiếm phải áp dụng cùng phép biến đổi đó trước khi gửi lên database. Nếu không, index sẽ bị bỏ qua hoàn toàn. Ví dụ Python xử lý đúng ký tự ‘đ’:

import unicodedata

def normalize_search(text):
    # Loại bỏ diacritics kết hợp, sau đó xử lý riêng đ/Đ
    nfd = unicodedata.normalize('NFD', text)
    stripped = ''.join(c for c in nfd if unicodedata.category(c) != 'Mn')
    return stripped.replace('đ', 'd').replace('Đ', 'D').lower()

Bảng so sánh: MySQL vs. PostgreSQL

Tính năng MySQL PostgreSQL
Dễ sử dụng Cao (dựa trên Collation) Trung bình (dựa trên Extension)
Hiệu năng Tốt với FTS Index Xuất sắc với GIN/Functional Index
Linh hoạt Hạn chế Rất cao

Lựa chọn phụ thuộc vào stack hiện tại của bạn. Đang dùng MySQL? Nâng lên 8.0 và chuyển sang utf8mb4_0900_ai_ci — ít xáo trộn, cho kết quả ngay lập tức. Đang bắt đầu mới hoặc xây dựng ứng dụng nặng về tìm kiếm? Functional index và GIN search của PostgreSQL xứng đáng với độ phức tạp cài đặt ban đầu cao hơn khi làm việc với văn bản tiếng Việt.

Share: