Full-text search với Elasticsearch/OpenSearch

4 câu hỏi~30 phútTừ khóa: tsvector, unaccent, GIN, inverted index, analyzer, asciifolding, edge n-gram, text vs keyword, bool filter, fuzzy, BM25, search_after, aggregation

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:

2. Vấn đề với LIKE '%x%'

SELECT * FROM products WHERE name ILIKE '%ao thun nam%';

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:

textkeyword
Phân tíchQua analyzer, tách từGiữ nguyên chuỗi
Dùng choTìm kiếm (name, description)Lọc chính xác, sort, aggregation (category, brand, sku)
Querymatchterm, 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

LIKEPostgreSQL FTS + GINElasticsearch / OpenSearch
IndexKhông (trừ pg_trgm)GIN trên tsvectorInverted index phân tán
Bỏ dấu tiếng ViệtKhôngunaccentasciifolding / icu_folding
TypoKhôngpg_trgm similarity (hạn chế)fuzziness AUTO
RelevanceKhôngts_rank (đơn giản)BM25, boost, function_score
FacetGROUP BYGROUP BYAggregation trong 1 request
AutocompleteKhôngprefix tsqueryedge n-gram, completion suggester
Vận hànhKhông gì thêmKhông gì thêmCluster 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

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:

{
  "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.

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


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.