Skip to content

HARRY-24 — GĐ10 — Search & Elasticsearch: inverted index, relevance, đồng bộ dữ liệu

GĐ10 — Search & Elasticsearch: inverted index, relevance, đồng bộ dữ liệu

GĐ4 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.


sql
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 đó.

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:

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

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

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

json
{
  "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:

json
{
  "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?"Không
Filter"Có khớp không? (đúng/sai)"Không
json
{
  "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", { "id": "asc" }]
}

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:

json
{ "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Đ12).


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 OFFSETGĐ8 — cùng nguyên nhân, cùng cách chữa.

search_after — cursor pagination của Elasticsearch:

json
// Trang 1
{ "size": 20, "sort": [{ "created_at": "desc" }, { "_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" }, { "_id": "asc" }],
  "search_after": [1717000000000, "abc123"] }

Bắt buộc có tie-breaker (_id) trong sort. 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ụ.

Quét toàn bộ index (export, reindex) dùng PIT (Point In Time) + search_after, không dùng scroll API (đã lỗi thời) và tuyệt đối không dùng from.


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Đ9Trễ 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Đ9 mục 4 rồi — đây là ứng dụng thứ hai của nó.

ts
// Worker tiêu thụ sự kiện outbox
async function handleProductChanged({ productId }: { productId: string }) {
  const product = await db.product.findUnique({
    where: { id: productId }, include: { brand: true, categories: true },
  })
  if (!product) {                                  // đã bị xoá
    await es.delete({ index: 'products', id: productId }).catch(ignore404)
    return
  }
  await es.index({
    index: 'products',
    id: product.id,                                // dùng id của DB → ghi đè, idempotent
    document: toSearchDocument(product),
    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.

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:

ts
await es.bulk({ operations: batch.flatMap(d => [
  { index: { _index: 'products', _id: d.id }}, toSearchDocument(d),
])})

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

ts
// So số lượng và checksum theo khoảng, chỉ reindex phần lệch
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.


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:

app → alias "products" → index "products_v3"

Reindex không downtime:

bash
# 1. Tạo index mới với mapping mới
PUT /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
{ "source": { "index": "products_v3" }, "dest": { "index": "products_v4" } }

# 3. Bắt kịp phần thay đổi trong lúc reindex (worker outbox ghi vào CẢ HAI index)

# 4. Đổi alias — NGUYÊN TỬ, không có khoảnh khắc nào alias không trỏ vào đâu
POST /_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á

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 SSPLCần sức mạnh đầy đủ, có người vận hành
OpenSearchFork của ES 7.10, Apache 2.0, 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Đ5)

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Đ20

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Đ20) 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:

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 8.x có RRF sẵn; Postgres làm được bằng cách gộp kết quả tsvectorpgvector trong một CTE. Chi tiết phần vector ở GĐ20.


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.
  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.
  3. Đồng bộ bằng outbox + worker đã có từ GĐ9; dùng id của DB làm document id (idempotent); có version chống ghi đè ngược.
  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.
  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).
  6. Chống lỗi chính tả (fuzziness / typo-tolerance) và kiểm tra bằng một truy vấn gõ sai.
  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.
  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.
  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.
  10. Chạy quy trình reindex không downtime bằng alias và ghi lại các bước đã làm.

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
  • [ ] Đị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
  • [ ] Vẽ được inverted index cho 3 document
  • [ ] Giải thích chuỗi analysis (char filter → tokenizer → token filter) và vì sao index và query phải cùng analyzer
  • [ ] Biết analyzer english phá tiếng Việt; biết dùng _analyze để kiểm tra
  • [ ] Giải thích BM25 qua TF / IDF / field length; biết vì sao BM25 hơn TF-IDF
  • [ ] Dùng boost theo field và function score theo tín hiệu nghiệp vụ
  • [ ] Đo relevance bằng zero-result rate và CTR@k, không chỉnh boost theo cảm tính
  • [ ] Phân biệt query context vs filter context; đặt đúng thứ vào filter
  • [ ] Phân biệt text vs keyword; biết vì sao aggregation phải trên keyword
  • [ ] Biết vì sao from + size chặn ở 10.000 (lý do phân tán) và dùng search_after + tie-breaker
  • [ ] Coi search index là bản sao phái sinh, không phải nguồn sự thật
  • [ ] Đồng bộ bằng outbox; dùng external version chống ghi đè ngược thứ tự
  • [ ] Có job đối soát và metric drift
  • [ ] Dùng alias và biết quy trình reindex không downtime 5 bước
  • [ ] Biết quy tắc kích thước shard và vì sao số shard không đổi được
  • [ ] Không bao giờ để ES ra Internet; không cho client gửi query DSL
  • [ ] So sánh được ES / OpenSearch / Meilisearch / Typesense / Postgres FTS và chọn có lý lẽ
  • [ ] Giải thích hybrid search và RRF; biết vì sao RRF né được vấn đề chuẩn hoá điểm

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.
  • 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.
  • 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ọc bằng cách build. Chứng minh, đừng tin.