Tìm kiếm tiếng Việt với Elasticsearch: bỏ dấu, n-gram và thứ tự bộ lọc

Tìm tiếng Việt có dấu hay không dấu đều khớp: asciifolding, vì sao stopword phải đứng trước nó, n-gram cho mã thiết bị, phân trang search_after.

· Đăng ngày · 6 phút đọc · Phiên bản: Elasticsearch 8.19

Người dùng Việt gõ tìm kiếm theo đủ kiểu: có dấu, không dấu, gõ dở nửa chữ, gõ một đoạn mã thiết bị. Ô tìm kiếm phải hiểu "may sieu am" là "Máy siêu âm", và "SAMS" là một phần của mã "SA-SAMSUNG".

Bài này đi qua cấu hình Elasticsearch cho danh mục thiết bị trong hệ thống quản lý mua sắm vật tư bệnh viện của mình: analyzer, câu truy vấn, phân trang sâu, cách dựng lại chỉ mục, và một lỗi thứ tự bộ lọc mà mình chỉ phát hiện ra khi soát lại cấu hình để viết bài này.

Bỏ dấu bằng asciifolding

Bộ lọc asciifolding đổi ký tự có dấu về dạng ASCII: "Máy siêu âm" thành may, sieu, am. Nó xử lý cả chữ đ, ra d. Áp dụng cùng một analyzer khi ghi chỉ mục và khi tìm, thì gõ có dấu hay không dấu đều khớp:

vietnamese_analyzer: {
  type: 'custom',
  tokenizer: 'standard',
  filter: ['lowercase', 'asciifolding', 'vietnamese_stop'],
},

Cái giá là mất phân biệt giữa các từ chỉ khác dấu: "lá", "là", "la" thành một. Với tên thiết bị thì gần như vô hại. Nếu cần xếp bản khớp đúng dấu lên trên, có thể giữ thêm một trường con không bỏ dấu và cộng điểm khi khớp trường đó.

Một giới hạn khác của standard tokenizer với tiếng Việt: nó tách theo âm tiết, không theo từ. "Máy siêu âm" là ba token, không phải hai từ "máy" và "siêu âm". Với danh mục thiết bị, kết hợp thêm truy vấn cụm từ (xem dưới) là đủ. Nếu cần tách từ thật sự, cộng đồng có plugin phân tích tiếng Việt dựa trên bộ tách từ của Cốc Cốc, đổi lại là phải cài plugin vào cluster.

Thứ tự bộ lọc: stopword phải đứng trước asciifolding

Cấu hình trên có thêm một bộ lọc stopword với các từ như "và", "của", "cho", "có", "là". Nhìn thì hợp lý, nhưng thực ra nó không bao giờ khớp.

Lý do: các bộ lọc chạy theo thứ tự. Khi token đến vietnamese_stop, asciifolding đã biến "và" thành va, "của" thành cua. Danh sách stopword vẫn ghi có dấu, nên so sánh nào cũng trượt. Không có lỗi nào báo ra, tìm kiếm vẫn chạy, chỉ là bộ lọc thành vô dụng.

Cách sửa ngây thơ là viết lại danh sách không dấu. Nhưng sau khi bỏ dấu thì co vừa là "có" vừa là "cơ", "cổ"; la vừa là "là" vừa là "lá"; cho vừa là "cho" vừa là "chó". Loại co sẽ xoá luôn những từ có nghĩa. Thứ tự đúng là loại stopword khi chữ còn dấu, rồi mới bỏ dấu:

filter: ['lowercase', 'vietnamese_stop', 'asciifolding'],
Token Của đi qua hai thứ tự bộ lọc. Thứ tự cũ: lowercase cho ra của, asciifolding cho ra cua, và vietnamese_stop giữ nguyên cua vì danh sách ghi của chứ không ghi cua. Thứ tự đúng: lowercase cho ra của, và vietnamese_stop loại nó trước khi asciifolding chạy.Thứ tự cũdanh sách không bao giờ khớpCủalowercasecủaasciifoldingcuavietnamese_stopcuacòn nguyên: vì danh sáchghi “của”, không ghi “cua”Thứ tự đúngloại stopword trướcCủalowercasecủavietnamese_stopbị loạiasciifolding chỉ cònnhững từ có nghĩa
Các bộ lọc chạy theo thứ tự: danh sách stopword có dấu phải gặp token trước khi asciifolding bỏ dấu.

