Elasticsearch 2 — Document, Index & Mapping

13 thg 7, 2026 2 lượt xem
#data-engineering
#elasticsearch
#mapping
#analyzer
#inverted-index

Elasticsearch 2 — Document, Index & Mapping

Ở bài Elasticsearch 1 — Tổng quan chúng ta đã thấy vì sao một team dữ liệu ngân hàng cần một hệ thống tìm kiếm/phân tích gần thời gian thực bên cạnh kho dữ liệu quan hệ. Bài này đi vào phần nền tảng nhất mà rất nhiều người dùng ES sai ngay từ đầu: mô hình dữ liệu. Nếu bạn không nắm chắc document, index, inverted index và đặc biệt là khác biệt giữa text với keyword, thì mọi truy vấn phía sau (query DSL, aggregation) sẽ cho kết quả kỳ lạ, chậm, hoặc đơn giản là sai — và tệ hơn, để sửa thì thường phải reindex lại toàn bộ.

Document — đơn vị dữ liệu cơ bản

Trong Elasticsearch, đơn vị lưu trữ và tìm kiếm nhỏ nhất là document: một object JSON. Không có "hàng" và "cột" như trong bảng quan hệ — mỗi document là một cấu trúc JSON tự mô tả, có thể lồng nhau.

Ví dụ một document mô tả một giao dịch chuyển khoản:

{
  "txn_id": "TXN-2026-0009812",
  "account_no": "0501234567",
  "amount": 15000000,
  "currency": "VND",
  "channel": "MOBILE",
  "description": "Chuyen tien hoc phi ky 2 cho con",
  "status": "SUCCESS",
  "created_at": "2026-06-30T09:41:22+07:00"
}

Mỗi document nằm trong một index và có một số metadata hệ thống, quan trọng nhất là:

  • _id — định danh duy nhất của document trong index. Bạn có thể tự đặt (ví dụ dùng txn_id làm _id) hoặc để ES tự sinh một chuỗi ngẫu nhiên. Nếu index một document với _id đã tồn tại, ES sẽ ghi đè (upsert theo _id) — đây là cách chống trùng lặp cực kỳ hữu ích khi nạp log/giao dịch.
  • _source — bản JSON gốc mà bạn đã gửi vào, được ES lưu nguyên vẹn. Khi search, ES trả về _source để bạn hiển thị. Lưu ý phân biệt: dữ liệu dùng để tìm kiếm (đã được đánh chỉ mục) khác với dữ liệu lưu để trả về (_source). Đây là điểm mấu chốt sẽ nói kỹ ở phần inverted index.

Ngoài ra còn _index (document thuộc index nào) và _version (số phiên bản, tăng mỗi lần cập nhật, dùng cho optimistic concurrency control).

Index — "bảng logic" nhưng đừng nghĩ như bảng

Một index là tập hợp các document có cấu trúc tương tự nhau. Về mặt logic, có thể hình dung index gần giống một "bảng" trong cơ sở dữ liệu quan hệ. Nhiều tài liệu dùng bảng so sánh sau để người quen SQL dễ hình dung:

RDBMS (ví dụ PostgreSQL)ElasticsearchGhi chú
DatabaseCluster / indexKhông ánh xạ 1-1
TableIndex"Bảng logic" chứa document
RowDocument (JSON)Không cố định số cột
ColumnFieldKiểu do mapping quyết định
Schema (DDL)MappingCó thể tự sinh (dynamic)
Primary key_idDuy nhất trong 1 index

Cảnh báo — bảng so sánh này chỉ để làm quen, không phải mô hình đúng. Có những khác biệt căn bản khiến việc "tư duy như SQL" gây lỗi:

  1. ES không có JOIN thực sự. Bạn không nối hai index như nối hai bảng. Muốn có dữ liệu quan hệ, bạn phải denormalize (gộp phẳng dữ liệu vào một document) hoặc dùng kiểu nested/join với chi phí và hạn chế riêng. Trong ngân hàng, thay vì để accounttransaction ở hai index rồi join, ta thường nhúng luôn tên khách, số tài khoản, kênh... vào mỗi document giao dịch lúc nạp.
  2. ES ưu tiên đọc/tìm kiếm, không phải giao dịch (transaction). Không có BEGIN/COMMIT, không có ACID nhiều-document, không khóa hàng. ES là near real-time: document sau khi index cần một khoảng (mặc định refresh_interval 1 giây) mới truy vấn thấy được.
  3. Mapping khó thay đổi. Trong SQL bạn ALTER TABLE khá thoải mái; trong ES, đổi kiểu một field đã có thường không được phép và phải reindex (nói ở cuối bài).
  4. Một index thực chất là nhiều Lucene index (shard). Index được chia thành các shard để phân tán và mở rộng — chuyện sẽ bàn kỹ ở Cluster & Scaling.

