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