Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mapping & Field Types với .NET

Cách tiếp cận Mapping trong .NET

Có 3 cách khai báo mapping khi dùng Elastic.Clients.Elasticsearch:

1. Attribute Mapping   - Khai báo trên model class (đơn giản, tập trung)
2. Fluent Mapping      - Trong code khi tạo index (linh hoạt, type-safe)
3. Inference Mapping   - ES client tự suy ra từ .NET types (nhanh nhưng ít kiểm soát)

Attribute Mapping

using Elastic.Clients.Elasticsearch.Mapping;

public class Product
{
    // Không cần attribute - ES tự map theo type name
    public int Id { get; set; }

    // Text field: full-text search (analyzed)
    [Text(Analyzer = "standard")]
    public string Name { get; set; } = string.Empty;

    // Text với custom analyzer
    [Text(Analyzer = "english", SearchAnalyzer = "english")]
    public string? Description { get; set; }

    // Keyword: exact match, sort, aggregation
    [Keyword(IgnoreAbove = 256)]
    public string Category { get; set; } = string.Empty;

    // ScaledFloat: lưu như long, tốt cho tiền tệ
    [ScaledFloat(ScalingFactor = 100)]
    public decimal Price { get; set; }

    // Numeric
    [Integer]
    public int StockQuantity { get; set; }

    [Boolean]
    public bool InStock { get; set; }

    // Array - không cần type đặc biệt trong ES
    [Keyword]
    public List<string> Tags { get; set; } = [];

    [Date(Format = "strict_date_optional_time")]
    public DateTime CreatedAt { get; set; }

    // Object thông thường (flatten trong ES)
    public ProductSpecs? Specs { get; set; }
}

public class ProductSpecs
{
    [Keyword]
    public string? Storage { get; set; }

    [Keyword]
    public string? Ram { get; set; }

    [HalfFloat]
    public float? ScreenSize { get; set; }
}

Fluent Mapping trong CreateIndex

public async Task CreateProductIndexAsync()
{
    await _es.Indices.CreateAsync<Product>("products", c => c
        .Settings(s => s
            .NumberOfShards(1)
            .NumberOfReplicas(1)
            .Analysis(a => a
                .Analyzers(an => an
                    .Custom("vi_analyzer", ca => ca
                        .Tokenizer("standard")
                        .Filter(["lowercase", "asciifolding"])
                    )
                )
            )
        )
        .Mappings(m => m
            .Dynamic(DynamicMapping.Strict) // Chỉ cho phép fields đã khai báo
            .Properties(p => p
                // text + keyword multi-field (search and sort on same field)
                .Text(t => t
                    .Name(n => n.Name)
                    .Analyzer("vi_analyzer")
                    .Fields(f => f
                        .Keyword(k => k
                            .Name("keyword")
                            .IgnoreAbove(256)
                        )
                    )
                )
                .Text(t => t.Name(n => n.Description).Analyzer("english"))
                .Keyword(k => k.Name(n => n.Category))
                .Keyword(k => k.Name(n => n.Brand))
                .ScaledFloat(sf => sf.Name(n => n.Price).ScalingFactor(100))
                .Boolean(b => b.Name(n => n.InStock))
                .Integer(i => i.Name(n => n.StockQuantity))
                .Keyword(k => k.Name(n => n.Tags))
                .Date(d => d.Name(n => n.CreatedAt).Format("strict_date_optional_time"))
                .GeoPoint(g => g.Name("location"))
                // Nested object - giữ quan hệ giữa sub-fields
                .Nested<ProductReview>(n => n
                    .Name("reviews")
                    .Properties(rp => rp
                        .Keyword(k => k.Name(r => r.UserId))
                        .Byte(b => b.Name(r => r.Score))
                        .Text(t => t.Name(r => r.Comment))
                    )
                )
            )
        )
    );
}

Field Types Quan trọng

text vs keyword

// text: Full-text search - được phân tích (tokenize, lowercase, v.v.)
// → "Apple iPhone 15 Pro" → tokens: ["apple", "iphone", "15", "pro"]
// → Tìm "iphone" → Match ✅