Hai bài học chung:

  • Với mọi bộ lọc dựa trên danh sách (stopword, synonym), hãy tự hỏi: lúc token đến bộ lọc này, nó trông như thế nào?
  • Kiểm tra analyzer bằng API _analyze thay vì đoán. Một lệnh là thấy ngay token nào còn lại:
POST /equipments/_analyze
{ "analyzer": "vietnamese_analyzer", "text": "Máy thở và monitor của ICU" }

Tìm theo một phần mã bằng n-gram

Mã thiết bị như "SA-SAMSUNG" hay "ECG-FUKUDA" thường được gõ dở: "sams", "fuku". Analyzer thông thường chỉ khớp nguyên token, nên cần thêm một trường con n-gram: khi ghi chỉ mục, mỗi token được cắt thành mọi đoạn con dài 2 đến 10 ký tự.

ngram_tokenizer: { type: 'ngram', min_gram: 2, max_gram: 10, token_chars: ['letter', 'digit'] },

modelNumber: {
  type: 'text',
  analyzer: 'vietnamese_analyzer',
  fields: {
    keyword: { type: 'keyword' },
    ngram: { type: 'text', analyzer: 'ngram_analyzer', search_analyzer: 'ngram_search' },
  },
},

Điểm quan trọng là search_analyzer khác analyzer: lúc ghi thì cắt n-gram, lúc tìm thì không cắt, chỉ chuẩn hoá chữ thường và bỏ dấu. Nếu cắt n-gram cả chuỗi tìm kiếm, "sams" sẽ thành sa, am, ms... và khớp với rất nhiều thứ không liên quan.

Ba điều cần biết khi dùng n-gram:

  • Chỉ mục phình to. Một token dài 10 ký tự sinh ra 45 n-gram. Chỉ nên dùng cho vài trường ngắn cần tìm một phần (tên, mã), không cho mô tả dài.
  • Token tìm kiếm dài hơn max_gram sẽ không khớp trường n-gram, vì không n-gram nào dài đến vậy. Đây là lý do không nên để n-gram là cách tìm duy nhất.
  • Elasticsearch mặc định chỉ cho max_gram - min_gram tối đa là 1; muốn khoảng rộng hơn phải nâng index.max_ngram_diff.

Một truy vấn, ba cách khớp

Câu truy vấn gộp ba cách khớp trong một bool.should, chỉ cần một cách khớp là đủ:

should: [
  // 1. Đủ mọi từ, rải trên nhiều trường: "siêu âm samsung"
  { multi_match: { query, type: 'cross_fields', operator: 'and',
      fields: ['equipmentName^3', 'modelNumber^2', 'manufacturer^2', 'unit', 'origin'] } },
  // 2. Gõ dở từ cuối: "máy siêu â"
  { multi_match: { query, type: 'phrase_prefix',
      fields: ['equipmentName^3', 'modelNumber^2', 'manufacturer^2'] } },
  // 3. Một phần của tên hoặc mã: "sams"
  { multi_match: { query, operator: 'and',
      fields: ['equipmentName.ngram', 'modelNumber.ngram'] } },
],
minimum_should_match: 1,
  • cross_fields coi các trường như một trường lớn, nên "siêu âm samsung" khớp dù "siêu âm" nằm ở tên còn "samsung" nằm ở hãng.
  • phrase_prefix xử lý trường hợp người dùng đang gõ dở, rất hợp với ô tìm kiếm gõ tới đâu lọc tới đó.
  • Trọng số ^3, ^2 đẩy kết quả khớp ở tên lên trên kết quả khớp ở xuất xứ hay đơn vị tính.

