Full-text search với Elasticsearch/OpenSearch
Tiếp đi: tìm kiếm sản phẩm cho shop-app. Khách gõ "ao thun nam" mà không ra "Áo thun nam", mình đang dùng LIKE.
Đây là lúc database quan hệ bắt đầu "hụt hơi". Ta đi từ PostgreSQL trước, rồi mới tới Elasticsearch/OpenSearch.
1. Bài toán
shop-app có 2 triệu sản phẩm. Ô tìm kiếm cần:
- "ao thun nam" khớp "Áo thun nam cổ tròn" (không dấu, không phân biệt hoa thường).
- Gõ sai "ao thuun" vẫn ra (typo tolerance).
- Lọc giá 100k–300k, danh mục "Thời trang nam"; sắp xếp theo độ liên quan.
- Bên trái hiển thị số lượng theo danh mục, theo thương hiệu (facet).
- Gõ "ao th" đã gợi ý (autocomplete).
2. Vấn đề với LIKE '%x%'
SELECT * FROM products WHERE name ILIKE '%ao thun nam%';
- Không dùng được B-tree: wildcard ở đầu → seq scan 2 triệu dòng (xem bài 6.4).
- Không có dấu: "ao" ≠ "Áo".
- Phải đúng thứ tự từ: "thun nam ao" không ra.
- Không có relevance: sản phẩm có chữ "áo thun nam" trong tên và sản phẩm chỉ có trong mô tả được coi như nhau.
- Không typo, không gợi ý.
3. Giải pháp
Lựa chọn đầu tiên: full-text của PostgreSQL. Thường là đủ cho vài trăm nghìn tới vài triệu dòng, không thêm hệ thống mới.
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- unaccent không IMMUTABLE nên bọc lại để dùng trong generated column
CREATE FUNCTION f_unaccent(text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $$ SELECT public.unaccent('public.unaccent', $1) $$;
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', f_unaccent(coalesce(name,''))), 'A') ||
setweight(to_tsvector('simple', f_unaccent(coalesce(description,''))), 'B')
) STORED;
CREATE INDEX idx_products_search ON products USING GIN (search_vector);
SELECT id, name, ts_rank(search_vector, q) AS rank
FROM products, plainto_tsquery('simple', f_unaccent('ao thun nam')) q
WHERE search_vector @@ q AND price BETWEEN 100000 AND 300000
ORDER BY rank DESC LIMIT 20;
Giới hạn: không có stemmer tiếng Việt, typo chỉ xấp xỉ bằng pg_trgm, ts_rank phải tính trên mọi dòng khớp (chậm khi khớp nhiều), facet = nhiều câu GROUP BY, và tải search đè lên DB chính.
Khi vượt giới hạn đó: search engine (Elasticsearch/OpenSearch). Ý tưởng cốt lõi là inverted index:
Document Inverted index (term → doc ids)
1: "Áo thun nam cổ tròn" ao → 1, 2, 3
2: "Áo thun nữ" thun → 1, 2
3: "Áo sơ mi nam" nam → 1, 3
nu → 2
Query "ao thun nam": ao ∩/∪ thun ∪ nam → chấm điểm → 1 (3 từ) > 3, 2
Giống mục lục cuối sách: tra từ → ra trang, không đọc cả sách. Vì sao cấu trúc này nhanh (term dictionary, FST, posting list, segment, doc values, shard) xem bài 6.12.
Analyzer = cách biến chữ thành term, chạy cả lúc index lẫn lúc search:
"Áo Thun-Nam"
│ char_filter (tùy chọn: bỏ HTML...)
▼ tokenizer standard
["Áo", "Thun", "Nam"]
│ filter lowercase
▼
["áo", "thun", "nam"]
│ filter asciifolding (bỏ dấu tiếng Việt)
▼
["ao", "thun", "nam"] ← lưu vào inverted index
Thêm edge_ngram cho autocomplete: "thun" → "th", "thu", "thun". Chỉ dùng lúc index, lúc search dùng analyzer thường, nếu không "ao th" sẽ khớp quá rộng.
text vs keyword:
| text | keyword | |
|---|---|---|
| Phân tích | Qua analyzer, tách từ | Giữ nguyên chuỗi |
| Dùng cho | Tìm kiếm (name, description) | Lọc chính xác, sort, aggregation (category, brand, sku) |
| Query | match | term, terms |
4. Code từng bước
Bước 1: dependency (Spring Boot 3):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- Dùng OpenSearch: org.opensearch.client:spring-data-opensearch-starter -->
spring:
elasticsearch:
uris: ${SEARCH_URL}
username: ${SEARCH_USER}
password: ${SEARCH_PASSWORD}
Bước 2: index settings + mapping (src/main/resources/search/product-settings.json):
{
"analysis": {
"filter": {
"autocomplete_filter": { "type": "edge_ngram", "min_gram": 2, "max_gram": 15 }
},
"analyzer": {
"vi_folding": { "tokenizer": "standard", "filter": ["lowercase", "asciifolding"] },
"vi_autocomplete": { "tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "autocomplete_filter"] }
},
"normalizer": {
"lower_folding": { "type": "custom", "filter": ["lowercase", "asciifolding"] }
}
}
}
Bước 3: document Java:
@Document(indexName = "products")
@Setting(settingPath = "search/product-settings.json")
public record ProductDoc(
@Id String id,
@MultiField(
mainField = @Field(type = FieldType.Text, analyzer = "vi_folding"),
otherFields = {
@InnerField(suffix = "auto", type = FieldType.Text,
analyzer = "vi_autocomplete", searchAnalyzer = "vi_folding"),
@InnerField(suffix = "raw", type = FieldType.Keyword, normalizer = "lower_folding")
})
String name,
@Field(type = FieldType.Text, analyzer = "vi_folding") String description,
@Field(type = FieldType.Keyword) String category,
@Field(type = FieldType.Keyword) String brand,
@Field(type = FieldType.Scaled_Float, scalingFactor = 100) BigDecimal price,
@Field(type = FieldType.Integer) int soldCount,
@Field(type = FieldType.Boolean) boolean inStock
) {}
Query: phân biệt must (tính điểm) và filter (chỉ lọc, không tính điểm, được cache):
@Service
@RequiredArgsConstructor
public class ProductSearchService {
private final ElasticsearchOperations ops;
public SearchPage<ProductDoc> search(String text, String category,
BigDecimal min, BigDecimal max, Pageable page) {
var query = NativeQuery.builder()
.withQuery(q -> q.bool(b -> {
b.must(m -> m.multiMatch(mm -> mm
.query(text)
.fields("name^3", "name.auto", "description") // tên quan trọng gấp 3
.fuzziness("AUTO") // "thuun" vẫn khớp "thun"
.operator(Operator.And)));
if (category != null) b.filter(f -> f.term(t -> t.field("category").value(category)));
b.filter(f -> f.range(r -> r.number(n -> n.field("price")
.gte(min.doubleValue()).lte(max.doubleValue()))));
b.filter(f -> f.term(t -> t.field("inStock").value(true)));
return b;
}))
.withAggregation("by_brand", Aggregation.of(a -> a.terms(t -> t.field("brand").size(20))))
.withPageable(page)
.build();
return SearchHitSupport.searchPageFor(ops.search(query, ProductDoc.class), page);
}
}
Bước 4: test bằng curl:
curl -s -u "$SEARCH_USER:$SEARCH_PASSWORD" "$SEARCH_URL/products/_analyze" \
-H 'Content-Type: application/json' \
-d '{"analyzer":"vi_folding","text":"Áo Thun-Nam"}'
# → tokens: ao, thun, nam
curl -s "http://localhost:8080/api/products/search?q=ao%20thuun%20nam&min=100000&max=300000"
5. Cách hoạt động
"ao thuun nam" + category=thoi-trang-nam + price 100k–300k
│
▼ analyzer search (vi_folding) → [ao, thuun, nam]
┌──────────────── shard 0 ─────┐ ┌──── shard 1 ────┐ (query phase: song song)
│ filter: category, price, │ │ ... │
│ inStock (bitset, cache) │ │ │
│ must: tra inverted index │ │ │
│ thuun ~ thun (fuzzy, 1 sửa)│ │ │
│ chấm điểm BM25, giữ top 20 │ │ top 20 │
└──────────────┬───────────────┘ └──────┬──────────┘
└──── coordinating node ─┘
merge → top 20 toàn cục → fetch phase lấy _source
+ aggregation by_brand (cộng từ mọi shard)
BM25 (mặc định): điểm cao khi từ xuất hiện nhiều trong document (nhưng có bão hòa), từ hiếm trong toàn index (IDF: "thun" giá trị hơn "ao"), field ngắn (từ trong tên ngắn quan trọng hơn trong mô tả dài). Boosting: name^3, hoặc function_score cộng thêm theo soldCount để sản phẩm bán chạy lên trên.
6. So sánh
| LIKE | PostgreSQL FTS + GIN | Elasticsearch / OpenSearch | |
|---|---|---|---|
| Index | Không (trừ pg_trgm) | GIN trên tsvector | Inverted index phân tán |
| Bỏ dấu tiếng Việt | Không | unaccent | asciifolding / icu_folding |
| Typo | Không | pg_trgm similarity (hạn chế) | fuzziness AUTO |
| Relevance | Không | ts_rank (đơn giản) | BM25, boost, function_score |
| Facet | GROUP BY | GROUP BY | Aggregation trong 1 request |
| Autocomplete | Không | prefix tsquery | edge n-gram, completion suggester |
| Vận hành | Không gì thêm | Không gì thêm | Cluster mới + đồng bộ dữ liệu (bài 6.13) |
7. Khi nào nên dùng / không nên dùng
- Dùng PostgreSQL FTS: dưới vài triệu dòng, search phụ (admin tìm đơn), không cần typo/facet phức tạp.
- Dùng ES/OpenSearch: search là tính năng chính của sản phẩm, cần typo, facet, autocomplete, ranking tùy chỉnh, tải search lớn.
- Không bao giờ: coi ES là source of truth. Giá, tồn kho, đơn hàng lưu ở PostgreSQL. ES chỉ là bản sao để tìm, có thể xóa đi và build lại bất cứ lúc nào. Trang chi tiết và lúc đặt hàng luôn đọc giá từ DB.
Trang danh sách mình làm phân trang kiểu page=600&size=20 thì ES báo lỗi "Result window is too large". Tăng max_result_window lên là xong nhỉ?
Tăng được, nhưng gần như luôn là cách sai. Lý do nằm ở cách search phân tán:
from=12000, size=20, index có 5 shard
→ mỗi shard phải sắp xếp và trả về 12.020 kết quả
→ coordinating node merge 60.100 kết quả, bỏ 60.080, giữ 20
Trang càng sâu càng tốn RAM/CPU, giới hạn mặc định from+size ≤ 10.000
Cách đúng:
- UI cho người: không ai đọc trang 600. Giới hạn 50 trang, khuyến khích lọc thêm. Google cũng vậy.
- Cuộn vô tận / API lấy hết: dùng
search_after(giống keyset pagination ở bài 6.6). Sort theo_score+idlàm tie-breaker, gửi giá trị sort của phần tử cuối trang trước. - Cần kết quả ổn định khi dữ liệu thay đổi giữa các trang: kết hợp Point in Time (PIT).
- Export toàn bộ cho batch job: PIT +
search_after(scroll API cũ không còn khuyến nghị cho việc này).
{
"size": 20,
"query": { "match": { "name": "ao thun nam" } },
"sort": [ { "_score": "desc" }, { "id": "asc" } ],
"search_after": [ 12.53, "p-889120" ]
}
Mình để category là text cho tiện tìm luôn, rồi lọc term "Thời trang nam" lại không ra gì. Sao vậy?
Bạn gặp đúng lỗi kinh điển nhất. text đã qua analyzer nên trong index chỉ có các term thoi, trang, nam. Còn term query không phân tích input, nên nó đi tìm nguyên chuỗi "Thời trang nam", không tồn tại.
- Lọc chính xác, facet, sort →
keyword. - Vừa muốn tìm vừa muốn lọc → multi-field:
category(keyword) +category.text(text). Giốngname.rawở trên. - Tốt hơn nữa: lọc theo
categoryId/slug (thoi-trang-nam) chứ không theo tên hiển thị, vì tên có thể đổi.
Mapping của field đã tạo không đổi kiểu được. Phải tạo index mới với mapping đúng rồi reindex: cách làm không downtime bằng alias ở bài 6.13.
Phỏng vấn hỏi về full-text search và Elasticsearch thì trả lời sao?
Q1. Vì sao không dùng LIKE '%keyword%' cho ô tìm kiếm?
🇻🇳 Tiếng Việt:
Wildcard ở đầu làm B-tree vô dụng nên phải quét toàn bảng. LIKE cũng không có relevance, không bỏ dấu, không chịu lỗi gõ, phải đúng thứ tự từ. Em sẽ thử full-text của PostgreSQL trước với tsvector, unaccent và GIN index. Khi cần typo, facet, autocomplete và ranking tốt hơn ở quy mô lớn thì mới dùng Elasticsearch hoặc OpenSearch.
🇬🇧 English:
A leading wildcard makes the B-tree useless, so it scans the whole table. LIKE also has no relevance, no accent folding, no typo tolerance and needs the exact word order. I would first try PostgreSQL full-text search with tsvector, unaccent and a GIN index. Only when we need typo tolerance, facets, autocomplete and better ranking at scale would I move to Elasticsearch or OpenSearch.
Q2. Inverted index và analyzer là gì?
🇻🇳 Tiếng Việt:
Inverted index ánh xạ từ term sang danh sách document chứa nó, giống mục lục cuối sách. Analyzer quyết định term là gì: tokenizer tách từ, sau đó filter như lowercase, asciifolding để bỏ dấu tiếng Việt. Cùng analyzer phải chạy lúc index và lúc search để "ao thun" khớp "Áo thun". Với autocomplete thì dùng edge n-gram lúc index và analyzer thường lúc search.
🇬🇧 English:
An inverted index maps each term to the list of documents that contain it, like the index at the back of a book. The analyzer decides what the terms are: a tokenizer splits the text, then filters such as lowercase and asciifolding remove Vietnamese accents. The same analysis must run at index and search time so that "ao thun" matches "Áo thun". For autocomplete, I use edge n-grams at index time and a normal analyzer at search time.
Q3. must và filter trong bool query khác nhau gì?
🇻🇳 Tiếng Việt:
must tham gia tính điểm relevance, dùng cho phần chữ người dùng gõ. filter chỉ trả lời có hoặc không, không tính điểm và được cache, dùng cho giá, danh mục, còn hàng. Đặt điều kiện lọc vào must vừa chậm hơn vừa làm sai thứ tự kết quả.
🇬🇧 English:
must contributes to the relevance score and is used for the text the user types. filter is a yes-or-no match, is not scored and can be cached, so it is used for price, category and stock. Putting filters in must is slower and distorts the ranking.
Q4. Elasticsearch có thay được database không?
🇻🇳 Tiếng Việt:
Không. Elasticsearch không có transaction nhiều document, là near real-time và có thể mất ghi khi lỗi. Em coi nó là read model để tìm kiếm. PostgreSQL là source of truth; giá và tồn kho lúc đặt hàng luôn đọc từ DB, và index có thể build lại từ DB bất cứ lúc nào.
🇬🇧 English:
No. Elasticsearch has no multi-document transactions, it is near real-time, and writes can be lost on failure. I treat it as a read model for search. PostgreSQL is the source of truth; price and stock at checkout are always read from the database, and the index can be rebuilt from the database at any time.
Cách nhớ ngắn gọn
- LIKE → PostgreSQL FTS (tsvector + unaccent + GIN) → ES/OpenSearch.
- Inverted index: term → docs. Analyzer: tokenizer + lowercase + asciifolding.
- text để tìm (match), keyword để lọc/sort/facet (term).
- must tính điểm, filter thì không; BM25 + boost.
- Trang sâu → search_after; ES không phải source of truth.
Tự kiểm tra
Vì sao unaccent cần bọc trong hàm IMMUTABLE?
Generated column và index expression chỉ nhận hàm IMMUTABLE; unaccent mặc định là STABLE vì phụ thuộc dictionary.
Filter nào giúp "ao" khớp "Áo"?
lowercase + asciifolding (hoặc icu_folding), chạy cả lúc index lẫn lúc search.
Vì sao edge n-gram chỉ dùng lúc index?
Nếu cả query cũng bị cắt n-gram thì "ao th" sinh ra "ao", "th"... và khớp quá nhiều sản phẩm không liên quan.
BM25 cho điểm cao hơn khi nào?
Term xuất hiện nhiều trong doc (có bão hòa), term hiếm trong toàn index, và field chứa term ngắn.
Phân trang sâu nên dùng gì?
search_after với sort ổn định có tie-breaker, kèm Point in Time nếu cần snapshot nhất quán.