// keyword: Exact match - không phân tích
// → "Apple iPhone 15 Pro" → lưu nguyên
// → Tìm "iphone" → No match ❌
// → Dùng cho: filter chính xác, sort, aggregation

// Multi-field: dùng cả hai cho cùng một field
.Text(t => t.Name(n => n.Category)
    .Fields(f => f.Keyword(k => k.Name("keyword")))
)
// Search full-text: category
// Aggregation / Sort: category.keyword

Nested vs Object

// Object (mặc định): flatten → mất quan hệ trong array
// Vấn đề:
// reviews: [{ user: "Alice", score: 5 }, { user: "Bob", score: 1 }]
// Flatten: reviews.user: ["Alice", "Bob"], reviews.score: [5, 1]
// → Query user=Alice AND score=1 → Sai! (match vì flatten)

// Nested: mỗi item là hidden document riêng
.Nested<ProductReview>(n => n
    .Name("reviews")
    .Properties(p => p
        .Keyword(k => k.Name(r => r.UserId))
        .Byte(b => b.Name(r => r.Score))
    )
)
// Dùng nested query để tìm chính xác theo cặp

Xem Mapping từ .NET

public async Task<string> GetMappingAsync()
{
    var response = await _es.Indices.GetMappingAsync(m =>
        m.Indices("products")
    );

    var properties = response.Indices["products"].Mappings.Properties;
    foreach (var (name, prop) in properties)
    {
        Console.WriteLine($"{name}: {prop.Type}");
    }

    return response.DebugInformation;
}

Thay đổi Mapping - Reindex Pattern

// Không thể thay đổi field type sau khi đã có data → cần Reindex

public async Task ReindexAsync(string sourceIndex, string destIndex)
{
    // 1. Tạo index mới với mapping đúng
    await CreateIndexWithNewMappingAsync(destIndex);

    // 2. Reindex data
    var response = await _es.ReindexAsync(r => r
        .Source(s => s.Index(sourceIndex))
        .Dest(d => d.Index(destIndex))
    );

    if (!response.IsSuccess())
        throw new Exception($"Reindex failed: {response.DebugInformation}");

    Console.WriteLine($"Reindexed {response.Total} documents");

    // 3. Chuyển alias (zero-downtime)
    await _es.Indices.UpdateAliasesAsync(a => a
        .Actions(
            new RemoveIndexAction { Index = sourceIndex, Alias = "products-live" },
            new AddAction { Index = destIndex, Alias = "products-live" }
        )
    );
}

Dynamic Mapping

Elasticsearch tự động phát hiện và tạo mapping khi index document lần đầu.

# Index một document mà không cần tạo mapping trước (tham khảo)
POST /products/_doc
{
  "name": "Laptop Pro",
  "price": 1299.99,
  "in_stock": true,
  "tags": ["laptop", "business"],
  "released_at": "2024-01-15"
}

# ES tự động tạo mapping
GET /products/_mapping
# Response:
{
  "products": {
    "mappings": {
      "properties": {
        "name":        { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
        "price":       { "type": "float" },
        "in_stock":    { "type": "boolean" },
        "tags":        { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
        "released_at": { "type": "date" }
      }
    }
  }
}

Vấn đề với Dynamic Mapping:

  • Có thể tạo mapping không mong muốn
  • Không thể thay đổi field type sau khi đã có data
  • Có thể gây “mapping explosion” với data dynamic

Explicit Mapping - Best Practice

# Tạo index với mapping rõ ràng TRƯỚC khi index data
PUT /products
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "english"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "category": {
        "type": "keyword"
      },
      "tags": {
        "type": "keyword"
      },
      "brand": {
        "type": "keyword"
      },
      "rating": {
        "type": "half_float"
      },
      "in_stock": {
        "type": "boolean"
      },
      "stock_quantity": {
        "type": "integer"
      },
      "created_at": {
        "type": "date",
        "format": "strict_date_optional_time||epoch_millis"
      },
      "location": {
        "type": "geo_point"
      },
      "specs": {
        "type": "object",
        "properties": {
          "storage": { "type": "keyword" },
          "ram": { "type": "keyword" },
          "screen_size": { "type": "float" }
        }
      },
      "reviews": {
        "type": "nested",
        "properties": {
          "user_id": { "type": "keyword" },
          "score": { "type": "byte" },
          "comment": { "type": "text" }
        }
      }
    }
  }
}

