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 --strict với @elastic/elasticsearch 9.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.


textReady
SELECT * FROM products WHERE name ILIKE '%iphon%';

Bốn vấn đề, mỗi cái đủ để hỏng sản phẩm:

  1. 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.
  2. 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.
  3. 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.
  4. 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ậcCông cụĐủ khi
1ILIKE / pg_trgmVài nghìn bản ghi, tìm kiếm là tính năng phụ
2Postgres tsvector + GINTới ~vài triệu bản ghi, một ngôn ngữ, không cần facet phức tạp
3Elasticsearch / OpenSearch / Meilisearch / TypesenseRelevance 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 đó.

textReady
Doc 1: "iPhone 15 Pro Max"Doc 2: "Samsung Galaxy S24 Pro"Doc 3: "iPhone case leather"Inverted index:  iphone  → [1, 3]  15      → [1]  pro     → [1, 2]  max     → [1]  samsung → [2]  case    → [3]

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:

textReady
"The Running Shoes!"  → character filter   : bỏ HTML, chuẩn hoá ký tự  → tokenizer          : ["The", "Running", "Shoes"]  → token filter       : lowercase       → ["the", "running", "shoes"]                         stop words      → ["running", "shoes"]                         stemming        → ["run", "shoe"]  → term lưu vào index : run, shoe

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ì:

jsonReady
POST /products/_analyze{ "analyzer": "vi_analyzer", "text": "Điện thoại Đà Nẵng" }

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.

jsonReady
"analysis": { "analyzer": {  "vi_text":   { "type": "custom", "tokenizer": "standard", "filter": ["lowercase"] },  "vi_folded": { "type": "custom", "tokenizer": "standard", "filter": ["lowercase", "asciifolding"] }} },"name": { "type": "text", "analyzer": "vi_text",          "fields": { "folded": { "type": "text", "analyzer": "vi_folded" } } }

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.

jsonReady
POST /products_v1/_analyze{ "analyzer": "vi_folded", "text": "Điện thoại Đà Nẵng" }

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ĩaTrực giác
TF (term frequency)Term xuất hiện bao nhiêu lần trong documentNhiề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ậpTerm hiếm mang nhiều thông tin hơn
Field lengthDocument dài hay ngắnKhớ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ế:

jsonReady
{  "query": {    "multi_match": {      "query": "iphone pro",      "fields": ["name^3", "brand^2", "description"],   // ^ = boost      "type": "best_fields",      "fuzziness": "AUTO"                                // chịu lỗi chính 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:

jsonReady
{  "query": {    "function_score": {      "query": { "multi_match": { "query": "iphone", "fields": ["name^3", "description"] }},      "functions": [        { "field_value_factor": { "field": "sales_count", "modifier": "log1p", "factor": 0.5 }},        { "filter": { "term": { "in_stock": true }}, "weight": 2 }      ],      "boost_mode": "multiply"    }  }}

Đ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ờiCó chấm điểmCó cache
Query"Khớp tốt tới đâu?"CóKhông
Filter"Có khớp không? (đúng/sai)"KhôngCó
jsonReady
{  "query": {    "bool": {      "must":   [{ "multi_match": { "query": "laptop gaming", "fields": ["name^3", "description"] }}],      "filter": [                                  // KHÔNG chấm điểm → nhanh hơn, được cache        { "term":  { "tenant_id": "t_123" }},      // multi-tenant: LUÔN filter        { "term":  { "status": "published" }},        { "range": { "price_minor": { "gte": 10000000, "lte": 50000000 }}}      ],      "should":  [{ "term": { "brand": "asus" }}], // tăng điểm nếu khớp, không bắt buộc      "must_not":[{ "term": { "discontinued": true }}]    }  },  "aggs": {    "by_brand": { "terms": { "field": "brand", "size": 20 }},    "price_ranges": { "range": { "field": "price_minor", "ranges": [      { "to": 10000000 }, { "from": 10000000, "to": 30000000 }, { "from": 30000000 }    ]}}  },  "size": 20,  "sort": ["_score", { "doc_id": "asc" }]          // doc_id: xem mục 5, KHÔNG sort theo _id}

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 analyzeDùng để
textCó (tách từ, stem)Full-text search: match
keywordKhô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:

jsonReady
{ "brand": {    "type": "text",    "fields": { "raw": { "type": "keyword" }}}}

→ 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áchHợp khiChi phí
search_as_you_typegõ dở từ cuối, khớp được từ giữa chuỗi; mặc định nên thử trướcthê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íchindex phình, phải tự đặt search_analyzer
completion suggestergợi ý theo tiền tố, rất nhanhchỉ 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:

typescriptReady
function buildSuggest(q: string, ctx: { tenantId: string }) {  return {    index: 'products', size: 8, _source: ['doc_id', 'name'],    query: { bool: {      must: [{ multi_match: { query: q, type: 'bool_prefix',        fields: ['name_suggest', 'name_suggest._2gram', 'name_suggest._3gram'] } }],      filter: [{ term: { tenant_id: ctx.tenantId } }],    } },  }}

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:

typescriptReady
highlight: {  pre_tags: ['<mark>'], post_tags: ['</mark>'],  encoder: 'html',  fields: { 'name.folded': { number_of_fragments: 0 }, description: { fragment_size: 150, number_of_fragments: 1 } },}

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 &amp;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.

jsonReady
"analyzer": { "vi_folded_search": { "type": "custom", "tokenizer": "standard",  "filter": ["lowercase", "asciifolding", "vi_synonyms"] } },"filter": { "vi_synonyms": { "type": "synonym_graph",  "synonyms": ["dt, dien thoai, smartphone", "laptop, may tinh xach tay"] } },"name": { "fields": { "folded": { "type": "text", "analyzer": "vi_folded",                                   "search_analyzer": "vi_folded_search" } } }

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:

jsonReady
// mapping{ "properties": { "doc_id": { "type": "keyword" }, "created_at": { "type": "date" } } }// document: { "doc_id": "abc123", "created_at": "2026-06-01T10:00:00Z", ... }
jsonReady
// Trang 1{ "size": 20, "sort": [{ "created_at": "desc" }, { "doc_id": "asc" }] }// → kết quả cuối có "sort": [1717000000000, "abc123"]// Trang 2 — truyền giá trị sort cuối cùng{ "size": 20, "sort": [{ "created_at": "desc" }, { "doc_id": "asc" }],  "search_after": [1717000000000, "abc123"] }

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ở.

jsonReady
POST /products/_pit?keep_alive=1m// → { "id": "46ToAwMD..." }// Mỗi trang: KHÔNG ghi tên index trong đường dẫn (index nằm trong PIT)POST /_search{  "size": 1000,  "query": { "match_all": {} },  "pit": { "id": "46ToAwMD...", "keep_alive": "1m" },  "sort": [{ "created_at": "asc" }, { "doc_id": "asc" }],  "search_after": [1717000000000, "abc123"]}// Response có "pit_id" — có thể KHÁC id bạn vừa gửi. Luôn dùng id nhận được gần nhất.DELETE /_pit{ "id": "<pit_id mới nhất>" }

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áchCơ chếƯuNhược
Dual writeGhi DB rồi ghi ES trong cùng handlerĐơn giản nhấtKhông nguyên tử — ES lỗi là lệch vĩnh viễn
Outbox + workerGhi outbox cùng transaction, worker đẩy sang ESĐáng tin, đã có sẵn từ GĐ10Trễ vài giây; phải vận hành worker
CDC (Debezium, Mongo change stream)Đọc WAL/oplog của DBKhông đụng vào code ứng dụngHạ 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ệchTrễ 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ó.

typescriptReady
// Worker tiêu thụ sự kiện outboxasync function handleProductChanged({ productId }: { productId: string }) {  const product = await db.product.findUnique({    where: { id: productId }, include: { brand: true, categories: true },  })  if (!product) {                                  // đã bị xoá cứng: không còn version để so    await es.delete({ index: 'products', id: productId }).catch(ignore404)    return  }  if (product.deletedAt) {                         // xoá mềm: xoá CÓ version, xem phần xoá có version bên dưới    await es.delete({ index: 'products', id: product.id,      version: product.updatedAt.getTime(), version_type: 'external' }).catch(ignore(404, 409))     // helper ignore: xem đoạn mã trong phần xoá có version    return  }  await es.index({    index: 'products',    id: product.id,                                // dùng id của DB → ghi đè, idempotent    document: toSearchDocument(product),           // gồm doc_id: product.id (mục 5)    version: product.updatedAt.getTime(),          // chống ghi đè ngược    version_type: 'external',  })}

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ự
textReady
 API (1 transaction)     Relay          Queue        Worker           ES UPDATE product ──┐ INSERT outbox  ──┴COMMIT─► đọc outbox ─► job A(v=10:00) ┐ UPDATE product (10:01) ... ► job B(v=10:01) ─────────────┤ chạy trước                                                           ├─► index v=10:01 OK                                                           └─► job A retry muộn:                                                               index v=10:00                                                               ES: 409 conflict Worker: 409 = "đã có bản mới hơn" => coi là thành công, KHÔNG retry.

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:

typescriptReady
await es.index({ index: 'products', id: product.id, document, version: v, version_type: 'external' })  .catch((e) => { if (e?.meta?.statusCode !== 409) throw e })

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:

typescriptReady
// Cùng cơ chế version như worker: không có version, lô reindex sẽ ghi đè vô điều kiện// lên bản mới hơn mà worker vừa ghi.await es.bulk({ operations: batch.flatMap(d => [  { index: { _index: 'products', _id: d.id,             version: d.updatedAt.getTime(), version_type: 'external' }}, toSearchDocument(d),])})// Kết quả từng dòng nằm trong response.items; status 409 = bản trong index mới hơn (bỏ qua được).

Đối soát (reconciliation) — job không ai viết nhưng ai cũng cần. Chạy hàng đêm:

typescriptReady
// Ví dụ rút gọn: chỉ so số lượng theo khoảng. Số lượng bằng nhau vẫn có thể lệch// (thiếu 1 bản ghi + dư 1 bản ghi); bản đầy đủ so danh sách id + updated_at (xem bài tập mục 10)const dbCount = await db.product.count({ where: { updatedAt: { gte: since }}})const esCount = await es.count({ index: 'products', query: { range: { updated_at: { gte: since }}}})if (Math.abs(dbCount - esCount) > 0) {  logger.warn({ dbCount, esCount }, 'search index drift detected')  metrics.gauge('search.index.drift', dbCount - esCount)  await reindexRange(since)}

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.

typescriptReady
// Bỏ qua lỗi theo mã HTTP, ném lại mọi lỗi khácconst ignore = (...codes: number[]) => (e: unknown) => {  const status = (e as { meta?: { statusCode?: number } })?.meta?.statusCode  if (status === undefined || !codes.includes(status)) throw e}// Danh sách index ghi đích, đọc từ cấu hình MỖI job, ví dụ ['products_v3', 'products_v4']declare function writeIndices(): Promise<string[]>export async function handleProductChanged({ productId }: { productId: string }) {  const p = await db.product.findUnique({ where: { id: productId } })   // client KHÔNG lọc deletedAt  const targets = await writeIndices()  const results = await Promise.allSettled(targets.map((index) => {    if (!p) return es.delete({ index, id: productId }).catch(ignore(404))    const version = p.updatedAt.getTime()     // xoá mềm cũng đặt updatedAt = thời điểm xoá    return p.deletedAt      ? es.delete({ index, id: p.id, version, version_type: 'external' }).catch(ignore(404, 409))      : es.index({ index, id: p.id, document: toSearchDocument(p), version, version_type: 'external' })          .catch(ignore(409))  }))  const failed = results.find((r) => r.status === 'rejected')  if (failed) throw failed.reason          // BullMQ retry; ghi lại an toàn vì có version}
typescriptReady
type Doc = { id: string; updatedAt: Date }export type BulkOutcome = { retry: Doc[]; failed: { id: string; status: number; reason?: string }[]; conflicts: number }// items[i] ứng với batch[i]: ES trả kết quả theo đúng thứ tự gửi.export function sortBulkItems(batch: Doc[], res: estypes.BulkResponse): BulkOutcome {  const out: BulkOutcome = { retry: [], failed: [], conflicts: 0 }  if (!res.errors) return out  res.items.forEach((item, i) => {    const r = Object.values(item)[0]!            // mỗi item có đúng một khoá: index | create | update | delete    const doc = batch[i]!    if (r.status < 300) return    if (r.status === 409) out.conflicts++        // bản trong index đã mới hơn: bỏ qua    else if (r.status === 429 || r.status >= 500) out.retry.push(doc)    else out.failed.push({ id: doc.id, status: r.status, reason: r.error?.reason ?? undefined })  })  return out}export async function bulkIndex(index: string, batch: Doc[], attempt = 0): Promise<BulkOutcome['failed']> {  const res = await es.bulk({ operations: batch.flatMap((d) => [    { index: { _index: index, _id: d.id, version: d.updatedAt.getTime(), version_type: 'external' as const } },    toSearchDocument(d),  ]) })  const { retry, failed } = sortBulkItems(batch, res)  if (retry.length && attempt < 3) {    await new Promise((r) => setTimeout(r, 500 * 2 ** attempt))    failed.push(...(await bulkIndex(index, retry, attempt + 1)))  } else failed.push(...retry.map((d) => ({ id: d.id, status: 0, reason: 'het luot retry (429 hoac 5xx)' })))  return failed                                  // ghi log + metric; đừng nuốt im lặng}

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 mapping cho 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:

textReady
app → alias "products" → index "products_v3"

Reindex không downtime:

bashReady
# 1. Tạo index mới với mapping mớiPUT /products_v4  { "mappings": { ... } }# 2. Reindex từ cũ sang mới (chạy nền, theo dõi qua task API)POST /_reindex?wait_for_completion=false{ "conflicts": "proceed",    # 409 là bình thường: bản trong v4 đã mới hơn thì bỏ qua  "source": { "index": "products_v3" },  "dest": { "index": "products_v4", "version_type": "external" } }   # chỉ ghi bản mới hơn# 3. Bắt kịp phần thay đổi trong lúc reindex (worker outbox ghi vào CẢ HAI index,#    theo tên index thật; xem "ghi vào hai index" ở mục 6)# 4. Đổi alias — NGUYÊN TỬ, không có khoảnh khắc nào alias không trỏ vào đâuPOST /_aliases{ "actions": [  { "remove": { "index": "products_v3", "alias": "products" }},  { "add":    { "index": "products_v4", "alias": "products" }}]}# 5. Giữ v3 vài ngày để rollback được, rồi xoá
Sơ đồ: dòng thời gian reindex qua alias
textReady
 bước  alias trỏ   worker ghi   việc đang chạy 1     v3          v3            tạo v4 với mapping mới 2     v3          v3 và v4      _reindex v3 -> v4 (nền) 3     v3          v3 và v4      đối soát id + updated_at 4     v3 -> v4    v3 và v4      POST /_aliases (remove + add) 5     v4          v4            giữ v3 vài ngày; rollback = đổi 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ếuChọn khi
ElasticsearchMạnh nhất, aggregation phong phú, hệ sinh thái lớnNặ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
OpenSearchFork 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ớiCần license mở hoặc đã ở AWS
MeilisearchCực dễ dùng, typo-tolerance tuyệt vời, nhanhÍt tính năng phân tích, quy mô nhỏ hơnSearch sản phẩm/tài liệu, đội nhỏ
TypesenseNhanh, dễ, API sạch, có vector searchHệ sinh thái nhỏTương tự Meilisearch
Postgres tsvectorKhông thêm hạ tầng, nhất quán tức thì, có transactionKhông có typo-tolerance, relevance cơ bảnMặc định — thử trước tiên
AlgoliaSaaS, nhanh, không vận hànhĐắt theo lượng truy vấnCó tiền, ưu tiên tốc độ ra mắt
Atlas SearchLucene 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:

textReady
score(d) = Σ  1 / (k + rank_i(d))        với k ≈ 60

Ư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:

typescriptReady
// Gộp N danh sách đã xếp hạng (mỗi danh sách là mảng id, tốt nhất ở đầu)function rrf(rankings: string[][], k = 60): string[] {  const score = new Map<string, number>()  for (const list of rankings)    list.forEach((id, i) => score.set(id, (score.get(id) ?? 0) + 1 / (k + i + 1)))  return [...score.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id)}const [bm25Ids, knnIds] = await Promise.all([searchBm25(q), embed(q).then(searchKnn)])const merged = rrf([bm25Ids, knnIds]).slice(0, 20)
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):

textReady
 a: 1/61 + 1/62 = 0,03252     (hạng 1 ở danh sách đầu, hạng 2 ở danh sách sau) c: 1/63 + 1/61 = 0,03227 b: 1/62        = 0,01613     (chỉ có ở một danh sách) d: 1/63        = 0,01587 => ['a', 'c', 'b', 'd']

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.

  1. 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.

  2. Index tài liệu/sản phẩm của DA3 với mapping/schema tường minh, dynamic: strict nếu dùng ES.

    Đáp án
    jsonReady
    PUT /products_v1{ "settings": { "number_of_shards": 1, "number_of_replicas": 0,    "analysis": {      "analyzer": {        "vi_text":   { "type": "custom", "tokenizer": "standard", "filter": ["lowercase"] },        "vi_folded": { "type": "custom", "tokenizer": "standard", "filter": ["lowercase", "asciifolding"] },        "vi_folded_search": { "type": "custom", "tokenizer": "standard",                              "filter": ["lowercase", "asciifolding", "vi_synonyms"] } },      "filter": { "vi_synonyms": { "type": "synonym_graph",                    "synonyms": ["dt, dien thoai, smartphone", "laptop, may tinh xach tay"] } } } },  "mappings": { "dynamic": "strict", "properties": {    "doc_id":     { "type": "keyword" },    "tenant_id":  { "type": "keyword" },    "name":       { "type": "text", "analyzer": "vi_text", "fields": {                     "folded": { "type": "text", "analyzer": "vi_folded", "search_analyzer": "vi_folded_search" } } },    "name_suggest": { "type": "search_as_you_type", "analyzer": "vi_folded" },    "description":{ "type": "text", "analyzer": "vi_text" },    "brand":      { "type": "text", "fields": { "raw": { "type": "keyword" } } },    "sales_count":{ "type": "integer" },    "created_at": { "type": "date" },    "updated_at": { "type": "date" } } } }POST /_aliases{ "actions": [{ "add": { "index": "products_v1", "alias": "products" } }] }

    Mong đợi: gửi document có field lạ thì nhận lỗi strict_dynamic_mapping_exception (400). Các analyzer và subfield folded, name_suggest được giải thích ở mục 2 và mục 4; phần mapping này qua tsc --strict dưới dạng IndicesCreateRequest (chưa chạy với Elasticsearch).

  3. Đồ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_id bằng id đó để làm tie-breaker.

  4. API GET /search: full-text + filter tenant_id bắt buộc + facet theo ít nhất một trường + phân trang bằng search_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.

    typescriptReady
    import { z } from 'zod'const Params = z.object({  q: z.string().trim().min(1).max(200),  brand: z.array(z.string().max(100)).max(10).optional(),  cursor: z.string().max(500).optional(),  size: z.coerce.number().int().min(1).max(50).default(20),})   // key lạ (ví dụ "query") bị zod bỏ, không đi tiếp xuống EStype Cursor = [number, string]            // [_score, doc_id]const enc = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64url')function dec(s: string): Cursor {  const v = JSON.parse(Buffer.from(s, 'base64url').toString())  if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== 'number' || typeof v[1] !== 'string')    throw new Error('bad cursor')          // cursor do client giữ: luôn validate hình dạng  return v as Cursor}export function buildSearch(raw: unknown, ctx: { tenantId: string }) {  const p = Params.parse(raw)  return {    index: 'products', size: p.size,    query: { function_score: {      query: { bool: {        must: [{ multi_match: { query: p.q, fuzziness: 'AUTO',                                fields: ['name^3', 'name.folded^2', 'description^1'] } }],        filter: [{ term: { tenant_id: ctx.tenantId } },          // luôn có, chỉ từ ctx                 ...(p.brand ? [{ terms: { 'brand.raw': p.brand } }] : [])],      } },      functions: [{ field_value_factor: { field: 'sales_count', modifier: 'log1p', factor: 0.5 } }],      boost_mode: 'multiply',    } },    aggs: { by_brand: { terms: { field: 'brand.raw', size: 20 } } },    sort: [{ _score: 'desc' }, { doc_id: 'asc' }],    ...(p.cursor ? { search_after: dec(p.cursor) } : {}),  }}// handler: const r = await es.search(buildSearch(req.query, { tenantId: req.user.tenantId }))//          const last = r.hits.hits.at(-1); nextCursor = last && enc(last.sort as Cursor)

    Mong đợi: yêu cầu ?q=iphone&query[match_all]={} cho kết quả như ?q=iphone. Lưu ý cursor theo _score là 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 theo created_at như mục 5 hoặc dùng PIT.

  5. 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^1 và function_score với field_value_factor trên sales_count nằm trong buildSearch ở đáp án của yêu cầu 4. Tín hiệu "mới nhất" thay bằng gauss trên created_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.

  6. 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ó trong buildSearch ở đá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_transpositions mặc định bật; AUTO cho 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ự) AUTO không cho sửa lỗi nào.

  7. Đo: ghi lại zero-result rate và 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 }.

    textReady
    zero-result rate = số truy vấn hits = 0 / tổng truy vấnp95              = phân vị 95 của took_ms (hoặc thời gian đo ở API)

    Chạ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 ILIKE rồ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ưng ipohne ra 0.

  8. 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.

    typescriptReady
    async function reconcile(from: Date, to: Date) {  const dbRows = await db.product.findMany({    where: { updatedAt: { gte: from, lt: to } }, select: { id: true, updatedAt: true },  })  const esRows = await scanRange(from, to)   // PIT + search_after, _source: doc_id, updated_at  const inEs = new Map(esRows.map((r) => [r.doc_id, Date.parse(r.updated_at)]))  const bad = dbRows.filter((r) => (inEs.get(r.id) ?? -1) < r.updatedAt.getTime())  // thiếu hoặc cũ  metrics.gauge('search.index.drift', bad.length)  for (const r of bad) await queue.add('product-changed', { productId: r.id }, { jobId: `recon-${r.id}-${r.updatedAt.getTime()}` })}

    Hàng bị DELETE cứng không còn trong DB nên lọc theo updated_at khô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.

  9. 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 /search bằng token của B: mọi hits[*]._source.tenant_id phải bằng B. Gỡ dòng filter tenant khỏi buildSearch thì test phải đỏ. (b) Xoá sản phẩm, chờ có giới hạn (poll tối đa N giây, không sleep cố định, xem GĐ13 mục 13) tới khi /search không còn thấy; nhớ refresh mặ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_at mới hơn, lấy trang 2 bằng search_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 (sort created_at desc, doc_id asc).

  10. 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: external và "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 /search vẫn trả 200; sau khi đổi alias, GET /_alias/products chỉ còn v2.

  11. 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ối highlight và analyzer vi_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):

    textReady
    1. GET /suggest?q=dien tho   -> có "Điện thoại ..." (tiền tố, không dấu)2. description = "<img src=x onerror=alert(1)> iphone"   GET /search?q=iphone      -> fragment không chứa thẻ img thô                                (dạng &lt;img) và có <mark>iphone</mark>3. GET /search?q=dt          -> ra sản phẩm "điện thoại" nhờ synonym_graph

    Kiểm tra 2 phải đỏ nếu bỏ encoder và client gán innerHTML thô. Kiểm tra 3 phải đỏ nếu bỏ vi_synonyms khỏi vi_folded_search.

  12. 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 handleProductChanged và sortBulkItems ở mục 6 (phần xoá có version). Kiểm tra:

    textReady
    1. xoá mềm (updatedAt = 10:02), rồi index muộn với version 10:01   -> 409 trong vòng gc_deletes (60 s), tài liệu không hiện lại2. bulk 5 dòng (201, 409, 429, 400, 200)   -> retry = [dòng 3], failed = [dòng 4], conflicts = 13. cấu hình ghi = products_v3,products_v4, ghi một tài liệu   -> có ở cả hai index; tắt v4 thì job lỗi và retry, v3 không bị ghi hỏng

    Kiể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ơ đồ.

textReady
 client ─► GET /search?q=&brand=&cursor=             │  tenantId lấy từ token (KHÔNG từ query string)             ▼        buildSearch()  ── DSL do server dựng ──► alias "products" ─► products_v1 API ─ 1 transaction: ghi bảng + outbox ─► relay ─► queue ─► worker                                                      └► es.index(external) cron đêm: so id + updated_at (DB vs ES) ─► metric search.index.drift                                          └► reindex phần lệch

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 2 tsvector + 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 _analyze với cùng analyzer cho cả hai phía.

  • Biết analyzer english phá 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_analyzer hoặc standard + asciifolding. Kiểm: POST /products/_analyze vớ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^3 nhân điểm khớp ở tên; function_score + field_value_factor trộn tín hiệu nghiệp vụ (sales_count, in_stock) với boost_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; must chỉ giữ phần full-text.

  • Phân biệt text vs keyword; biết vì sao aggregation phải trên keyword

    Đáp án

    text được analyze (để match); keyword nguyên chuỗi (filter, sort, aggregation). Aggregation trên text gom theo token đã stem (apple, inc), nên dùng brand.raw. Xem mục 4.

  • Biết vì sao from + size chặn ở 10.000 (lý do phân tán) và dùng search_after + tie-breaker là trường keyword (không phải _id); biết khi nào cần PIT

    Đáp án

    Mỗi shard phải trả from + size kết quả để node điều phối gộp, nên chặn 10.000. Dùng sort có 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.index với version = updatedAt, version_type: external; bản cũ nhận 409 và bị bỏ qua. Cả bulk và _reindex cũ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_at theo khoảng (không chỉ đếm), xuất search.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 /search vẫ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 rrf củ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. Retriever rrf củ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_graph chỉ đặt ở search analyzer

    Đáp án

    name dùng vi_text (giữ dấu), name.folded dùng vi_folded (thêm asciifolding), truy vấn trên cả hai. synonym_graph chỉ 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: _analyze với vi_folded trên "Điện thoại Đà Nẵng" cho dien, 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án innerHTML thô

    Đá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ới pre_tags/post_tags cố định, ES đã escape văn bản nên client không escape lần nữa (escape hai lần ra &amp;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ỏ encoder hoặc gán innerHTML cho chuỗi không đi qua highlight (như _source thô) 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 external và giải thích index.gc_deletes

    Đáp án

    Xoá mềm đặt updatedAt, rồi es.delete với version = updatedAt và version_type: 'external', cùng thước đo với index. ES giữ version của tài liệu đã xoá trong index.gc_deletes (mặc định 60 giây); hết hạn thì một index cũ 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.items củ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: true chỉ 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à retry một dòng, failed một dòng, conflicts bằ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_index từ 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_v3 và 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_trgm bê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.