Vì sao Elasticsearch search nhanh: inverted index, Lucene

5 câu hỏi~35 phútTừ khóa: inverted index, posting list, term dictionary, FST, skip list, frame of reference, roaring bitmap, segment, refresh, merge, translog, doc values, BKD tree, filter cache, BM25, shard, query then fetch

Tiếp đi: phỏng vấn hay hỏi "tại sao làm chức năng search thì dùng Elasticsearch là tốt nhất?". Mình chỉ biết nói "vì nó có inverted index". Bên trong nó nhanh nhờ cái gì vậy?

Câu "vì có inverted index" mới đúng một phần. Elasticsearch nhanh vì nó xếp sẵn dữ liệu theo đúng hình dạng của câu hỏi tìm kiếm, ở nhiều tầng: từ điển term trong RAM, danh sách docId đã sort và nén, segment bất biến dễ cache, cột riêng cho sort/facet, cây riêng cho range, và chia shard chạy song song. Ở bài 6.11 ta đã biết dùng ES (analyzer, mapping, bool query). Bài này mở nắp xem bên trong.

1. Bài toán

shop-app giờ có 5 triệu sản phẩm. Khách gõ:

q        = "áo thun nam cotton"
category = thoi-trang-nam
price    = 100.000 – 300.000
facet    = đếm theo brand, theo size
sort     = độ liên quan, trả 20 kết quả, mong muốn < 50 ms

2. Vấn đề: vì sao B-tree của PostgreSQL không cứu được

Ở bài 6.4, B-tree sắp xếp theo giá trị đầy đủ của cột, đọc từ trái sang. Nó trả lời tốt "name bắt đầu bằng 'Áo thun'" nhưng không trả lời được "name chứa chữ 'cotton' ở đâu đó":

B-tree trên name (sắp theo chuỗi đầy đủ):
  "Áo khoác..." | "Áo sơ mi..." | "Áo thun cotton nam" | "Quần cotton..."
                                          ▲                     ▲
                    "cotton" nằm rải rác giữa chuỗi → không có điểm bắt đầu để seek

WHERE name ILIKE '%cotton%'
→ Seq Scan 5.000.000 dòng × so khớp chuỗi
→ ~5 triệu × ~300 byte ≈ 1,5 GB phải đọc (nếu không nằm hết trong cache)
→ hàng giây, và mỗi request search lại làm lại từ đầu

Kể cả quét xong, SQL chỉ trả lời có/không. Không có khái niệm "sản phẩm này khớp tốt hơn sản phẩm kia" (relevance). Muốn có phải tự viết công thức trong ORDER BY, tính trên mọi dòng khớp.

3. Giải pháp: lật ngược dữ liệu

Thay vì "document → các từ", ta lưu "từ → các document". Đó là inverted index. Elasticsearch (và OpenSearch) là lớp phân tán bọc quanh Apache Lucene; mọi cấu trúc dưới đây là của Lucene.

Bước A. Analyze 3 sản phẩm mẫu (analyzer vi_folding ở bài 6.11):

doc 1: "Áo thun nam Cotton cổ tròn"
doc 2: "Áo thun nữ cotton"
doc 3: "Áo sơ mi nam, áo công sở"

tokenize    → [Áo][thun][nam][Cotton][cổ][tròn]
lowercase   → [áo][thun][nam][cotton][cổ][tròn]
asciifolding→ [ao][thun][nam][cotton][co][tron]
               pos:0   1    2     3     4    5

doc 3 → [ao][so][mi][nam][ao][cong][so]   (pos 0..6)

Bước B. Gom thành term → posting list (docId, term frequency, positions):

term     posting list  (docId : tf : [positions])
───────  ─────────────────────────────────────────
ao     → 1:1:[0]   2:1:[0]   3:2:[0,4]
co     → 1:1:[4]
cong   → 3:1:[5]
cotton → 1:1:[3]   2:1:[3]
mi     → 3:1:[2]
nam    → 1:1:[2]   3:1:[3]
nu     → 2:1:[2]
so     → 3:2:[1,6]
thun   → 1:1:[1]   2:1:[1]
tron   → 1:1:[5]
         ▲
         docId luôn được SORT tăng dần — đây là chìa khóa của mọi thứ phía sau

Bước C. Term dictionary + FST: tìm term trong micro-giây

Danh sách term được sort và lưu trên đĩa theo block (term dictionary). Để biết term "cotton" nằm ở block nào mà không đọc đĩa, Lucene giữ trong RAM một term index dạng FST (finite state transducer): một automaton chia sẻ cả tiền tố lẫn hậu tố, rất nhỏ.