Field Types Quan trọng

text vs keyword

# text: Full-text search, được analyze (tách từ, lowercase, v.v.)
# keyword: Exact match, sorting, aggregations, không analyze

# Ví dụ:
"name": "Apple iPhone 15 Pro"

# text field → tokens: ["apple", "iphone", "15", "pro"]
# → Tìm "iphone" → Match!
# → Tìm "Apple iPhone 15 Pro" → Match!

# keyword field → lưu nguyên "Apple iPhone 15 Pro"
# → Tìm "iphone" → No match!
# → Tìm "Apple iPhone 15 Pro" → Match!
# → Dùng cho filter, sort, aggregations
# Multi-field: vừa search full-text vừa filter/sort
"name": {
  "type": "text",
  "fields": {
    "keyword": { "type": "keyword" }  # name.keyword
  }
}

# Search full-text
GET /products/_search
{ "query": { "match": { "name": "iphone" } } }

# Exact filter hoặc aggregation
GET /products/_search
{ "aggs": { "brands": { "terms": { "field": "name.keyword" } } } }

Numeric Types

byte:         -128 to 127
short:        -32,768 to 32,767
integer:      -2^31 to 2^31-1
long:         -2^63 to 2^63-1
float:        32-bit IEEE 754
double:       64-bit IEEE 754
half_float:   16-bit (tiết kiệm space, kém chính xác hơn)
scaled_float: Stored as long, scaled by factor (tốt cho currency)

# Dùng scaled_float cho giá tiền
"price": {
  "type": "scaled_float",
  "scaling_factor": 100  # 19.99 → stored as 1999
}

date

"created_at": {
  "type": "date",
  "format": "strict_date_optional_time||yyyy-MM-dd||epoch_millis"
}

# Tất cả formats đều được chấp nhận:
{ "created_at": "2024-01-15" }
{ "created_at": "2024-01-15T10:30:00Z" }
{ "created_at": 1705315800000 }   # Unix timestamp ms

nested vs object

# object: Thông thường, flatten thành flat fields
"address": {
  "type": "object",
  "properties": {
    "city": { "type": "keyword" },
    "country": { "type": "keyword" }
  }
}
# Vấn đề: Mất mối quan hệ giữa fields của các objects trong array

# Ví dụ vấn đề:
{
  "comments": [
    { "user": "Alice", "score": 5 },
    { "user": "Bob",   "score": 1 }
  ]
}
# Flatten: comments.user: ["Alice", "Bob"]
#          comments.score: [5, 1]
# Query: user=Alice AND score=1 → Sai! Match vì không theo cặp

# nested: Mỗi nested document là hidden document riêng
"comments": {
  "type": "nested",
  "properties": {
    "user":  { "type": "keyword" },
    "score": { "type": "integer" }
  }
}
# Query nested: Đúng! Dùng nested query

Index Templates

# Áp dụng settings/mappings tự động cho indices mới
PUT /_index_template/products-template
{
  "index_patterns": ["products-*"],   # Áp dụng với indices matching pattern
  "priority": 100,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1
    },
    "mappings": {
      "dynamic": "strict",            # Không cho phép fields không có mapping
      "properties": {
        "name": { "type": "text" },
        "price": { "type": "scaled_float", "scaling_factor": 100 }
      }
    }
  }
}

Thay đổi Mapping

# Không thể thay đổi field type sau khi đã index data
# Giải pháp: Reindex

# 1. Tạo index mới với mapping đúng
PUT /products-v2
{ "mappings": { "properties": { ... } } }

# 2. Reindex data
POST /_reindex
{
  "source": { "index": "products" },
  "dest": { "index": "products-v2" }
}

# 3. Tạo alias trỏ tới index mới
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products",    "alias": "products-latest" } },
    { "add":    { "index": "products-v2", "alias": "products-latest" } }
  ]
}

# Application dùng alias → không cần đổi code
GET /products-latest/_search