Nói ngắn gọn: index giống bảng ở chỗ "gom các bản ghi cùng loại", nhưng khác ở chỗ nó là một cấu trúc tìm kiếm chứ không phải một cấu trúc lưu trữ quan hệ.

Inverted index — vì sao full-text nhanh

Đây là trái tim của Elasticsearch (thực chất là của Lucene bên dưới). Hãy hiểu tại sao tìm một từ trong hàng trăm triệu document lại nhanh cỡ mili-giây.

Trong RDBMS, muốn tìm bản ghi chứa từ "học phí" trong cột mô tả, thông thường phải quét (scan) từng hàng và so khớp chuỗi — chậm khi dữ liệu lớn. Elasticsearch làm ngược lại: nó xây một inverted index — bảng tra cứu đi từ term (từ) → danh sách document chứa term đó.

Giả sử có 3 document mô tả giao dịch:

  • doc1: "Chuyen tien hoc phi"
  • doc2: "Thanh toan hoc phi online"
  • doc3: "Chuyen tien mua ban"

ES phân tích (analyze) từng câu thành các term rồi dựng bảng:

TermDanh sách document (posting list)
chuyendoc1, doc3
tiendoc1, doc3
hocdoc1, doc2
phidoc1, doc2
thanhdoc2
toandoc2
onlinedoc2
muadoc3
bandoc3

Khi bạn tìm "hoc phi", ES chỉ cần tra hai term hocphi trong bảng, lấy ra hai posting list rồi giao/hợp chúng lại — không quét document nào không liên quan. Đó là lý do full-text search cực nhanh và quy mô gần như không phụ thuộc tổng số document, mà phụ thuộc số term khớp.

Hai điều rút ra rất quan trọng:

  • Cùng một analyzer được dùng cho lúc index và lúc query. Nếu lúc nạp bạn lowercase và bỏ dấu, nhưng lúc tìm lại không, thì term không khớp được. ES mặc định áp analyzer của field cho cả hai phía, nên thường bạn không phải lo — nhưng khi tuỳ biến thì phải nhớ nguyên tắc này.
  • Inverted index dùng cho tìm kiếm khác với _source dùng để trả về. Một field text được phân tích thành term để tra cứu, nhưng chính chuỗi gốc vẫn nằm trong _source để hiển thị.

Mapping — schema của index

Mapping định nghĩa các field của index và kiểu dữ liệu của chúng — tương tự schema/DDL nhưng linh hoạt hơn. Có hai cách hình thành mapping:

Dynamic mapping (tự suy diễn)

Khi bạn index một document vào index chưa có mapping cho field nào đó, ES tự đoán kiểu dựa trên giá trị JSON:

  • chuỗi → mặc định thành field text kèm một sub-field keyword (multi-field, nói bên dưới);
  • số nguyên → long, số thực → float;
  • true/falseboolean;
  • chuỗi dạng ngày (nếu bật date detection) → date;
  • object lồng → object.

Dynamic mapping tiện cho việc thử nghiệm và cho log không cố định cấu trúc, nhưng nguy hiểm cho hệ thống production: ES đoán sai kiểu (ví dụ mã tài khoản "0501234567" bị hiểu là số, làm mất số 0 đầu về mặt ngữ nghĩa, hoặc số tiền bị đoán thành float gây sai lệch), và mapping có thể "phình" ngoài kiểm soát (mapping explosion) khi log có quá nhiều field lạ.

Explicit mapping (khai báo tường minh)