FST (rút gọn) cho: co, cong, cotton, mi, nam, nu
          ┌─ n ─▶(g)───────────▶ cong   → block 2
 (start)─c─o─┼─ t ─ t ─ o ─ n ───▶ cotton → block 2
          │  └─ (kết thúc) ─────▶ co     → block 1
          ├─ m ─ i ─────────────▶ mi     → block 3
          └─ n ─┬─ a ─ m ───────▶ nam    → block 3
                └─ u ───────────▶ nu     → block 3
Đi theo từng ký tự: O(độ dài term), không phụ thuộc 5 triệu doc

Bước D. Giao posting list cho truy vấn nhiều từ

"ao thun nam cotton" với operator AND = giao 4 danh sách đã sort. Bắt đầu từ danh sách ngắn nhất, nhảy (advance) trên các danh sách còn lại:

Trên 5 triệu doc (số liệu minh họa):
  ao     → 1.800.000 docs
  nam    →   900.000
  thun   →   400.000
  cotton →    60.000   ← dẫn đường

cotton: 12, 87, 305, 1201, ...
thun  : advance(12) → 12 ✓   advance(87) → 90 ✗ (bỏ 87)  ...
nam   : advance(12) → 12 ✓ ...
ao    : advance(12) → 12 ✓ → doc 12 khớp cả 4

Skip list trên posting list:
  level 1:  0 ───────────── 1024 ─────────── 2048 ───▶
  level 0:  0,3,7,12,...   1024,1030,...     2048,...
  advance(1500): nhảy thẳng tới block 1024, không đọc từng phần tử

Kết quả: chi phí tỉ lệ với danh sách hiếm nhất (~60k), không phải 5 triệu.

Nén posting list: docId đã sort nên lưu khoảng cách (delta) thay vì số tuyệt đối:

docIds : 73, 300, 302, 332, 343, 372
delta  : 73, 227,   2,  30,  11,  29     ← số nhỏ
Frame of Reference: chia block 128 số, mỗi block dùng đúng số bit cần
  (max delta 227 → 8 bit/số thay vì 32 bit) → nhỏ ~4 lần, đọc tuần tự, CPU giải nén cực nhanh (SIMD)

Roaring bitmap (cho kết quả filter được cache):
  chia docId theo khối 65.536; khối thưa → mảng short; khối dày → bitmap 8 KB
  AND/OR giữa hai bitmap = phép bit trên CPU

Bước E. Segment: index nhỏ, bất biến

Một Lucene index không phải một file lớn mà là nhiều segment. Mỗi segment là một inverted index hoàn chỉnh, ghi một lần, không bao giờ sửa.

index sản phẩm (doc mới)
     │
     ▼
in-memory buffer  + ghi translog (để không mất khi crash)
     │  refresh (mặc định mỗi 1s)
     ▼
segment mới (trong OS page cache) → TỪ LÚC NÀY MỚI SEARCH THẤY   ← near real-time
     │  flush/commit (định kỳ) → fsync xuống đĩa, translog được cắt
     ▼
seg_1 [big] seg_2 seg_3 seg_4 [nhỏ]   ── merge nền ──▶  seg_5 [big]

UPDATE doc 2  = đánh dấu xóa doc 2 cũ (tombstone, file .liv) + index bản mới vào segment mới
DELETE doc 2  = chỉ đánh dấu; dữ liệu thật biến mất khi merge

Vì sao bất biến lại nhanh:

Bước F. Doc values và BKD tree: phần không phải chữ

Inverted index trả lời "term → doc". Nhưng sort theo giá và đếm facet cần chiều ngược lại "doc → giá trị". Lucene lưu thêm doc values: lưu theo cột trên đĩa (giống columnar DB).

doc values (column-oriented, theo segment)
docId :   0      1      2      3     ...
brand : [coolm][yody ][coolm][owen ]   ← lưu ordinal số + từ điển
price : [199k ][149k ][259k ][399k ]

Facet "đếm theo brand" trên 6.000 doc khớp:
  for docId in matches: count[brand_ord[docId]]++     ← đọc tuần tự 1 cột, không đụng _source

