Elasticsearch 2 — Document, Index & Mapping
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ùngtxn_idlà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) | Elasticsearch | Ghi chú |
|---|---|---|
| Database | Cluster / index | Không ánh xạ 1-1 |
| Table | Index | "Bảng logic" chứa document |
| Row | Document (JSON) | Không cố định số cột |
| Column | Field | Kiểu do mapping quyết định |
| Schema (DDL) | Mapping | Có thể tự sinh (dynamic) |
| Primary key | _id | Duy 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:
- 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/joinvới chi phí và hạn chế riêng. Trong ngân hàng, thay vì đểaccountvàtransactionở 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. - 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 địnhrefresh_interval1 giây) mới truy vấn thấy được. - Mapping khó thay đổi. Trong SQL bạn
ALTER TABLEkhá 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). - 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:
| Term | Danh sách document (posting list) |
|---|---|
| chuyen | doc1, doc3 |
| tien | doc1, doc3 |
| hoc | doc1, doc2 |
| phi | doc1, doc2 |
| thanh | doc2 |
| toan | doc2 |
| online | doc2 |
| mua | doc3 |
| ban | doc3 |
Khi bạn tìm "hoc phi", ES chỉ cần tra hai term hoc và phi 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
_sourcedùng để trả về. Một fieldtextđượ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
textkèm một sub-fieldkeyword(multi-field, nói bên dưới); - số nguyên →
long, số thực →float; true/false→boolean;- 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í | text | keyword |
|---|---|---|
| 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, hostname →keyword. Bạn cần lọc chính xác (status = "FAILED"), gom nhóm (đếm giao dịch theochannel), và sort.
Ví dụ minh hoạ cái sai kinh điển: nếu để status là text, giá trị "IN_PROGRESS" bị phân tích thành hai term in và progress; khi bạn aggregate "đếm theo status" sẽ ra hai nhóm in và progress 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, description là text để search, còn description.raw là keyword để 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ánhfloat/doublevì sai số dấu phẩy động; dùngscaled_floatvớiscaling_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 quaformat. 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_histogramcầntime_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ótypevàamountphải khớp với nhau).nestedindex 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.
standardtokenizer 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 định là standard: 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:
- Tạo index mới với mapping đúng (ví dụ
transactions-2026.06-v2). - Dùng API
_reindexđể copy toàn bộ document từ index cũ sang index mới (ES đọc_sourcevà index lại theo mapping mới, dựng lại inverted index). - 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. - 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 keyword ở Aggregations.
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ànhlong, mất ý nghĩa mã và không lọc chính xác được.statuslàtext, nên khi fraud aggregate "đếm giao dịch FAILED theo giờ",IN_PROGRESSbị tách thànhin/progress, số liệu sai; và không sort được.amountbị đoánfloat, 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_id là keyword; amount là scaled_float (scaling_factor 100); created_at là date; description là multi-field (text với asciifolding để tìm không dấu + .raw là keyword). Đặ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 theochannelvàdate_histogramtheo giờ (vớitime_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 đè),_sourcelà 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". textvskeywordlà 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ánhfloat); ngày dùngdate(UTC, để ýtime_zone); mảng object cần liên hệ nội bộ dùngnested. - Analyzer = char filters → tokenizer → token filters (lowercase,
asciifoldingbỏ dấu, stop, stemming). Với tiếng Việt,standardcắ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.
Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.
Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.
Đả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.