Với dữ liệu quan trọng như giao dịch ngân hàng, ta khai báo mapping rõ ràng khi tạo index:

PUT /transactions-2026.06
{
  "mappings": {
    "properties": {
      "txn_id":      { "type": "keyword" },
      "account_no":  { "type": "keyword" },
      "amount":      { "type": "scaled_float", "scaling_factor": 100 },
      "currency":    { "type": "keyword" },
      "channel":     { "type": "keyword" },
      "status":      { "type": "keyword" },
      "description": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "raw": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "created_at":  { "type": "date" }
    }
  }
}

Lưu ý: đây là JSON API của Elasticsearch, không phải SQL — bạn gửi nó bằng HTTP PUT tới cluster (ví dụ qua curl hoặc Kibana Dev Tools). Nên tắt hẳn dynamic mapping hoặc đặt "dynamic": "strict" để document có field lạ bị từ chối thay vì âm thầm sinh mapping mới.

text vs keyword — phân biệt CỰC KỲ quan trọng

Đây là quyết định thiết kế quan trọng nhất trong ES và là nguồn gốc của phần lớn lỗi khi mới dùng. Cả hai đều nhận chuỗi, nhưng cách xử lý hoàn toàn khác:

Tiêu chítextkeyword
Có phân tích (analyze)?Có — tách token, lowercase, v.v.Không — lưu nguyên chuỗi như một term duy nhất
Dùng đểFull-text search ("tìm gần đúng")Lọc chính xác, aggregation, sort
Ví dụ khớp"hoc phi" tìm thấy "Chuyen tien hoc phi"Chỉ khớp khi bằng đúng cả chuỗi
Aggregation/sort?Không (mặc định tắt, tốn RAM nếu bật fielddata)Có, hiệu quả (dùng doc_values)
Case-sensitive?Không (đã lowercase)Có (nguyên văn)

Quy tắc thực chiến:

  • description, nội dung tự do, tin nhắn, ghi chútext. Người dùng muốn gõ vài từ và tìm gần đúng.
  • Mã (txn_id, account_no), trạng thái (SUCCESS/FAILED), currency, channel, mã chi nhánh, email, IP, hostnamekeyword. Bạn cần lọc chính xác (status = "FAILED"), gom nhóm (đếm giao dịch theo channel), và sort.

Ví dụ minh hoạ cái sai kinh điển: nếu để statustext, giá trị "IN_PROGRESS" bị phân tích thành hai term inprogress; khi bạn aggregate "đếm theo status" sẽ ra hai nhóm inprogress thay vì một nhóm IN_PROGRESS — sai hoàn toàn. Vì thế mọi field dùng cho lọc/aggregation/sort phải là keyword.

Multi-field — có cả hai trong một

Thường bạn muốn một field vừa tìm full-text vừa lọc/aggregate chính xác. Giải pháp là multi-field: khai báo field chính là text và một sub-field keyword. Trong ví dụ mapping ở trên, descriptiontext để search, còn description.rawkeyword để nhóm/sort/lọc chính xác (ví dụ đếm số giao dịch có mô tả trùng khít). Đây cũng chính là cấu trúc mà dynamic mapping tự tạo cho mọi chuỗi: field (text) + field.keyword (keyword). ignore_above: 256 bỏ qua việc index phần keyword nếu chuỗi quá dài (tránh phình).

Các kiểu field quan trọng khác

  • Số: long, integer, short, byte, double, float, half_float, scaled_float. Với tiền tệ, tránh float/double vì sai số dấu phẩy động; dùng scaled_float với scaling_factor (ví dụ 100 để lưu tới xu) hoặc lưu số nguyên đơn vị nhỏ nhất (đồng). Đây là điểm mà đoán tự động thường sai.
  • date: lưu dưới dạng số mili-giây từ epoch (UTC). Chấp nhận nhiều format qua format. Lưu ý múi giờ: ES lưu UTC, nên với dữ liệu Việt Nam (UTC+7) phải chú ý khi hiển thị và khi aggregate theo ngày (date_histogram cần time_zone).
  • boolean: true/false.
  • ip: lưu và tìm theo dải IP (hữu ích cho log truy cập, phát hiện gian lận theo IP).
  • object: JSON lồng, ES tự "làm phẳng" theo dấu chấm (customer.name). Nhược điểm: một mảng object bị làm phẳng làm mất liên hệ giữa các trường trong cùng phần tử.
  • nested: dùng khi bạn có mảng các object cần giữ liên hệ nội bộ (ví dụ mảng các bút toán trong một giao dịch, mỗi bút toán có typeamount phải khớp với nhau). nested index mỗi phần tử như một sub-document riêng, truy vấn đúng nhưng tốn kém hơn — không lạm dụng.