Số và geo (price, location) được index bằng BKD tree (k-d tree theo block): range price 100k–300k đi xuống cây, lấy nguyên các block nằm trọn trong khoảng mà không cần xét từng doc.

                [0 ─────────── 2.000k]
           [0 ── 250k]        [250k ── 2.000k]
     [0─120k]   [120k─250k]   [250k─400k] ...
        ▲ một phần    ▲ nằm trọn → lấy cả block    ▲ một phần → kiểm từng doc

Filter context và filter cache: điều kiện trong filter (category, price, inStock) chỉ trả lời có/không. Lucene cache kết quả thành bitset theo từng segment; lần sau cùng filter chỉ là phép AND bit. Segment bất biến nên cache không bao giờ sai.

bitset category=thoi-trang-nam : 0110 1101 0011 ...
bitset price 100k–300k         : 1110 0101 0111 ...
AND                            : 0110 0101 0011 ...  → rồi mới giao với posting list chữ

Bước G. BM25: thứ SQL không có

Với mỗi doc khớp, cộng điểm của từng term (chỉ ý tưởng, không cần thuộc công thức):

score(doc) = Σ  IDF(term) × tf_saturation(tf) × length_norm(field)

IDF    : "cotton" (60k doc) hiếm hơn "ao" (1,8 triệu) → cotton nặng điểm hơn nhiều
tf     : xuất hiện 2 lần tốt hơn 1 lần, nhưng 20 lần không hơn bao nhiêu (bão hòa, k1)
length : "Áo thun nam cotton" (4 từ) thắng mô tả 300 từ có chứa cotton (b)

Tất cả số liệu cần (df của term, tf, độ dài field đã nén thành 1 byte "norm") đều tính sẵn lúc index. Lúc search chỉ cộng nhân. Thêm nữa, Lucene dùng WAND/block-max: mỗi block posting lưu điểm tối đa có thể đạt, block nào không thể lọt top 20 thì bỏ qua luôn.

Bước H. Phân tán: shard và replica

index "products" = 5 primary shard × 1 replica (mỗi shard = 1 Lucene index ~1 triệu doc)

        client
          │
          ▼
  coordinating node
    │  QUERY phase: gửi song song tới 1 bản (primary HOẶC replica) của mỗi shard
    ├──▶ shard0 ──▶ top 20 (docId, score)
    ├──▶ shard1 ──▶ top 20
    ├──▶ ...    ──▶ ...           + facet count từng phần
    │  merge → top 20 toàn cục, cộng facet
    │  FETCH phase: chỉ lấy _source của 20 doc thắng
    ▼
  response

4. Ghép lại: đường đi của "áo thun nam cotton"

1. analyze      "áo thun nam cotton" → [ao, thun, nam, cotton]
2. mỗi shard, mỗi segment:
   a. FST tìm 4 term → con trỏ tới 4 posting list           (RAM, µs)
   b. filter: bitset category ∧ bitset price (cache) ∧ inStock  (phép bit)
   c. giao posting list, dẫn bởi "cotton", skip list         (~60k → ~6k doc)
   d. BM25 + block-max bỏ block yếu → giữ top 20
   e. facet brand/size: đọc doc values của ~6k doc
3. coordinating node merge 5 × top 20 → top 20, cộng facet
4. fetch _source 20 doc → JSON
Không bước nào quét 5 triệu doc.

Thử nhanh bằng curl để thấy segment và profile:

curl -s -u "$SEARCH_USER:$SEARCH_PASSWORD" "$SEARCH_URL/_cat/segments/products?v&h=shard,segment,docs.count,docs.deleted,size"
curl -s -u "$SEARCH_USER:$SEARCH_PASSWORD" "$SEARCH_URL/products/_search" \
  -H 'Content-Type: application/json' \
  -d '{"profile":true,"query":{"match":{"name":{"query":"ao thun nam cotton","operator":"and"}}}}'
# profile cho biết thời gian từng bước: build_scorer, next_doc, advance, score

5. So sánh

DB LIKE '%x%'PostgreSQL FTS (tsvector + GIN)Elasticsearch / OpenSearch
Cấu trúcKhông index (B-tree vô dụng)GIN = inverted index trong 1 nodeLucene: FST + posting nén + segment + doc values, chia shard
Tốc độ 5 triệu dòngSeq scan, giâyNhanh khi khớp ít; chậm khi khớp nhiều vì rank từng dòngChục ms, ổn định, scale ngang
RelevanceKhôngts_rank, đơn giản, không có IDF toàn cụcBM25, boost, function_score
Typo / prefixKhôngpg_trgm (hạn chế)Levenshtein automaton, edge n-gram
FacetNhiều GROUP BYNhiều GROUP BYAggregation trên doc values, 1 request
Nhất quánStrong, trong transactionStrong, trong transactionNear real-time (~1s), eventual so với DB
Chi phí vận hànhKhôngKhông thêm hệ thốngCluster riêng, RAM/heap, pipeline đồng bộ

