Skip to content

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 qua normalize().
  • 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ức MATCH.

Nhờ vậy "chuyên sư phạm", "chuyen su pham""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.mjs sinh sẵn seed/search.sql để db:reset:local chạ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ỗi db.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.

Tài liệu nội bộ dự án Chuyên Chọn.