Elasticsearch 3 — Query DSL & tìm kiếm liên quan
Elasticsearch 3 — Query DSL & tìm kiếm liên quan
Ở bài overview chúng ta đã thấy Elasticsearch (ES) mạnh ở đâu, và ở bài về document & index ta đã học cách dữ liệu được lưu và ánh xạ (mapping). Bây giờ tới phần mà mọi người thực sự chờ đợi: truy vấn. Làm sao để hỏi ES "tìm cho tôi các giao dịch có mô tả nhắc tới chuyển khoản, trong tháng 6, số tiền trên 50 triệu" — và nhận về kết quả được sắp xếp theo mức độ liên quan?
Đây là điểm khác biệt cốt lõi giữa ES và một cơ sở dữ liệu quan hệ. SQL trả về tập kết quả thỏa điều kiện, không có khái niệm "khớp nhiều hay ít". ES thì mặc định trả về kết quả kèm điểm (_score) — cái nào khớp tốt hơn đứng trên. Muốn khai thác được điều này, bạn phải hiểu Query DSL (Domain Specific Language) — ngôn ngữ truy vấn dạng JSON của ES.
Lưu ý xuyên suốt bài: Query DSL là JSON, không phải SQL. Các block dưới đây không phải câu lệnh chạy trên sandbox PostgreSQL, mà là request gửi tới ES. Đừng nhầm.
Cấu trúc một request search
Truy vấn cơ bản nhất là gọi endpoint _search trên một index (hoặc nhiều index), gửi kèm một JSON body:
GET /transactions/_search
{
"query": {
"match": { "description": "chuyển khoản lương" }
},
"from": 0,
"size": 20,
"sort": [ { "_score": "desc" } ]
}
Các thành phần chính trong body:
query: mệnh đề truy vấn — trái tim của request. Bên trong là một hoặc nhiều query clause lồng nhau.from/size: phân trang — bỏ quafromkết quả đầu, lấysizekết quả.sort: thứ tự sắp xếp. Mặc định theo_scoregiảm dần._source: chọn trường muốn trả về (giảm băng thông).aggs: tổng hợp (aggregation) — chủ đề của bài sau.highlight: đánh dấu đoạn khớp trong kết quả.
Kết quả trả về là JSON với hits.total.value (tổng số khớp), hits.max_score (điểm cao nhất) và mảng hits.hits — mỗi phần tử có _id, _score và _source (document gốc).
match vs term — sai lầm kinh điển
Đây là kiến thức nền tảng nhất và cũng là nơi người mới sai nhiều nhất. Cả hai đều tìm document khớp một giá trị, nhưng cơ chế hoàn toàn khác.
match — full-text, ĐƯỢC phân tích
match là truy vấn full-text. Trước khi so khớp, ES phân tích (analyze) chuỗi bạn gửi vào bằng đúng analyzer đã dùng khi index — tách token, hạ chữ thường, loại dấu câu... rồi so từng token với inverted index.
GET /transactions/_search
{
"query": {
"match": { "description": "Chuyển Khoản Lương" }
}
}
ES tách thành các token chuyển, khoản, lương và tìm document chứa bất kỳ token nào (mặc định toán tử or). Document chứa cả ba token sẽ có điểm cao hơn document chỉ chứa một. Đây là cái ta muốn cho các trường mô tả tự do.
Muốn buộc khớp tất cả token, dùng operator:
{
"query": {
"match": {
"description": {
"query": "chuyển khoản lương",
"operator": "and"
}
}
}
}
term — chính xác, KHÔNG phân tích
term tìm đúng token trong index, không hề phân tích chuỗi truy vấn. Nó hợp cho các trường "định danh": mã trạng thái, loại giao dịch, mã chi nhánh, keyword.
GET /transactions/_search
{
"query": {
"term": { "kind": "transfer" }
}
}
Cảnh báo: đừng dùng term trên trường text
Đây là cái bẫy chết người. Giả sử bạn có trường description kiểu text với analyzer chuẩn. Khi index chuỗi "Chuyển khoản lương", ES lưu vào inverted index các token đã hạ chữ thường: chuyển, khoản, lương. Nhưng term không phân tích chuỗi truy vấn:
{ "query": { "term": { "description": "Chuyển khoản lương" } } }
Truy vấn này tìm đúng token "Chuyển khoản lương" (nguyên văn, viết hoa, có dấu cách) trong index — mà token đó không tồn tại vì index chỉ chứa các token con đã hạ chữ thường. Kết quả: 0 hit, dù dữ liệu rõ ràng có. Bạn ngồi debug hàng giờ mà không hiểu vì sao.
Quy tắc vàng:
| Loại trường | Query nên dùng | Vì sao |
|---|---|---|
text (mô tả, nội dung) | match, match_phrase | cần phân tích để khớp linh hoạt |
keyword (mã, trạng thái, ID) | term, terms | khớp chính xác, không phân tích |
| số, ngày | term, range | so sánh giá trị |
Nhiều mapping đặt trường vừa là text vừa có sub-field .keyword (multi-field). Khi đó dùng match trên description để full-text, dùng term trên description.keyword để khớp nguyên văn.
match_phrase và multi_match
match_phrase khớp cả cụm từ, đúng thứ tự và liền kề. Dùng khi thứ tự chữ có ý nghĩa:
{
"query": {
"match_phrase": { "description": "phí quản lý tài khoản" }
}
}
Chỉ khớp document chứa đúng cụm "phí quản lý tài khoản" theo thứ tự, không khớp "quản lý phí tài khoản". Tham số slop cho phép nới lỏng số vị trí xê dịch giữa các từ.
multi_match chạy một match trên nhiều trường cùng lúc — rất hữu ích khi không biết từ khóa nằm ở trường nào:
{
"query": {
"multi_match": {
"query": "Nguyen Van A",
"fields": ["counterparty_name", "description", "note"],
"type": "best_fields"
}
}
}
Có thể tăng trọng số một trường bằng cú pháp ^: "fields": ["counterparty_name^3", "description"] khiến khớp ở counterparty_name quan trọng gấp 3 lần.
bool query — kết hợp điều kiện
Truy vấn thực tế hiếm khi chỉ một mệnh đề. bool là "chất keo" để ghép nhiều mệnh đề, với bốn khối con:
must: các mệnh đề bắt buộc khớp. Có tính điểm (query context).should: mệnh đề "nên có" — không bắt buộc, nhưng khớp thì cộng điểm. Nếu không cómust/filter, cần ít nhất mộtshouldkhớp (điều chỉnh bằngminimum_should_match).must_not: mệnh đề không được khớp (loại trừ). Không tính điểm.filter: mệnh đề bắt buộc khớp nhưng KHÔNG tính điểm (filter context) — chỉ lọc yes/no.
Ví dụ đầy đủ: tìm giao dịch có mô tả nhắc tới "chuyển khoản", loại giao dịch là chuyển tiền, không phải giao dịch đã hủy, trong khoảng thời gian và số tiền nhất định:
GET /transactions/_search
{
"query": {
"bool": {
"must": [
{ "match": { "description": "chuyển khoản quốc tế" } }
],
"should": [
{ "match": { "note": "gấp ưu tiên" } }
],
"must_not": [
{ "term": { "status": "cancelled" } }
],
"filter": [
{ "term": { "kind": "transfer" } },
{ "range": { "amount": { "gte": 50000000 } } },
{ "range": { "created_at": { "gte": "2026-06-01", "lt": "2026-07-01" } } }
]
}
}
}
Cách đọc: "Phải khớp mô tả chuyển khoản quốc tế; nếu note có 'gấp/ưu tiên' thì được đẩy lên trên; loại bỏ giao dịch đã hủy; và chỉ giữ giao dịch loại transfer, số tiền ≥ 50 triệu, phát sinh trong tháng 6/2026."
Query context vs Filter context — cực kỳ quan trọng cho hiệu năng
Đây là khái niệm quyết định pipeline tìm kiếm của bạn nhanh hay chậm.
- Query context: câu hỏi là "document này khớp tốt đến mức nào?" — ES tính
_score. Các mệnh đề trongmust/shouldchạy ở context này. - Filter context: câu hỏi là "document này khớp hay không (yes/no)?" — không tính điểm. Các mệnh đề trong
filter/must_notchạy ở context này.
Filter context có hai lợi thế lớn về hiệu năng:
- Không tính điểm → bỏ qua toàn bộ phép tính BM25, nhẹ hơn nhiều.
- Được cache → ES lưu kết quả filter dưới dạng bitset (mỗi document 1 bit: khớp hay không) trong node query cache. Lần sau cùng filter đó chạy lại (rất phổ biến với các bộ lọc như "tháng này", "chi nhánh Hà Nội") thì lấy từ cache, gần như tức thì.
Nguyên tắc thực chiến: bất cứ điều kiện nào không cần góp phần vào độ liên quan — lọc theo trạng thái, khoảng ngày, khoảng số tiền, mã chi nhánh, loại giao dịch — hãy đặt trong filter. Chỉ để trong must/should những mệnh đề full-text mà bạn muốn ảnh hưởng tới thứ hạng. Một truy vấn viết đúng context có thể nhanh gấp nhiều lần so với nhét tất cả vào must.
Chấm điểm liên quan & thuật toán BM25
Khi một mệnh đề chạy ở query context, ES gán cho mỗi document một _score — số dương, càng cao càng liên quan. Từ ES 5.0, thuật toán mặc định là BM25 (Best Match 25), một cải tiến của TF-IDF kinh điển. Bạn không cần nhớ công thức, nhưng cần hiểu trực giác ba yếu tố:
- TF — Term Frequency (tần suất từ): một từ xuất hiện càng nhiều lần trong một document thì document đó càng liên quan tới từ đó. Mô tả nhắc "chuyển khoản" 3 lần liên quan hơn mô tả nhắc 1 lần. Nhưng BM25 cho TF bão hòa (saturation): từ lần thứ 10 trở đi thêm cũng gần như không tăng điểm nữa — tránh việc spam từ khóa. TF-IDF cũ thì tăng tuyến tính, dễ bị "nhồi từ".
- IDF — Inverse Document Frequency (độ hiếm của từ): từ càng hiếm trong toàn index thì càng "đắt giá". Từ "khoản" xuất hiện ở hầu hết giao dịch nên gần như vô nghĩa để phân biệt; còn "SWIFT" hay "OFFSHORE" hiếm hơn nên khớp nó có ý nghĩa hơn nhiều. IDF nâng điểm cho từ hiếm.
- Field length (độ dài trường): một từ khớp trong trường ngắn quan trọng hơn khớp trong trường dài. Nếu "lương" xuất hiện trong mô tả 5 từ thì đáng giá hơn xuất hiện trong ghi chú 500 từ. BM25 chuẩn hóa theo độ dài.
Nói ngắn gọn: BM25 thưởng cho từ xuất hiện nhiều (nhưng giảm dần), từ hiếm trong toàn kho, và khớp trong trường ngắn gọn. Hai tham số k1 (điều khiển tốc độ bão hòa TF) và b (mức chuẩn hóa độ dài) có thể tinh chỉnh trong mapping, nhưng mặc định (k1=1.2, b=0.75) hợp cho hầu hết trường hợp.
Muốn hiểu vì sao một document có điểm như vậy, dùng explain:
GET /transactions/_search
{
"explain": true,
"query": { "match": { "description": "swift chuyển khoản" } }
}
Kết quả trả về cây tính điểm chi tiết cho từng document — công cụ vàng khi debug "vì sao kết quả này lại đứng trên kết quả kia".
Sort, phân trang & vấn đề deep pagination
Mặc định kết quả sắp theo _score. Bạn có thể sort theo trường bất kỳ; khi sort theo trường khác _score, ES bỏ luôn việc tính điểm (score = null) để tiết kiệm:
{
"query": { "term": { "kind": "transfer" } },
"sort": [ { "created_at": "desc" }, { "amount": "desc" } ]
}
Phân trang bằng from/size đơn giản nhưng có giới hạn nghiêm trọng. Để lấy trang thứ 1000 (from: 10000, size: 10), mỗi shard phải tìm và sắp xếp 10.010 kết quả rồi node điều phối gộp lại và bỏ đi 10.000 cái đầu. Càng đi sâu càng tốn RAM và CPU. Vì thế ES chặn from + size vượt quá index.max_result_window (mặc định 10.000).
Giải pháp cho phân trang sâu là search_after: thay vì "nhảy tới offset", ta nói "lấy các kết quả sau giá trị sort của phần tử cuối trang trước". Cần một khóa sort duy nhất (thường ghép thêm _id để đảm bảo tie-break):
{
"size": 10,
"query": { "match_all": {} },
"sort": [ { "created_at": "desc" }, { "_id": "asc" } ],
"search_after": [ 1719763200000, "txn_88231" ]
}
Mỗi trang, bạn lấy giá trị sort của hit cuối cùng làm search_after cho trang kế. Cách này chi phí gần như hằng số dù đi sâu tới đâu — chuẩn để export hàng triệu bản ghi hoặc cuộn (scroll) qua toàn bộ dữ liệu. (Với export toàn bộ không cần thứ hạng, còn có Point in Time + search_after, hoặc API Scroll cũ.)
Highlight — đánh dấu đoạn khớp
Khi hiển thị kết quả tìm kiếm cho người dùng, ta muốn tô đậm đoạn text khớp. highlight làm việc đó, trả về đoạn trích có thẻ <em> (hoặc thẻ tùy chỉnh) bao quanh từ khớp:
{
"query": { "match": { "description": "chuyển khoản swift" } },
"highlight": {
"fields": {
"description": {
"pre_tags": ["<mark>"],
"post_tags": ["</mark>"],
"number_of_fragments": 2
}
}
}
}
Kết quả trả thêm khối highlight cho mỗi hit, chứa các fragment đã đánh dấu — front-end chỉ việc render.
Fuzzy & wildcard (sơ lược)
Hai loại query "gần đúng", hữu ích nhưng phải dùng cẩn thận:
fuzzy: khớp cả những từ sai chính tả nhẹ, dựa trên khoảng cách Levenshtein (số ký tự cần sửa). Tìm"nguyen"vớifuzziness: 1vẫn khớp"nguyan". Dùng cho tìm tên khách hàng gõ sai. Chi phí cao hơnmatchnhiều.wildcard: khớp mẫu với*(nhiều ký tự) và?(một ký tự), ví dụ"VN*". Rất tốn tài nguyên, đặc biệt khi wildcard nằm đầu chuỗi (*123) — buộc quét toàn bộ term. Tránh dùng trên index lớn; nếu cần khớp tiền tố, hãy cân nhắc kiểu trường chuyên dụng hoặc n-gram lúc index.
Với nhu cầu tìm-khi-gõ (search-as-you-type) hay gợi ý, giải pháp đúng thường là mapping search_as_you_type hoặc edge n-gram lúc index, không phải wildcard lúc query.
Use case thực tế
Bối cảnh: Đội vận hành ngân hàng NCB cần một màn hình tra cứu giao dịch nghi ngờ. Cán bộ nhập từ khóa (mô tả tự do), chọn khoảng ngày, loại giao dịch và ngưỡng số tiền. Chỉ số giao dịch trong index transactions gồm ~180 triệu document, mỗi document có description (text), counterparty_name (text + .keyword), kind (keyword), amount (long), created_at (date), status (keyword).
Yêu cầu điển hình: "Tìm giao dịch nghi ngờ liên quan tới 'chuyển khoản quốc tế offshore', loại transfer, số tiền ≥ 500 triệu, trong quý 2/2026, chưa bị hủy — sắp theo mức liên quan."
Truy vấn đúng chuẩn (full-text ở query context, còn lại ở filter context để được cache):
GET /transactions/_search
{
"size": 50,
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "chuyển khoản quốc tế offshore",
"fields": ["description^2", "counterparty_name"],
"type": "best_fields"
}
}
],
"filter": [
{ "term": { "kind": "transfer" } },
{ "range": { "amount": { "gte": 500000000 } } },
{ "range": { "created_at": { "gte": "2026-04-01", "lt": "2026-07-01" } } }
],
"must_not": [
{ "term": { "status": "cancelled" } }
]
}
},
"highlight": {
"fields": { "description": { "pre_tags": ["<mark>"], "post_tags": ["</mark>"] } }
},
"sort": [ "_score", { "created_at": "desc" } ]
}
Kết quả đo được (minh họa): Ba filter (kind, amount, created_at) thu hẹp từ 180 triệu xuống ~90.000 document, và vì cán bộ liên tục dùng lại cùng bộ lọc quý/loại nên bitset filter được cache — sau lần đầu, phần lọc gần như miễn phí. Chỉ ~90.000 document phải tính BM25 thay vì 180 triệu. Kết quả trả về trong khoảng chục mili-giây, tô đậm đoạn khớp cho cán bộ nhìn thấy ngay lý do một giao dịch bị đưa lên. Khi cần export toàn bộ danh sách nghi ngờ ra file cho bộ phận AML, hệ thống dùng search_after thay vì from/size để vượt giới hạn 10.000 mà không sập RAM.
So sánh: nếu nhét cả ba điều kiện lọc vào must, ES sẽ tính điểm cho cả 180 triệu và không cache được — truy vấn chậm hơn hàng chục lần và tiêu tốn tài nguyên vô ích.
Về mặt full-text tiếng Việt, cách tiếp cận này tương đồng với full-text search trong Postgres mà ta bàn ở bài fulltext search của PostgreSQL — điểm khác là ES cho điểm BM25 và mở rộng ngang tốt hơn nhiều trên khối lượng log/giao dịch lớn.
Ghi nhớ
- Query DSL là JSON, không phải SQL.
querylà mệnh đề chính; kèmfrom/size,sort,_source,highlight,aggs. matchphân tích chuỗi truy vấn (full-text) — dùng cho trườngtext.termkhông phân tích (khớp chính xác) — dùng chokeyword/số/ngày.- Đừng dùng
termtrên trườngtext→ thường trả về 0 hit vì token index đã hạ chữ thường/tách nhỏ, không khớp chuỗi nguyên văn. boolghép mệnh đề:must(bắt buộc, có điểm),should(cộng điểm),must_not(loại trừ),filter(bắt buộc, không điểm).- Query context tính
_score; filter context chỉ yes/no, không tính điểm và được cache (bitset). Đặt mọi bộ lọc không ảnh hưởng liên quan vàofilterđể tăng tốc. - BM25 (cải tiến TF-IDF): thưởng từ xuất hiện nhiều (bão hòa dần), từ hiếm (IDF cao), và khớp trong trường ngắn. Dùng
explain: trueđể hiểu điểm. - Deep pagination:
from/sizebị chặn ởmax_result_window(mặc định 10.000) và tốn tài nguyên. Dùngsearch_aftervới khóa sort duy nhất để cuộn sâu chi phí hằng số. highlighttô đậm đoạn khớp.fuzzycho sai chính tả nhẹ;wildcardrất tốn tài nguyên, tránh dùng — nhất là wildcard đầu chuỗi.
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.