6. Khi nào ES là tốt nhất / khi nào KHÔNG

Vì ES là bản sao eventually consistent, phải có pipeline đưa thay đổi từ PostgreSQL sang: đó là bài 6.13.

Index ngược tốn RAM không? 5 triệu sản phẩm thì một node 8 GB có ổn không nhỉ?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: phần lớn index nằm trên đĩa, không nằm trong heap. Lucene đọc qua mmap, để OS page cache giữ phần nóng.

Ước lượng thô, 5 triệu sản phẩm, name + description ngắn + vài field keyword/số:
  _source (JSON nén)        ~ 2–3 GB   đĩa, chỉ đọc lúc fetch 20 doc
  inverted index + positions~ 1–2 GB   đĩa → page cache
  doc values (brand, price) ~ 200–400 MB đĩa → page cache
  FST term index            ~ vài chục MB  (off-heap ở bản mới)
  ─────────────────────────
  tổng index ~ 4–6 GB

RAM 8 GB node:
  heap JVM   : 4 GB   (≤ 50% RAM, và < ~31 GB để giữ compressed oops)
  page cache : ~4 GB  ← phần này mới làm search nhanh

Vậy 5 triệu sản phẩm là cỡ nhỏ với ES. Production nên 3 node để có replica và master quorum, không phải vì thiếu RAM.

Vừa cập nhật sản phẩm xong, sao search khoảng 1 giây sau mới thấy? Mình tưởng index xong là có liền.

Đúng rồi, đó là near real-time, đúng như sơ đồ segment ở trên:

t=0     PUT /products/_doc/p-1001     → vào buffer + translog → trả 200 OK (đã bền)
t=0..1s search                        → chưa thấy (chưa có segment)
t≈1s    refresh                       → segment mới mở cho reader → thấy
GET /products/_doc/p-1001 (theo id)   → thấy ngay (realtime get đọc translog/refresh riêng)

Tại sao không refresh sau mỗi doc? Mỗi refresh tạo một segment nhỏ. Nhiều segment nhỏ → mỗi search phải duyệt nhiều segment, merge liên tục, cache bị xóa. 1 giây là điểm cân bằng.

Ngoài 1 giây này, còn độ trễ từ PostgreSQL sang ES qua pipeline đồng bộ, đo và xử lý ở bài 6.13.

Vậy sao không bỏ PostgreSQL, dùng ES làm DB luôn? Đỡ phải đồng bộ hai nơi.

Ý tưởng hấp dẫn, nhưng chính những thứ làm ES nhanh lại làm nó dở khi làm DB:

Thiết kế làm ES nhanhHệ quả khi làm DB
Segment bất biến, update = xóa + thêmGhi/sửa nhiều (tồn kho, đơn hàng) tốn kém, phải merge
Refresh mỗi 1sĐọc ngay sau ghi có thể không thấy dữ liệu
Mỗi doc độc lập trên một shardKhông transaction nhiều doc: không thể "trừ kho + tạo đơn" nguyên tử
Denormalize để search nhanhKhông foreign key, không join, không constraint
Đổi mapping = reindexMigrate schema nặng nề

Kết luận: PostgreSQL giữ sự thật (giá, kho, đơn), ES giữ bản sao được sắp xếp để tìm. Đồng bộ hai nơi là chi phí có chủ đích, và index ES luôn build lại được từ DB.

Phỏng vấn hỏi "tại sao làm chức năng search thì dùng Elasticsearch?" thì trả lời sao?

Q1. Tại sao làm chức năng search thì dùng Elasticsearch?

🇻🇳 Tiếng Việt:

Vì database quan hệ index theo giá trị cả cột bằng B-tree, nên tìm "chứa từ" với LIKE '%x%' phải quét toàn bảng và không có relevance. Elasticsearch dựa trên Lucene, lưu inverted index: term trỏ tới danh sách docId đã sort và nén, tra term qua FST trong RAM, giao các danh sách bằng skip list nên chi phí theo term hiếm nhất chứ không theo số dòng. Nó có sẵn BM25, typo, autocomplete, facet trên doc values, và chia shard chạy song song để scale ngang. Đổi lại nó là near real-time và không phải source of truth, nên em vẫn giữ dữ liệu gốc ở PostgreSQL và đồng bộ sang. Nếu dữ liệu nhỏ, em dùng full-text của PostgreSQL trước.

