Giao diện
Tìm kiếm tiếng Việt
Vấn đề
SQLite trong D1 không có hàm unaccent, và tokenizer unicode61 của FTS5 coi "chuyên" và "chuyen" là hai từ khác nhau. Trong khi đó người Việt tra cứu rất hay gõ không dấu.
Cách giải quyết
Chuẩn hoá ở tầng ứng dụng, cả khi ghi lẫn khi đọc.
ts
// packages/shared/src/text.ts
export function normalize(input: string): string {
return input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // bỏ dấu thanh và dấu phụ
.replace(/đ/g, 'd').replace(/Đ/g, 'D')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
}- Khi dựng chỉ mục: mọi văn bản đưa vào
search_fts.bodyđều đi quanormalize(). - Khi truy vấn: câu người dùng nhập cũng đi qua
normalize()rồi chuyển thành biểu thứcMATCH.
Nhờ vậy "chuyên sư phạm", "chuyen su pham" và "CHUYÊN SƯ PHẠM" cho cùng kết quả.
Biểu thức MATCH
ts
export function toFtsQuery(input: string): string {
const tokens = normalize(input).replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(Boolean);
if (!tokens.length) return '';
return tokens.map((t, i) => (i === tokens.length - 1 ? `${t}*` : t)).join(' AND ');
}Mọi token đều bắt buộc (AND), riêng token cuối dùng prefix-match * để gợi ý hoạt động ngay khi người dùng đang gõ dở.
Cấu trúc chỉ mục
sql
CREATE TABLE search_docs (
id INTEGER PRIMARY KEY, entity_type TEXT, entity_id INTEGER,
slug TEXT, title TEXT, subtitle TEXT, url TEXT,
UNIQUE (entity_type, entity_id)
);
CREATE VIRTUAL TABLE search_fts USING fts5(body, content='');search_fts là bảng contentless — nó chỉ giữ chỉ mục ngược, không lưu lại văn bản, nên nhẹ. rowid của nó luôn bằng search_docs.id, và truy vấn join hai bảng qua khoá đó:
sql
SELECT d.*, bm25(search_fts) AS rank
FROM search_fts f JOIN search_docs d ON d.id = f.rowid
WHERE search_fts MATCH ? ORDER BY rank LIMIT ?;bm25() trả về giá trị âm, càng nhỏ càng khớp — nên ORDER BY rank (tăng dần) là đúng chiều.
Dựng lại chỉ mục
Hai đường, dùng chung một logic khái niệm:
- Lúc phát triển:
scripts/build-seed.mjssinh sẵnseed/search.sqlđểdb:reset:localchạy hoàn toàn ngoại tuyến. - Trên môi trường chạy thật:
POST /v1/admin/reindexđọc lại dữ liệu gốc và ghi lại chỉ mục theo lô 200 câu lệnh mỗidb.batch().
Hai bản normalize()
build-seed.mjs chạy bằng Node thuần nên không import được file .ts; nó giữ một bản sao của normalize(). Nếu sửa hàm trong packages/shared, phải chép sang script đó, nếu không chỉ mục và câu truy vấn sẽ bỏ dấu khác nhau và tìm kiếm sẽ trả về rỗng một cách khó hiểu.