GĐ11 — Search & Elasticsearch: inverted index, relevance, đồng bộ dữ liệu
Kiểm chứng ngày 2026-10-05: phần mã mới (mapping, truy vấn, xoá có version, bulk, ghi hai index) qua
tsc --strictvới@elastic/elasticsearch9.5.1; hàm phân loại lỗi bulk đã chạy với response giả. Chưa chạy với Elasticsearch thật, nên mọi "mong đợi" về kết quả_analyze, highlight hay 409 là suy ra từ tài liệu. Nguồn: index modules (gc_deletes), aliases, bulk, search (highlight.encoder), synonym_graph, search_as_you_type, asciifolding.
GĐ05 mục 14 đã cho bạn full-text search bằng Postgres
tsvector. Giai đoạn này trả lời câu hỏi tiếp theo: khi nào cái đó không đủ, và cái gì thay thế nó.Cảnh báo trước: một search engine riêng là một database thứ hai trong hệ thống của bạn. Nó phải được đồng bộ, backup, giám sát, và nó sẽ lệch với nguồn sự thật. Mục 6 của giai đoạn này — đồng bộ dữ liệu — là phần khó nhất và quan trọng nhất, quan trọng hơn cú pháp query rất nhiều.
1. Vì sao LIKE '%keyword%' không phải là search#
Bốn vấn đề, mỗi cái đủ để hỏng sản phẩm:
- Không dùng được index.
%ở đầu khiến B-tree vô dụng → quét toàn bảng. 100k sản phẩm là vài trăm ms; một triệu là vài giây. - Không có relevance. Kết quả không có thứ tự nào có ý nghĩa. Sản phẩm khớp đúng tên và sản phẩm khớp một chữ trong mô tả xếp ngang nhau.
- Không hiểu ngôn ngữ. Tìm "running" không ra "run". Tìm "chuột" ra cả "chuột máy tính" lẫn "bẫy chuột" mà không phân biệt được cái nào người dùng muốn.
- Không chịu được lỗi chính tả. "iphon" không ra "iPhone". Người dùng thật gõ sai rất nhiều.
Ba bậc thang giải pháp:
| Bậc | Công cụ | Đủ khi |
|---|---|---|
| 1 | ILIKE / pg_trgm | Vài nghìn bản ghi, tìm kiếm là tính năng phụ |
| 2 | Postgres tsvector + GIN | Tới ~vài triệu bản ghi, một ngôn ngữ, không cần facet phức tạp |
| 3 | Elasticsearch / OpenSearch / Meilisearch / Typesense | Relevance là tính năng cốt lõi của sản phẩm |
Đừng nhảy lên bậc 3 quá sớm. Bậc 2 đi xa hơn hầu hết người ta nghĩ. Nhưng cũng đừng cố ở bậc 2 khi search chính là sản phẩm của bạn.
2. Inverted index — cơ chế đằng sau mọi search engine#
Định nghĩa. Index thông thường (B-tree) ánh xạ document → nội dung. Inverted
index ánh xạ ngược lại: term → danh sách document chứa term đó.
Truy vấn "iphone pro" → giao/hợp hai danh sách → Doc 1 (khớp cả hai, điểm cao), Doc 3 và Doc 2 (khớp một, điểm thấp hơn). Đây là lý do search engine trả kết quả trong mili-giây trên hàng triệu document.
Analysis — bước biến văn bản thành term. Đây là nơi "hiểu ngôn ngữ" xảy ra:
Nguyên tắc sống còn: query phải đi qua cùng bộ analyzer với lúc index. Nếu
index dùng stemming mà query không, người dùng gõ "running" sẽ không khớp term
run đã lưu → không ra kết quả nào, và bạn sẽ ngồi debug rất lâu.
Pitfall #1 — analyzer sai cho tiếng Việt. Analyzer english stem theo quy tắc
tiếng Anh và sẽ phá tiếng Việt. Với tiếng Việt, dùng icu_analyzer (plugin ICU),
hoặc standard + asciifolding (để "Đà Nẵng" khớp "da nang"), hoặc plugin tách từ
tiếng Việt chuyên biệt. Kiểm tra bằng API _analyze trước khi index bất cứ thứ gì:
Analyzer tiếng Việt cụ thể: giữ dấu và bỏ dấu cùng lúc#
vi_analyzer ở trên chỉ là tên minh hoạ cho lệnh _analyze. Cách làm tối thiểu, không cần plugin: hai analyzer trên cùng một nội dung, nối bằng multi-field.
Truy vấn dùng cả hai: "fields": ["name^3", "name.folded"]. Người gõ có dấu khớp mạnh ở name; người gõ không dấu ("dien thoai") chỉ khớp được ở name.folded, điểm thấp hơn. Giữ cả hai vì bỏ dấu làm mất phân biệt (má, mà, ma thành cùng một term): để name chấm điểm cao hơn cho khớp đúng dấu.
Mong đợi: bốn term dien, thoai, da, nang (suy ra từ tài liệu asciifolding, chưa chạy; kiểm riêng ký tự Đ/đ bằng lệnh này trước khi tin). Giới hạn: tokenizer standard tách theo ranh giới từ Unicode (UAX #29), với văn bản tiếng Việt có dấu cách thì kết quả giống tách theo khoảng trắng, nên "điện thoại" thành hai term và "thoại điện" cũng khớp; muốn khớp theo cụm hãy thêm một mệnh đề match_phrase với boost. Plugin tách từ tiếng Việt nằm ngoài phạm vi giai đoạn này (xem câu hỏi mở cuối bài).
3. Relevance: điểm số được tính thế nào#
BM25 là thuật toán chấm điểm mặc định của Elasticsearch (thay thế TF-IDF từ phiên bản 5). Ba yếu tố:
| Yếu tố | Ý nghĩa | Trực giác |
|---|---|---|
| TF (term frequency) | Term xuất hiện bao nhiêu lần trong document | Nhiều lần → liên quan hơn, nhưng bão hoà |
| IDF (inverse document frequency) | Term hiếm tới mức nào trong toàn bộ tập | Term hiếm mang nhiều thông tin hơn |
| Field length | Document dài hay ngắn | Khớp trong tiêu đề 3 từ mạnh hơn khớp trong mô tả 500 từ |
Vì sao BM25 hơn TF-IDF: TF tăng tuyến tính trong TF-IDF, nên một trang spam lặp từ khoá 200 lần sẽ thắng. BM25 làm TF bão hoà — lần xuất hiện thứ 20 gần như không thêm điểm.
Điều chỉnh relevance trong thực tế:
name^3 nghĩa là khớp ở tên quan trọng gấp 3 lần khớp ở mô tả. Đây là công cụ
chỉnh relevance thông dụng nhất.
Business signal quan trọng hơn text relevance. Người dùng tìm "iphone" thường muốn sản phẩm bán chạy, còn hàng, đánh giá tốt — không phải sản phẩm có chữ "iphone" nhiều lần nhất:
Đo relevance, đừng đoán. Chỉ số thực tế: CTR@k (tỉ lệ click vào k kết quả đầu), zero-result rate (bao nhiêu % truy vấn không ra gì — chỉ số dễ đo và có giá trị nhất), và tập truy vấn vàng (100 query + kết quả mong đợi do người gán) để tính NDCG. Không có đo lường, mọi lần chỉnh boost là mê tín.
4. Query DSL — những thứ dùng 90% thời gian#
Query context vs Filter context — khác biệt quan trọng nhất:
| Trả lời | Có chấm điểm | Có cache | |
|---|---|---|---|
| Query | "Khớp tốt tới đâu?" | Có | Không |
| Filter | "Có khớp không? (đúng/sai)" | Không | Có |
Quy tắc: mọi thứ không cần chấm điểm phải nằm trong filter. Đây là tối ưu
hiệu năng dễ nhất và bị bỏ qua nhiều nhất.
text vs keyword — nhầm lẫn số một của người mới:
| Kiểu | Được analyze | Dùng để |
|---|---|---|
text | Có (tách từ, stem) | Full-text search: match |
keyword | Không (lưu nguyên chuỗi) | Filter chính xác, aggregation, sort |
Thường cần cả hai trên cùng một field:
→ match trên brand (tìm), terms aggregation trên brand.raw (facet).
Pitfall #2 — aggregation trên field text. Nó sẽ gom theo từng token đã
stem, không phải giá trị gốc. Facet "thương hiệu" của bạn sẽ hiện apple, inc
thay vì Apple Inc.. Luôn aggregate trên keyword.
Pitfall #3 — quên filter tenant_id. Trong app multi-tenant, một truy vấn
thiếu filter tenant là rò rỉ dữ liệu giữa khách hàng. Đừng dựa vào kỷ luật:
gói mọi truy vấn qua một hàm duy nhất luôn chèn filter tenant, và viết test chứng
minh (ma trận authorization → GĐ13).
Autocomplete, highlight và từ đồng nghĩa#
Autocomplete. Ba cách, chọn theo nhu cầu:
| Cách | Hợp khi | Chi phí |
|---|---|---|
search_as_you_type | gõ dở từ cuối, khớp được từ giữa chuỗi; mặc định nên thử trước | thêm các subfield _2gram, _3gram, _index_prefix |
edge_ngram (analyzer lúc index) | cần kiểm soát từng bước phân tích | index phình, phải tự đặt search_analyzer |
completion suggester | gợi ý theo tiền tố, rất nhanh | chỉ khớp từ đầu chuỗi, cấu trúc riêng trong bộ nhớ |
Với search_as_you_type, truy vấn là multi_match loại bool_prefix trên field gốc và hai subfield shingle. Vẫn phải có filter tenant:
Phía client phải debounce (gõ mỗi phím một truy vấn là cách tự tạo tải) và rate limit endpoint; name_suggest nhận cùng giá trị name khi dựng document.
Highlight. Bảo ES trả đoạn khớp đã bọc tag:
Fragment được trả về là văn bản của _source, tức dữ liệu người dùng nhập. Nếu description chứa <img src=x onerror=...> và frontend gán fragment vào innerHTML, đó là XSS. encoder: 'html' (giá trị hợp lệ là default hoặc html) bảo ES mã hoá HTML phần văn bản rồi mới chèn pre_tags/post_tags, nên fragment chỉ chứa văn bản đã escape cộng đúng hai tag <mark> bạn đặt; chọn một cách thôi, đừng escape thêm ở client vì sẽ thành &lt;. Hai điều kiện để an toàn: tag cố định do bạn đặt, và chỉ gán innerHTML cho fragment do highlight trả về. Nếu không có fragment (field không khớp) thì hiển thị _source bằng text node, đừng gán innerHTML. Phương án thay thế là encoder mặc định với tag không phải HTML (ví dụ [[ và ]]), escape fragment ở client rồi mới thay hai dấu đó bằng <mark>; khi đó không dùng encoder: 'html'. Highlight trên name.folded thay vì name vì field đó khớp cả truy vấn có dấu lẫn không dấu và vẫn trả văn bản gốc có dấu.
Từ đồng nghĩa (dt = điện thoại). Dùng synonym_graph và chỉ đặt trong search analyzer, không đặt lúc index (tài liệu ghi rõ filter này chỉ dành cho search analyzer; đồng nghĩa lúc index buộc phải reindex mỗi khi đổi luật). Đặt asciifolding trước synonym_graph trong chuỗi filter: ES dùng các filter đứng trước để phân tích chính các luật đồng nghĩa, nên luật viết dạng không dấu mới khớp.
Luật viết inline như trên muốn đổi thì phải đóng index (_close), cập nhật settings rồi mở lại (_open); trong lúc đó index tạm không phục vụ, nhưng không cần tạo lại index. Để đổi luật mà không đóng index, dùng synonyms_set: cập nhật bằng Synonyms API thì ES tự reload các search analyzer dùng set đó, không cần gọi thêm (tài liệu PUT _synonyms/{id}). Cờ "updateable": true của filter dành cho trường hợp tự gọi API reload search analyzer (xem tài liệu synonym_graph); chưa chạy với Elasticsearch thật.
Mức kiểm chứng: mapping và truy vấn trong mục này qua tsc --strict (kiểu IndicesCreateRequest, SearchRequest, SearchHighlight của @elastic/elasticsearch 9.5.1); chưa chạy với Elasticsearch thật.
5. Phân trang trong search — vì sao from/size gãy#
Elasticsearch chặn cứng from + size > 10.000. Đây không phải giới hạn tuỳ tiện.
Lý do cơ chế. ES phân tán: index chia thành nhiều shard. Để trả from: 9980, size: 20, mỗi shard phải trả về 10.000 kết quả đầu để node điều phối gộp và
sắp xếp. Với 5 shard là 50.000 document giữ trong bộ nhớ cho 20 kết quả. from: 1.000.000 sẽ giết cluster.
Đây chính xác là bài toán deep pagination đã gặp với OFFSET ở
GĐ09 — cùng nguyên nhân, cùng
cách chữa.
search_after — cursor pagination của Elasticsearch:
Không dùng _id làm tie-breaker. Tài liệu Elasticsearch ghi rõ trường _id bị hạn
chế trong aggregation, sort và script; cách chữa được khuyến nghị là chép giá trị
_id sang một trường khác có doc_values. Với id dạng chuỗi (UUID) đó là
keyword. Khi index, worker ghi thêm doc_id bằng đúng id của DB:
Bắt buộc có tie-breaker (doc_id) trong sort, và giá trị của nó phải duy
nhất trên mỗi document. Không có nó, hai document cùng created_at có thứ tự không
xác định → phân trang sẽ lặp hoặc bỏ sót bản ghi. Đây đúng là lý do cursor
pagination trên SQL cũng cần khoá phụ.
PIT (Point In Time) — khi cần một góc nhìn đóng băng. Cursor ở trên là stateless: mỗi trang truy vấn index ở trạng thái mới nhất, nên document bị sửa hoặc xoá giữa hai trang vẫn có thể làm trang lệch. Quét toàn bộ index (export, reindex) hoặc một phiên duyệt cần nhất quán thì mở PIT: ES giữ nguyên tập segment tại thời điểm mở.
Mọi request có PIT được ES tự thêm tie-breaker ngầm _shard_doc, nên khi quét có PIT
bạn có thể bỏ doc_id khỏi sort; giữ doc_id nếu cùng một truy vấn còn chạy không
có PIT. PIT không miễn phí: segment cũ không bị merge xoá khi PIT còn mở, tốn đĩa
và file handle. Vì vậy đừng giữ một PIT cho mỗi người dùng đang xem trang kết quả;
API công khai dùng cursor stateless, PIT dành cho job export/reindex và đóng ngay khi
xong. Không dùng scroll API (đã lỗi thời) và tuyệt đối không dùng from để quét.
Mức kiểm chứng: đối chiếu tài liệu Elasticsearch (trang paginate search results,
Open point in time API, _id field). Chưa chạy trên cluster thật vì máy kiểm tra
không có Elasticsearch.
6. Đồng bộ dữ liệu — phần khó nhất#
Sự thật cốt lõi: search index là bản sao phái sinh, không phải nguồn sự thật. Postgres là nguồn sự thật. Elasticsearch là view được tối ưu cho đọc. Nó sẽ lệch. Câu hỏi không phải "làm sao để không lệch" mà là "lệch bao lâu thì chấp nhận được, và làm sao phát hiện + sửa".
Bốn chiến lược:
| Cách | Cơ chế | Ưu | Nhược |
|---|---|---|---|
| Dual write | Ghi DB rồi ghi ES trong cùng handler | Đơn giản nhất | Không nguyên tử — ES lỗi là lệch vĩnh viễn |
| Outbox + worker | Ghi outbox cùng transaction, worker đẩy sang ES | Đáng tin, đã có sẵn từ GĐ10 | Trễ vài giây; phải vận hành worker |
| CDC (Debezium, Mongo change stream) | Đọc WAL/oplog của DB | Không đụng vào code ứng dụng | Hạ tầng nặng; ánh xạ schema phức tạp |
| Reindex định kỳ | Quét lại toàn bộ theo lịch | Đơn giản, tự chữa lệch | Trễ lâu; tốn tài nguyên |
Khuyến nghị: outbox + worker (chiến lược 2), cộng thêm reindex đối soát định kỳ (chiến lược 4) làm lưới an toàn. Bạn đã xây outbox ở GĐ10 mục 4 rồi — đây là ứng dụng thứ hai của nó.
version_type: 'external' giải quyết vấn đề nghiêm trọng. Sự kiện có thể đến
sai thứ tự (job A cập nhật lúc 10:00 retry và chạy sau job B lúc 10:01). Không
có version, phiên bản cũ ghi đè phiên bản mới và index sai vĩnh viễn. Với version,
ES từ chối ghi phiên bản cũ hơn.
Sơ đồ: luồng đồng bộ và sự kiện đến sai thứ tự
Hai điểm hay quên: worker phải đọc lại dòng hiện tại từ DB (không dùng payload cũ), và phải nuốt lỗi 409 (nếu không job sẽ retry vô hạn trên một lỗi vô hại). Code tham chiếu, chưa chạy với Elasticsearch thật:
Bulk API — bắt buộc cho reindex. Index từng document một là chậm gấp hàng chục lần:
Đối soát (reconciliation) — job không ai viết nhưng ai cũng cần. Chạy hàng đêm:
Metric search.index.drift là chỉ số vận hành quan trọng nhất của search.
Không có nó, bạn phát hiện lệch khi khách hàng phàn nàn.
Xoá có version, lỗi từng dòng của bulk và ghi vào hai index#
Xoá cũng phải mang version. Một es.delete không có version không được ES so với thứ tự các lần ghi khác, nên một job index cũ chạy muộn có thể tạo lại tài liệu đã xoá. Cách làm: xoá mềm (cột deletedAt, đặt luôn updatedAt bằng thời điểm xoá, xem GĐ14 mục 1) rồi dùng updatedAt làm version cho cả index lẫn delete, cùng một thước đo. Tombstone có hạn: ES chỉ giữ version của tài liệu đã xoá trong index.gc_deletes, mặc định 60 giây. Trong khoảng đó index có version thấp hơn bị từ chối (409); hết khoảng đó, một index cũ chạy muộn có thể tạo lại tài liệu (suy luận từ định nghĩa của tài liệu, chưa chạy). Vì worker luôn đọc lại dòng hiện tại từ DB (mục trên), cửa sổ rủi ro chỉ còn là một job đứng yên hơn 60 giây giữa lúc đọc và lúc ghi; đối soát đêm (mục này) bắt phần sót. Chỉ tăng gc_deletes khi đo được job thật sự đứng lâu hơn, vì nó giữ thêm tombstone trong bộ nhớ.
Bulk: đọc từng dòng. es.bulk trả HTTP 200 dù có dòng thất bại; cờ errors: true chỉ báo "có lỗi ở đâu đó". Kết quả từng dòng nằm trong items, theo đúng thứ tự gửi. Phân loại theo trạng thái: 409 là bản trong index đã mới hơn (bỏ qua), 429 và 5xx thử lại, 4xx còn lại (ví dụ mapping strict) là lỗi dữ liệu, không thử lại mà ghi log kèm id.
Ghi vào hai index khi reindex. Alias products chỉ nên là đường đọc. Alias trỏ một index thì ghi qua alias đi vào index đó; trỏ nhiều index mà không đặt is_write_index thì ghi bị từ chối, còn đặt thì chỉ một index nhận ghi (aliases). Cả hai cách đều không ghi được vào cả v3 và v4, nên worker ghi theo tên index thật, lấy danh sách từ cấu hình đọc mỗi job (ví dụ products_v3,products_v4 suốt cửa sổ reindex; sau khi đổi alias xong thì hạ về products_v4). Ghi lỗi một nơi thì cả job lỗi và retry; ghi lại an toàn nhờ version.
Mức kiểm chứng: tsc --strict sạch với @elastic/elasticsearch 9.5.1 (riêng dòng gom failed cuối bulkIndex được đổi sau lần kiểm đó, chưa chạy lại); sortBulkItems đã chạy với response giả năm dòng (201, 409, 429, 400, 200) cho retry = [p3], failed = [p4 / 400], conflicts = 1. Chưa chạy với Elasticsearch thật.
7. Vận hành: mapping, alias, reindex không downtime#
Mapping gần như bất biến. Đổi kiểu của một field đã tồn tại là không thể — phải tạo index mới và reindex. Vì vậy:
- Tắt
dynamic mappingcho index production:"dynamic": "strict". Nếu không, một field lạ lọt vào sẽ được ES tự đoán kiểu, đoán sai, và bạn kẹt với nó. - Đặt mapping tường minh ngay từ đầu, coi nó như migration.
Alias — kỹ thuật quan trọng nhất trong vận hành ES. Ứng dụng không bao giờ trỏ vào index thật; nó trỏ vào alias:
Reindex không downtime:
Sơ đồ: dòng thời gian reindex qua alias
Lý do dùng external: lô _reindex và worker cùng ghi vào v4, nên cả hai phải mang version
để bản cũ từ lô không đè bản mới của worker. Vì worker ghi vào v4 trong lúc reindex, xung đột
version là chắc chắn xảy ra; mặc định chúng làm _reindex dừng giữa chừng, nên cần
"conflicts": "proceed" và kiểm version_conflicts cùng failures trong kết quả task (409 ở đây
là bản trong v4 đã mới hơn, không phải lỗi). Đây là suy luận từ tài liệu Elasticsearch; chưa
chạy với Elasticsearch thật.
Shard: ít hơn bạn nghĩ. Mỗi shard là một Lucene index tốn RAM và file handle. Quy tắc: 10–50 GB mỗi shard. Index 5 GB cần đúng 1 shard, không phải 5. Số shard chính không đổi được sau khi tạo (chỉ replica đổi được) — thêm một lý do nữa để dùng alias.
Bảo mật, không thương lượng:
- Không bao giờ để ES ra Internet. Đã có nhiều vụ rò rỉ dữ liệu quy mô lớn vì cluster ES mở cổng 9200 không auth.
- Bật auth + TLS; app dùng API key có quyền tối thiểu (chỉ đọc/ghi index cần thiết).
- Không truyền query DSL từ client xuống ES. Client gửi tham số (
q,filters,page); server dựng DSL. Cho client gửi DSL nghĩa là cho họ chạy aggregation tuỳ ý trên toàn bộ dữ liệu, kể cả của tenant khác.
Chi phí. ES ngốn RAM (heap tối đa 31 GB — vượt ngưỡng này JVM mất compressed oops và hiệu năng giảm). Một cluster production tối thiểu ba node để có quorum. Đây là chi phí thật cần cân nhắc trước khi chọn bậc 3.
8. Elasticsearch vs các lựa chọn khác#
| Công cụ | Điểm mạnh | Điểm yếu | Chọn khi |
|---|---|---|---|
| Elasticsearch | Mạnh nhất, aggregation phong phú, hệ sinh thái lớn | Nặng, phức tạp, tốn RAM; license nhiều lớp (xem dưới bảng) | Cần sức mạnh đầy đủ, có người vận hành |
| OpenSearch | Fork của ES 7.10, Apache 2.0, do OpenSearch Software Foundation (Linux Foundation) quản trị; có dịch vụ AWS quản lý | Đi sau ES về tính năng mới | Cần license mở hoặc đã ở AWS |
| Meilisearch | Cực dễ dùng, typo-tolerance tuyệt vời, nhanh | Ít tính năng phân tích, quy mô nhỏ hơn | Search sản phẩm/tài liệu, đội nhỏ |
| Typesense | Nhanh, dễ, API sạch, có vector search | Hệ sinh thái nhỏ | Tương tự Meilisearch |
Postgres tsvector | Không thêm hạ tầng, nhất quán tức thì, có transaction | Không có typo-tolerance, relevance cơ bản | Mặc định — thử trước tiên |
| Algolia | SaaS, nhanh, không vận hành | Đắt theo lượng truy vấn | Có tiền, ưu tiên tốc độ ra mắt |
| Atlas Search | Lucene ngay trong MongoDB, không cần đồng bộ | Khoá vào Atlas | Đã dùng Mongo Atlas (→ GĐ06) |
Giấy phép Elasticsearch. Mã nguồn Elasticsearch phát hành theo ba giấy phép cho người dùng chọn: AGPLv3, SSPL 1.0 và Elastic License 2.0 (ELv2); phần x-pack và tính năng trả phí theo ELv2. Đừng viết "ES là SSPL" một mình: AGPLv3 là giấy phép mã nguồn mở được OSI công nhận, còn SSPL và ELv2 thì không. Nếu bạn đóng gói ES thành dịch vụ cho bên thứ ba, hãy đọc kỹ điều khoản của giấy phép bạn chọn. Đây là nguồn tham khảo, không phải tư vấn pháp lý.
Lời khuyên thẳng. Cho DA3: Meilisearch hoặc Typesense. Bạn học được toàn bộ khái niệm (inverted index, analyzer, relevance, facet, đồng bộ) với 1/10 chi phí vận hành. Chuyển sang ES khi thực sự cần aggregation phức tạp — khái niệm chuyển sang gần như nguyên vẹn.
Điều bạn nói khi phỏng vấn không phải "tôi biết Elasticsearch", mà: "tôi bắt đầu bằng Postgres FTS, đo được zero-result rate 12%, chuyển sang Meilisearch cho typo-tolerance, và đồng bộ bằng outbox + đối soát hàng đêm với metric drift." Câu đó chứng minh phán đoán, không phải danh sách công cụ.
9. Search ngữ nghĩa và hybrid — nối sang GĐ23#
Search từ khoá không hiểu ý nghĩa. Tìm "laptop cho lập trình viên" không khớp sản phẩm mô tả "máy tính xách tay RAM 32GB dành cho developer" nếu không trùng từ.
Vector search (→ GĐ23) giải quyết đúng chỗ
đó, nhưng lại kém ở chỗ search từ khoá mạnh: mã sản phẩm (SKU-4471), tên riêng,
số phiên bản — nơi khớp chính xác mới đúng.
Hybrid search kết hợp cả hai. Cách gộp phổ biến là Reciprocal Rank Fusion:
Ưu điểm của RRF là chỉ dùng thứ hạng, không dùng điểm số — nên không cần chuẩn hoá thang điểm giữa BM25 và cosine similarity, vốn là phần khó nhất khi gộp thủ công.
Elasticsearch có retriever rrf và linear, nhưng chúng cần giấy phép trả phí.
Cluster chạy giấy phép Basic (miễn phí) gọi retriever này sẽ nhận lỗi 403
security_exception. Với bản miễn phí, làm hybrid theo cách sau: chạy hai truy
vấn riêng (BM25 và kNN) rồi gộp ở phía server của bạn. RRF chỉ cần hai danh sách id đã
xếp hạng:
Ví dụ tính tay: kết quả mong đợi của rrf
Với rrf([['a','b','c'], ['c','a','d']]) và k = 60 (tính tay, chưa chạy):
Tài liệu nằm ở cả hai danh sách lên đầu dù không phải hạng 1 ở cả hai; điểm BM25 và cosine không bao giờ được đem so với nhau.
Postgres làm được tương tự bằng cách gộp kết quả tsvector và pgvector trong một
CTE. Chi tiết phần vector ở GĐ23.
Mức kiểm chứng: hàm rrf đã chạy thử bằng Node (tài liệu xuất hiện ở cả hai danh sách
lên đầu). Retriever rrf/linear cần giấy phép trả phí; tên gói cụ thể chưa xác minh,
xem trang subscription của Elastic trước khi dựa vào nó.
10. Bài tập — thêm search vào DA3#
Yêu cầu.
-
Chọn Meilisearch hoặc Typesense (hoặc ES nếu bạn muốn thử thách); chạy trong docker-compose.
Đáp án
Elasticsearch một node cho dev, bật bảo mật mặc định; không công khai cổng 9200 (mục 7). Chưa chạy: dùng compose của dự án, image theo tài liệu Elastic.
-
Index tài liệu/sản phẩm của DA3 với mapping/schema tường minh,
dynamic: strictnếu dùng ES.Đáp án
jsonReadyMong đợi: gửi document có field lạ thì nhận lỗi
strict_dynamic_mapping_exception(400). Các analyzer và subfieldfolded,name_suggestđược giải thích ở mục 2 và mục 4; phần mapping này quatsc --strictdưới dạngIndicesCreateRequest(chưa chạy với Elasticsearch). -
Đồng bộ bằng outbox + worker đã có từ GĐ10; dùng id của DB làm document id (idempotent); có version chống ghi đè ngược.
Đáp án
Dùng đúng đoạn
handleProductChangedở mục 6, thêm nuốt lỗi 409 (sơ đồ ở mục 6). Document id = id của DB,version = updatedAt(ms),version_type: 'external', vàdoc_idbằng id đó để làm tie-breaker. -
API
GET /search: full-text + filtertenant_idbắt buộc + facet theo ít nhất một trường + phân trang bằngsearch_after/cursor, không dùng offset.Đáp án
Server dựng DSL từ tham số đã validate; client không bao giờ gửi DSL.
typescriptReadyMong đợi: yêu cầu
?q=iphone&query[match_all]={}cho kết quả như?q=iphone. Lưu ý cursor theo_scorelà stateless: điểm có thể đổi khi dữ liệu đổi, nên trang có thể lệch nhẹ; chấp nhận được cho tìm kiếm, và nếu cần ổn định tuyệt đối thì sort theocreated_atnhư mục 5 hoặc dùng PIT. -
Boost: tên^3, mô tả^1; thêm một business signal (mới nhất, hoặc phổ biến nhất).
Đáp án
Boost
name^3,description^1vàfunction_scorevớifield_value_factortrênsales_countnằm trongbuildSearchở đáp án của yêu cầu 4. Tín hiệu "mới nhất" thay bằnggausstrêncreated_at. Mong đợi (chưa chạy): cùng từ khoá, sản phẩm bán chạy hơn xếp trên khi điểm text gần nhau. -
Chống lỗi chính tả (
fuzziness/ typo-tolerance) và kiểm tra bằng một truy vấn gõ sai.Đáp án
fuzziness: 'AUTO'đã có trongbuildSearchở đáp án của yêu cầu 4. Kiểm:?q=ipohne(đảo hai chữ liền kề tính là 1 phép sửa nhờfuzzy_transpositionsmặc định bật;AUTOcho tối đa 2 phép sửa với từ từ 6 ký tự) phải ra "iPhone". Mong đợi, chưa chạy. Từ ngắn (1 đến 2 ký tự)AUTOkhông cho sửa lỗi nào. -
Đo: ghi lại
zero-result ratevà p95 latency của search trước và sau khi chuyển từILIKE; đưa số vào README.Đáp án
Ghi log mỗi truy vấn:
{ q, tenantId, hits, took_ms }.textReadyChạy cùng một tập 200 đến 500 truy vấn thật (gồm cả truy vấn gõ sai) vào bản
ILIKErồi vào bản search mới; ghi hai bảng số vào README. Không bịa số: số của bạn là số đo trên dữ liệu của bạn. Dự đoán có cơ sở:ILIKE '%iphon%'vẫn khớp "iPhone" nhưngipohnera 0. -
Job đối soát hàng đêm so số lượng DB vs index, xuất metric
search.index.drift, tự reindex phần lệch.Đáp án
So tập id + updated_at, không chỉ số lượng: thiếu 1 và dư 1 cho số lượng bằng nhau.
typescriptReadyHàng bị
DELETEcứng không còn trong DB nên lọc theoupdated_atkhông thấy; chống bằng soft delete (códeletedAt) hoặc quét thêm danh sách id trong ES rồi hỏi DB. Mong đợi: xoá tay một document trong ES, chạy job:drift = 1, sau đó document trở lại vàdrift = 0ở lần chạy sau. -
Test: (a) tài liệu tenant A không xuất hiện trong kết quả của tenant B; (b) tài liệu bị xoá biến khỏi index trong vòng N giây; (c) phân trang không lặp/không sót khi có bản ghi mới chèn vào giữa.
Đáp án
(a) Tạo document tenant A và B cùng từ "iphone", gọi
/searchbằng token của B: mọihits[*]._source.tenant_idphải bằng B. Gỡ dòng filter tenant khỏibuildSearchthì test phải đỏ. (b) Xoá sản phẩm, chờ có giới hạn (poll tối đa N giây, khôngsleepcố định, xem GĐ13 mục 13) tới khi/searchkhông còn thấy; nhớrefreshmặc định là 1 giây nên "biến khỏi index" cần cộng độ trễ này. (c) Seed 50 document, lấy trang 1, chèn thêm 5 document cócreated_atmới hơn, lấy trang 2 bằngsearch_after: không id nào lặp và không id nào của 50 gốc bị thiếu khi gộp các trang (sortcreated_at desc, doc_id asc). -
Chạy quy trình reindex không downtime bằng alias và ghi lại các bước đã làm.
Đáp án
Làm đúng 5 bước ở mục 7, thêm
version_type: externalvà"conflicts": "proceed"ở_reindex, và ghi lại: thời điểm tạo v2,task id, kết quả đối soát (drift = 0), thời điểm đổi alias, và lệnh rollback. Mong đợi: trong lúc chạy,GET /searchvẫn trả 200; sau khi đổi alias,GET /_alias/productschỉ còn v2. -
Thêm autocomplete (
search_as_you_type), highlight cóencoder: 'html'và một nhóm từ đồng nghĩa; kiểm bằng ba truy vấn.Đáp án
Dùng
buildSuggest, khốihighlightvà analyzervi_folded_searchở mục 4 (phần autocomplete, highlight và từ đồng nghĩa), cùng mapping ở yêu cầu 2. Ba kiểm tra, kết quả mong đợi suy ra từ tài liệu (chưa chạy với Elasticsearch):textReadyKiểm tra 2 phải đỏ nếu bỏ
encodervà client gáninnerHTMLthô. Kiểm tra 3 phải đỏ nếu bỏvi_synonymskhỏivi_folded_search. -
Xoá có version, xử lý lỗi từng dòng của bulk và ghi vào hai index trong lúc reindex.
Đáp án
Dùng
handleProductChangedvàsortBulkItemsở mục 6 (phần xoá có version). Kiểm tra:textReadyKiểm tra 2 đã chạy với response giả (kết quả trên khớp); kiểm tra 1 và 3 cần Elasticsearch thật, chưa chạy.
Khung và mã dùng chung
Tự làm trước, rồi mới mở. Toàn bộ code dưới đây là code tham chiếu, chưa chạy (không có
Elasticsearch trên máy kiểm tra; đối chiếu tài liệu Elasticsearch 9 và client @elastic/elasticsearch 9).
Chọn Elasticsearch ở đây vì các mục 5 đến 7 đều viết cho nó; với Meilisearch/Typesense khái
niệm giữ nguyên, chỉ đổi cú pháp.
Sơ đồ.
Lỗi hay gặp: tenant lấy từ query string; cursor không validate; quên 409 nên job retry mãi;
đổi mapping trực tiếp thay vì tạo index mới; đối soát chỉ đếm; from để quét; để lộ cổng 9200.
Mục 7 và 8 là hai mục quan trọng nhất. Ai cũng index được dữ liệu; rất ít người đo được search có tốt lên không và phát hiện được index bị lệch.
Done khi#
-
Nêu được 4 lý do
LIKE '%x%'không phải searchĐáp án
Không dùng được B-tree (quét toàn bảng); không có relevance; không hiểu ngôn ngữ (running/run); không chịu lỗi chính tả (iphon). Sai thường gặp: chỉ nêu "chậm". Xem mục 1.
-
Định vị được bài toán của mình trên thang 3 bậc và bảo vệ được lựa chọn
Đáp án
Bậc 1
ILIKE/pg_trgm(vài nghìn bản ghi), bậc 2tsvector+ GIN (tới vài triệu, một ngôn ngữ), bậc 3 search engine khi relevance là tính năng cốt lõi. Lập luận mẫu: "search là tính năng phụ, 200k bản ghi, một ngôn ngữ → bậc 2; zero-result rate đo được > 10% và cần typo-tolerance → lên bậc 3". Sai thường gặp: nhảy bậc 3 vì "chuyên nghiệp hơn". -
Vẽ được inverted index cho 3 document
Đáp án
Cột trái là term, cột phải là danh sách id: ví dụ mục 2 (
iphone → [1,3],pro → [1,2]). Tự kiểm: tách đúng từ, viết thường, truy vấn "iphone pro" ra Doc 1 điểm cao nhất vì khớp cả hai term. -
Giải thích chuỗi analysis (char filter → tokenizer → token filter) và vì sao index và query phải cùng analyzer
Đáp án
Character filter → tokenizer → token filter (lowercase, stop, stem). Index lưu
run; nếu query không stem thì "running" không khớp. Kiểm: gọi_analyzevới cùng analyzer cho cả hai phía. -
Biết analyzer
englishphá tiếng Việt; biết dùng_analyzeđể kiểm traĐáp án
Nó stem theo luật tiếng Anh và bỏ stop word Anh; dùng
icu_analyzerhoặcstandard+asciifolding. Kiểm:POST /products/_analyzevới "Điện thoại Đà Nẵng", token phải là từ nguyên vẹn (hoặc đã bỏ dấu), không bị cắt vụn. -
Giải thích BM25 qua TF / IDF / field length; biết vì sao BM25 hơn TF-IDF
Đáp án
TF bão hoà (lần thứ 20 gần như không thêm điểm), IDF thưởng term hiếm, field ngắn mạnh hơn field dài. TF-IDF cho TF tăng tuyến tính nên spam lặp từ khoá thắng. Xem mục 3.
-
Dùng boost theo field và function score theo tín hiệu nghiệp vụ
Đáp án
name^3nhân điểm khớp ở tên;function_score+field_value_factortrộn tín hiệu nghiệp vụ (sales_count,in_stock) vớiboost_mode: multiply. Sai thường gặp: boost quá lớn làm text relevance vô nghĩa. -
Đo relevance bằng zero-result rate và CTR@k, không chỉnh boost theo cảm tính
Đáp án
Zero-result rate = % truy vấn không có kết quả (dễ đo nhất); CTR@k = tỉ lệ click vào k kết quả đầu; thêm tập truy vấn vàng để tính NDCG. Mỗi lần đổi boost phải so số trước/sau.
-
Phân biệt query context vs filter context; đặt đúng thứ vào
filterĐáp án
Query chấm điểm, không cache; filter chỉ đúng/sai, không chấm điểm, được cache. Mọi điều kiện không cần điểm (tenant, status, khoảng giá) vào
filter;mustchỉ giữ phần full-text. -
Phân biệt
textvskeyword; biết vì sao aggregation phải trênkeywordĐáp án
textđược analyze (đểmatch);keywordnguyên chuỗi (filter, sort, aggregation). Aggregation trêntextgom theo token đã stem (apple,inc), nên dùngbrand.raw. Xem mục 4. -
Biết vì sao
from + sizechặn ở 10.000 (lý do phân tán) và dùngsearch_after+ tie-breaker là trườngkeyword(không phải_id); biết khi nào cần PITĐáp án
Mỗi shard phải trả
from + sizekết quả để node điều phối gộp, nên chặn 10.000. Dùngsortcódoc_id(keyword, bản sao của_id) vàsearch_after. PIT khi cần góc nhìn đóng băng (export, reindex), không mở cho mỗi người dùng. Sai thường gặp: sort theo_id. -
Coi search index là bản sao phái sinh, không phải nguồn sự thật
Đáp án
Nguồn sự thật là Postgres; index có thể lệch nên phải có cách phát hiện và sửa. Câu trả lời mẫu: "lệch bao lâu chấp nhận được, phát hiện bằng metric drift, sửa bằng reindex phần lệch".
-
Đồng bộ bằng outbox; dùng external version chống ghi đè ngược thứ tự
Đáp án
Ghi outbox cùng transaction, worker đọc lại DB rồi
es.indexvớiversion = updatedAt,version_type: external; bản cũ nhận 409 và bị bỏ qua. Cảbulkvà_reindexcũng phải mang version. Xem sơ đồ ở mục 6. -
Có job đối soát và metric drift
Đáp án
Hằng đêm so id +
updated_attheo khoảng (không chỉ đếm), xuấtsearch.index.drift, reindex phần lệch. Tự kiểm: xoá tay một document trong ES, chạy job, thấy drift = 1 rồi về 0. -
Dùng alias và biết quy trình reindex không downtime 5 bước
Đáp án
Tạo v mới →
_reindex→ bắt kịp (worker ghi cả hai) → đổi alias nguyên tử → giữ index cũ rồi xoá. Tự kiểm: trong lúc chạy/searchvẫn 200, sau đó alias chỉ trỏ index mới. -
Biết quy tắc kích thước shard và vì sao số shard không đổi được
Đáp án
10 đến 50 GB mỗi shard; index 5 GB cần 1 shard. Số primary shard không đổi được sau khi tạo (chỉ replica đổi được), nên đổi bằng reindex qua alias.
-
Không bao giờ để ES ra Internet; không cho client gửi query DSL
Đáp án
Bật auth + TLS, API key quyền tối thiểu; server dựng DSL từ tham số đã validate (xem
buildSearchở bài tập). Tự kiểm: gửi?query[match_all]={}không đổi kết quả. -
So sánh được ES / OpenSearch / Meilisearch / Typesense / Postgres FTS và chọn có lý lẽ
Đáp án
ES (đầy đủ, nặng, giấy phép nhiều lớp), OpenSearch (Apache 2.0, do OpenSearch Software Foundation quản trị), Meilisearch/Typesense (đơn giản, typo-tolerance), Postgres FTS (mặc định, nhất quán tức thì). Chọn theo bậc thang ở mục 1, không theo tên.
-
Giải thích hybrid search và RRF; biết vì sao RRF né được vấn đề chuẩn hoá điểm, và retriever
rrfcủa ES cần giấy phép trả phí nên có cách gộp phía clientĐáp án
Hybrid = BM25 + vector; RRF chỉ dùng thứ hạng (
Σ 1/(k+rank)) nên khỏi chuẩn hoá thang điểm. Retrieverrrfcủa ES cần giấy phép trả phí (Basic gọi sẽ 403) nên chạy hai truy vấn rồi gộp phía server. Ví dụ tính tay ở mục 9. -
Dựng được analyzer giữ dấu và bỏ dấu cho tiếng Việt và giải thích vì sao
synonym_graphchỉ đặt ở search analyzerĐáp án
namedùngvi_text(giữ dấu),name.foldeddùngvi_folded(thêmasciifolding), truy vấn trên cả hai.synonym_graphchỉ dành cho search analyzer: đặt lúc index thì đổi luật phải reindex, và luật chỉ có tác dụng với dữ liệu ghi sau đó. Tự kiểm:_analyzevớivi_foldedtrên "Điện thoại Đà Nẵng" chodien,thoai,da,nang(mong đợi, chưa chạy). -
Highlight không mở lỗ XSS: nêu được vì sao fragment là dữ liệu người dùng và
encoder: 'html'chưa đủ nếu client gáninnerHTMLthôĐáp án
Fragment lấy từ
_source, tức văn bản người dùng nhập; ES chèn tag highlight vào đó. Chọn một trong hai cách, không kết hợp: (a)encoder: 'html'vớipre_tags/post_tagscố định, ES đã escape văn bản nên client không escape lần nữa (escape hai lần ra&lt;); phần chưa có fragment, lấy từ_source, hiển thị bằng text node; (b) encoder mặc định, tag[[/]], client escape fragment rồi mới thay tag bằng<mark>. "Chưa đủ" nghĩa là: bỏencoderhoặc gáninnerHTMLcho chuỗi không đi qua highlight (như_sourcethô) thì vẫn XSS. Tự kiểm: document chứa<img onerror=...>không thực thi script khi hiển thị kết quả. -
Xoá tài liệu khỏi index bằng version
externalvà giải thíchindex.gc_deletesĐáp án
Xoá mềm đặt
updatedAt, rồies.deletevớiversion = updatedAtvàversion_type: 'external', cùng thước đo vớiindex. ES giữ version của tài liệu đã xoá trongindex.gc_deletes(mặc định 60 giây); hết hạn thì mộtindexcũ muộn có thể tạo lại tài liệu, nên worker đọc lại DB trước khi ghi và đối soát đêm bắt phần sót. -
Đọc
response.itemscủa bulk và phân loại 409 (bỏ qua), 429 và 5xx (thử lại), 4xx còn lại (lỗi dữ liệu)Đáp án
errors: truechỉ báo có lỗi; trạng thái từng dòng nằm ởitems[i], cùng thứ tự gửi.sortBulkItemsở mục 6 trảretry,failed,conflicts. Tự kiểm: với năm dòng giả (201, 409, 429, 400, 200) kết quả làretrymột dòng,failedmột dòng,conflictsbằng 1 (đã chạy). -
Reindex qua alias mà worker ghi vào cả hai index theo tên thật
Đáp án
Alias trỏ nhiều index mà không có
is_write_indextừ chối ghi, còn có thì chỉ một index nhận ghi; vì vậy worker ghi theo danh sách tên index lấy từ cấu hình trong suốt cửa sổ reindex rồi hạ về một index sau khi đổi alias. Tự kiểm: ghi một tài liệu, thấy có ở cảproducts_v3vàproducts_v4(cần Elasticsearch thật, chưa chạy).
Câu hỏi mở / chưa giải quyết#
-
Learning to Rank (dùng ML xếp hạng lại top-N bằng tín hiệu hành vi) là bước tiếp theo tự nhiên, nhưng cần dữ liệu click ở quy mô đủ lớn. Ngoài phạm vi lộ trình.
Hướng trả lời hiện tại (chưa chốt)
Chưa làm Learning to Rank; chỉ ghi log impression và click từ ngày đầu để sau này có dữ liệu, và quay lại khi có đủ lượng click (ngưỡng cụ thể chưa có nguồn).
-
Query understanding — sửa chính tả, mở rộng đồng nghĩa, phát hiện ý định — thường mang lại nhiều hơn việc chỉnh relevance, nhưng cần log truy vấn thật. Bắt đầu bằng việc ghi log mọi truy vấn và kết quả rỗng ngay từ ngày đầu.
Hướng trả lời hiện tại (chưa chốt)
Bắt đầu bằng danh sách truy vấn rỗng hay gặp nhất, thêm đồng nghĩa thủ công, đo lại zero-result rate.
-
Search tiếng Việt vẫn là vấn đề chưa có lời giải sạch: tách từ tiếng Việt khó, các plugin chất lượng không đồng đều. Với dữ liệu nhỏ,
asciifolding+pg_trgmđôi khi cho kết quả thực dụng tốt hơn một pipeline phức tạp.Hướng trả lời hiện tại (chưa chốt)
Thử
asciifolding(vàpg_trgmbên Postgres) trước, so với tập truy vấn vàng; chỉ thêm plugin tách từ khi số đo cho thấy cần.