🇬🇧 English:

A relational database indexes whole column values with a B-tree, so a "contains word" search with LIKE '%x%' scans the whole table and has no relevance. Elasticsearch is built on Lucene and stores an inverted index: each term points to a sorted, compressed list of doc IDs, terms are found through an in-memory FST, and lists are intersected with skip lists, so cost depends on the rarest term rather than the row count. It gives BM25 ranking, typo tolerance, autocomplete and facets on doc values out of the box, and shards run in parallel so it scales horizontally. The trade-off is that it is near real-time and not a source of truth, so I keep the original data in PostgreSQL and sync it over. For small datasets I would start with PostgreSQL full-text search.

Q2. Lucene segment là gì, vì sao bất biến?

🇻🇳 Tiếng Việt:

Một shard là một Lucene index gồm nhiều segment, mỗi segment là một inverted index nhỏ ghi một lần. Doc mới vào buffer và translog, mỗi giây refresh thành segment mới nên mới search thấy, gọi là near real-time. Update và delete chỉ đánh dấu xóa, dữ liệu thật bị bỏ khi merge. Bất biến giúp đọc không cần lock, cache theo segment luôn đúng và nén được mạnh; cái giá là update tốn kém và cần merge nền.

🇬🇧 English:

A shard is a Lucene index made of many segments, and each segment is a small write-once inverted index. New documents go to a buffer and the translog, and a refresh every second turns them into a new searchable segment, which is why it is near real-time. Updates and deletes only mark documents as deleted, and the data is dropped during merges. Immutability means reads need no locks, per-segment caches never go stale and data compresses well; the cost is expensive updates and background merging.

Q3. Elasticsearch làm sort và facet nhanh thế nào khi inverted index là term → doc?

🇻🇳 Tiếng Việt:

Sort và aggregation cần chiều doc → giá trị, nên Lucene lưu thêm doc values theo cột trên đĩa, chỉ đọc đúng cột cần cho các doc khớp. Range trên số như giá dùng BKD tree, lấy cả block nằm trọn trong khoảng. Điều kiện trong filter context không tính điểm và được cache thành bitset theo segment, nên lọc lặp lại chỉ là phép AND bit.

🇬🇧 English:

Sorting and aggregations need the doc-to-value direction, so Lucene also stores doc values, a columnar on-disk format, and reads only the needed column for matching docs. Numeric ranges such as price use a BKD tree that takes whole blocks lying inside the range. Clauses in filter context are not scored and are cached as per-segment bitsets, so repeated filters become bitwise ANDs.

Q4. Một search request chạy qua cluster như thế nào?

🇻🇳 Tiếng Việt:

Node nhận request làm coordinating node, gửi song song tới một bản primary hoặc replica của mỗi shard. Query phase: mỗi shard trả top N docId và điểm cùng facet từng phần. Coordinating node merge ra top N toàn cục, rồi fetch phase chỉ lấy _source của N doc đó. Thêm replica giúp tăng throughput đọc, thêm shard và node giúp chia dữ liệu.

🇬🇧 English:

The receiving node acts as coordinator and sends the query in parallel to one copy, primary or replica, of each shard. In the query phase each shard returns its top N doc IDs with scores and partial facets. The coordinator merges them into the global top N, then the fetch phase loads _source only for those N documents. Replicas add read throughput, while more shards and nodes spread the data.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao docId trong posting list phải được sort?

Để giao/hợp nhiều danh sách bằng cách đi song song và advance/skip, và để nén delta (khoảng cách nhỏ, ít bit).

Truy vấn AND 4 từ, chi phí phụ thuộc vào gì?

Chủ yếu vào posting list ngắn nhất (term hiếm nhất), vì nó dẫn đường và các danh sách khác chỉ advance tới docId cần thiết.

Update một doc trong ES thực chất làm gì?

Đánh dấu bản cũ là đã xóa trong segment cũ, index bản mới vào buffer → segment mới sau refresh; bản cũ bị loại hẳn khi merge.

Facet theo brand đọc từ cấu trúc nào?

Doc values của field keyword brand (dạng cột), không phải inverted index hay _source.

Khi nào không nên dùng ES cho search?

Dữ liệu nhỏ hoặc search phụ (PostgreSQL FTS đủ), cần strong consistency/transaction, hoặc doc bị cập nhật liên tục.