Bỏ qua để đến nội dung
Search & RAG

3.9 Fuzzy search (3): làm cho nó nhanh

3.7 để lại một bài toán: tìm mọi term có khoảng cách ≤ k, không được quét cả dictionary. Có bốn cách, và chúng khác nhau ở chỗ chi phí nằm đâu — lúc index, lúc truy vấn, hay ở bộ nhớ.

1. BK-tree — dùng bất đẳng thức tam giác để cắt nhánh

Phần tiêu đề “1. BK-tree — dùng bất đẳng thức tam giác để cắt nhánh”

Edit distance là một metric, nên nó thoả bất đẳng thức tam giác. Từ đó:

d(q,node)d(node,child)    d(q,child)\bigl\lvert\, d(q, \text{node}) - d(\text{node}, \text{child}) \,\bigr\rvert \;\le\; d(q, \text{child})

Nghĩa là nếu đã biết d(q, node) = 3 và tìm với k = 1, thì mọi nhánh con có nhãn cạnh ngoài khoảng [2, 4] đều không thể chứa kết quả. Cắt luôn, không cần tính gì thêm.

Cây được dựng bằng cách: mỗi nút giữ một term, mỗi cạnh được gán nhãn bằng khoảng cách từ nút cha tới con.

mat
/ \
1/ \2
map mua
\
2\
bat

Truy vấn map, k = 1:

Nút thămd(q, nút)≤ k?Nhánh được đi tiếp
mat1✅ nhậncạnh trong [0, 2] → cả mapmua
map0✅ nhậncạnh trong [−1, 1] → bỏ nhánh cạnh 2 (bat)
mua2cạnh trong [1, 3] → không có con

bat không bao giờ được tính khoảng cách. Trên dictionary thật, BK-tree thăm cỡ vài phần trăm số nút.

Điểm mạnh: chỉ cần bộ nhớ ~1× dictionary, k không giới hạn. Điểm yếu: cây sâu, nhiều lần nhảy con trỏ ngẫu nhiên — không thân thiện với cache, khó phân tán.

Nhận xét: mọi lỗi (chèn/xoá/thay/đổi chỗ) đều có thể quy về so khớp sau khi xoá ở cả hai phía. Nên chỉ cần index các biến thể xoá — không cần sinh biến thể thay và chèn (vốn nhân với cỡ bảng chữ cái, tiếng Việt là ~90 ký tự có dấu).

Lúc indexVới mỗi term, sinh mọi biến thể xoá đến k ký tự → hash map biến thể → term gốc
Lúc truy vấnSinh biến thể xoá của truy vấn, tra hash. Trùng nhau → ứng viên

Chi phí bộ nhớ, tính bằng tay: term dài 10 ký tự, k = 2C(10,1) + C(10,2) = 10 + 45 = 55 biến thể cho một term. Với 500.000 term là ~27 triệu khoá. Đây là đánh đổi rất thẳng thắn: đổi RAM lấy latency, và latency đổi lại gần như O(1).

3. Levenshtein automaton — cách Lucene/Elasticsearch làm

Phần tiêu đề “3. Levenshtein automaton — cách Lucene/Elasticsearch làm”

Với truy vấn qk cho trước, dựng một automaton hữu hạn chấp nhận đúng tập chuỗi có khoảng cách ≤ k với q. Rồi giao automaton đó với FST của term dictionary (3.3).

Kết quả: đi một lượt song song hai cấu trúc, chỉ đi theo những nhánh tiền tố còn khả năng đúng. Không sinh ứng viên, không tính DP, không quét dictionary.

Đây là lý do fuzziness của Elasticsearch bị chặn cứng ở ≤ 2: kích thước automaton tăng theo k rất nhanh, và ở k = 3 phần thắng không còn bù được chi phí.

4. Chuẩn hoá trước — cách rẻ nhất, hay bị bỏ qua

Phần tiêu đề “4. Chuẩn hoá trước — cách rẻ nhất, hay bị bỏ qua”

Nếu lỗi có cấu trúc (mất dấu, teencode, viết tắt) thì nó không phải “lỗi ngẫu nhiên” và không nên chữa bằng fuzzy. Chữa bằng một hàm chuẩn hoá áp cho cả index và query — chi phí truy vấn bằng không. Với tiếng Việt đây thường là món đúng: 3.10.

CáchChi phí indexChi phí truy vấnRAMk tối đaChọn khi
Quét toàn bộ + DP0rất cao0dictionary < ~10k term, hoặc chạy offline
Trigram (3.8)trung bìnhthấptrung bình∞ (theo ngưỡng)đã có Postgres — pg_trgm là lựa chọn mặc định
BK-treetrung bìnhtrung bìnhthấpcần k lớn, một máy, dictionary vừa
SymSpellcaorất thấpcao2–3autocomplete/typeahead, latency là ràng buộc cứng
Levenshtein automatonthấpthấpthấp2đã dùng Elasticsearch/Lucene — có sẵn, dùng luôn
Chuẩn hoá trước~000lỗi có cấu trúc (mất dấu, teencode) — thử cách này trước

Thứ tự thử, từ rẻ đến đắt: (1) chuẩn hoá + bỏ dấu → (2) fuzzy chỉ khi truy vấn trả về 0 kết quả (fallback, không phải mặc định) → (3) trigram/automaton trên field ngắn → (4) embedding, vốn xử lý sai chính tả nhẹ khá tốt mà không cần cấu hình gì (Tầng 1).

Bật fuzzy làm mặc định cho mọi truy vấn là cách nhanh nhất để vừa mất precision vừa mất p95.

Phần 3 — Nền tảng kỹ thuật