Phần lọc theo khoa và theo phạm vi người dùng đặt trong filter chứ không phải must: filter không tham gia tính điểm và được Elasticsearch cache, phù hợp với điều kiện kiểu có hoặc không.

Phân trang sâu với search_after

Phân trang bằng from + size bị giới hạn bởi index.max_result_window, mặc định 10.000 kết quả, vì mỗi shard phải lấy và sắp xếp from + size bản ghi. Vượt quá là Elasticsearch báo lỗi.

Cách thay thế là search_after: mỗi trang trả về giá trị sắp xếp của bản ghi cuối, trang sau bắt đầu từ đó.

sort: [
  { [sortKey]: { order } },
  { id: { order: 'asc' } },   // phá hòa, để thứ tự luôn xác định
],
search_after: lastSort,       // lấy từ trang trước

Hai chi tiết dễ sót:

  • Luôn có một trường phá hoà duy nhất (như id) ở cuối sort. Nếu nhiều bản ghi trùng giá trị sắp xếp, thiếu nó thì có bản ghi bị lặp hoặc bị bỏ qua giữa hai trang.
  • search_after chỉ đi tuần tự: muốn tới trang 3 phải biết con trỏ của trang 2. Trong dự án, giao diện ghi nhớ con trỏ của từng trang đã đi qua, dùng from cho các trang đầu và chuyển sang con trỏ khi đi sâu. Nếu muốn nhảy thẳng tới một trang xa, câu trả lời thường là thêm bộ lọc chứ không phải phân trang sâu hơn.

Database là nguồn sự thật, chỉ mục dựng lại được

Thiết bị được ghi vào MySQL trước, sau khi transaction commit mới đẩy sang Elasticsearch. Cách ghi hai nơi này đơn giản, nhưng nếu bước đẩy sang chỉ mục lỗi thì chỉ mục sẽ thiếu. Vì vậy cần một script reindex dựng lại toàn bộ chỉ mục từ database. Vài kỹ thuật trong script đáng dùng lại:

  • Phân trang theo khoá (WHERE id > lastId ORDER BY id LIMIT n) thay cho OFFSET: tốc độ mỗi lô không chậm dần khi đi sâu vào bảng.
  • Nạp trước các bảng nhỏ (khoa phòng, hoạt động) vào Map để ghép tên vào tài liệu, thay vì join cho từng lô.
  • Tắt refresh trong lúc bulk (refresh_interval: -1) rồi bật lại khi xong, để Elasticsearch không phải làm mới chỉ mục sau mỗi lô.

Script hiện tại xoá chỉ mục cũ rồi tạo mới, nên trong lúc chạy, tìm kiếm sẽ trống. Với hệ thống cần chạy liên tục, cách làm chuẩn là dựng chỉ mục mới bên cạnh rồi chuyển alias sang nó trong một thao tác: người dùng luôn tìm qua alias và không bao giờ thấy chỉ mục dở dang. Còn nếu muốn chỉ mục không bao giờ lệch database, các hướng tiếp theo là outbox pattern hoặc CDC (đọc binlog), đổi lại là thêm một thành phần phải vận hành.

Tóm tắt

  • Bỏ dấu bằng asciifolding, nhưng đặt các bộ lọc dựa trên danh sách trước nó.
  • Dùng n-gram cho tìm một phần, với search_analyzer riêng, và chỉ trên vài trường ngắn.
  • Gộp cross_fields, phrase_prefix và n-gram để phủ các kiểu gõ khác nhau.
  • Phân trang sâu bằng search_after với một trường phá hoà.
  • Coi chỉ mục là bản sao dựng lại được, và kiểm analyzer bằng _analyze thay vì đoán.

Tìm kiếm tiếng Việt còn nhiều cách làm khác, nhất là phần tách từ. Nếu bạn đã thử hướng nào hiệu quả, mình rất muốn được trao đổi. Email của mình ở trang chủ.