Analyzer & tokenizer — chuẩn hoá văn bản

Với field text, việc chuỗi được biến thành term nào do analyzer quyết định. Một analyzer gồm ba tầng, chạy theo thứ tự:

  • Char filters: xử lý ký tự thô trước khi tách (bỏ thẻ HTML, thay ký tự).
  • Tokenizer: cắt chuỗi thành token. standard tokenizer cắt theo ranh giới từ (word boundaries) theo chuẩn Unicode.
  • Token filters: biến đổi token — lowercase (chuẩn hoá hoa/thường), asciifolding (bỏ dấu tiếng Việt: "phí" → "phi"), stop (loại từ dừng như "the", "and", "là", "và" — dùng thận trọng với tiếng Việt), stemming (rút về gốc từ, ví dụ tiếng Anh "running" → "run"; ít tác dụng với tiếng Việt vì không biến hình).

Analyzer mặc địnhstandard: tokenizer standard + lowercase, không bỏ dấu, không stop words. Với tiếng Việt, standard cắt theo âm tiết (mỗi tiếng một token) chứ không hiểu ranh giới từ ghép (ví dụ "học phí" thành hai token "học", "phí" chứ không phải một từ "học phí"). Điều này thường đủ dùng cho tìm kiếm giao dịch/log, nhưng để tìm kiếm ngôn ngữ tự nhiên chất lượng cao, người ta dùng:

  • asciifolding để người dùng gõ không dấu ("hoc phi") vẫn tìm được văn bản có dấu — rất phổ biến ở Việt Nam.
  • Plugin tách từ tiếng Việt (ví dụ các plugin dựa trên bộ tách từ như VnCoreNLP/coccoc-tokenizer) để nhận diện từ ghép đúng — cần cài thêm và không có sẵn trong ES core.

Bạn có thể kiểm tra một analyzer sinh ra term gì bằng API _analyze:

POST /transactions-2026.06/_analyze
{
  "field": "description",
  "text": "Chuyển Tiền Học Phí"
}

API này (JSON, không phải SQL) trả về danh sách token thực tế — công cụ số một để gỡ lỗi "tại sao tìm không ra".

Reindex — khi phải đổi mapping

Vì inverted index được xây lúc index theo mapping hiện có, bạn không thể đổi kiểu một field đã tồn tại (ví dụ từ text sang keyword, hay đổi analyzer) trên dữ liệu cũ. ES chỉ cho phép thêm field mới. Muốn đổi kiểu field cũ, quy trình chuẩn là reindex:

  1. Tạo index mới với mapping đúng (ví dụ transactions-2026.06-v2).
  2. Dùng API _reindex để copy toàn bộ document từ index cũ sang index mới (ES đọc _source và index lại theo mapping mới, dựng lại inverted index).
  3. Chuyển alias (bí danh, ví dụ transactions-current) từ index cũ sang index mới. Vì ứng dụng chỉ truy vấn qua alias, việc đổi này không gián đoạn người dùng.
  4. Xoá index cũ khi đã xác nhận.
POST /_reindex
{
  "source": { "index": "transactions-2026.06" },
  "dest":   { "index": "transactions-2026.06-v2" }
}

Bài học vận hành: luôn dùng alias thay vì trỏ thẳng vào tên index, và luôn khai báo explicit mapping từ đầu — vì reindex hàng trăm triệu document là việc tốn thời gian và tài nguyên. Chi tiết về nạp dữ liệu và alias sẽ ở Indexing & Ingest; cách viết truy vấn trên mapping này ở Query DSL; cách gom nhóm trên field keywordAggregations.

Use case thực tế

Bối cảnh. Team dữ liệu NCB xây một index tra cứu giao dịch cho bộ phận Vận hành và Giám sát gian lận (fraud). Mỗi ngày khoảng 1,8 triệu giao dịch đổ vào, giữ nóng (searchable) 90 ngày ≈ 162 triệu document. Yêu cầu: nhân viên gõ vài từ trong mô tả để tra cứu, đồng thời fraud lọc chính xác theo trạng thái/kênh/IP và thống kê theo giờ.

Vấn đề gặp phải (thực tế hay xảy ra). Ở phiên bản đầu, index để dynamic mapping. Kết quả:

  • account_no "0501234567" bị đoán thành long, mất ý nghĩa mã và không lọc chính xác được.
  • statustext, nên khi fraud aggregate "đếm giao dịch FAILED theo giờ", IN_PROGRESS bị tách thành in/progress, số liệu sai; và không sort được.
  • amount bị đoán float, tổng tiền lệch vài đồng do sai số dấu phẩy động.

Cách khắc phục. Team tạo index v2 với explicit mapping: account_no, status, channel, currency, txn_idkeyword; amountscaled_float (scaling_factor 100); created_atdate; description là multi-field (text với asciifolding để tìm không dấu + .rawkeyword). Đặt "dynamic": "strict".

Kết quả. Sau khi _reindex 162 triệu document sang v2 và chuyển alias txn-search:

  • Nhân viên gõ "hoc phi" (không dấu) vẫn ra giao dịch "Chuyển tiền học phí" nhờ asciifolding.
  • Fraud lọc status: "FAILED" chính xác, aggregate theo channeldate_histogram theo giờ (với time_zone: "+07:00") cho số đúng, phản hồi dưới 50 ms trên tập nóng.
  • Tổng số tiền khớp tuyệt đối với hệ core banking. Việc chuyển alias diễn ra trong giờ hành chính mà người dùng không nhận ra gián đoạn.

Ghi nhớ

  • Document là object JSON; đơn vị index/search nhỏ nhất. _id định danh (index trùng _id = ghi đè), _source là JSON gốc để trả về — khác với dữ liệu đã đánh chỉ mục để tìm.
  • Index giống "bảng logic" nhưng đừng tư duy như SQL: không JOIN thật, không giao dịch ACID nhiều-document, near real-time, mapping khó đổi, và bên dưới là nhiều shard Lucene.
  • Inverted index ánh xạ term → danh sách document; đó là lý do full-text nhanh. Cùng một analyzer phải dùng cho cả lúc index lẫn lúc query.
  • Dynamic mapping tiện nhưng dễ đoán sai kiểu và gây mapping explosion; production nên explicit mapping và cân nhắc "dynamic": "strict".
  • text vs keyword là phân biệt sống còn: text để full-text (được phân tích), keyword để lọc/aggregation/sort (chính xác, nguyên văn). Field mã/trạng thái/kênh phải là keyword.
  • Multi-field cho phép một chuỗi vừa search (text) vừa lọc/aggregate (.keyword).
  • Tiền dùng scaled_float/số nguyên (tránh float); ngày dùng date (UTC, để ý time_zone); mảng object cần liên hệ nội bộ dùng nested.
  • Analyzer = char filters → tokenizer → token filters (lowercase, asciifolding bỏ dấu, stop, stemming). Với tiếng Việt, standard cắt theo âm tiết; muốn tách từ ghép cần plugin chuyên dụng.
  • Đổi kiểu field đã có buộc phải reindex: tạo index mới → _reindex → chuyển alias → xoá cũ. Luôn dùng alias và explicit mapping từ đầu để tránh đau đầu về sau.

Bài viết liên quan

CSV/JSON/Parquet/Avro, lưu trữ theo hàng vs cột, nén, và OLTP vs OLAP.

13 thg 7, 2026 8

Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.

13 thg 7, 2026 5

Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.

13 thg 7, 2026 5

Đảm bảo chất lượng & minh bạch dữ liệu bằng dbt: data tests dựng sẵn (unique, not_null, relationships, accepted_values), singular test, unit test, và tài liệu tự sinh kèm lineage graph.

13 thg 7, 2026 5