Aggregation'lar ve Facet'li Arama: terms, range, date_histogram, nested ve post_filter
Facet'li aramada arama metni sorguya, seçili filtreler post_filter'a gider ve her facet kendi filtresi hariç diğer seçili filtrelerle sarılır; yoksa seçilen filtre kendi facet'ini boşaltır. Gerçek çıktılarla terms, range, date_histogram, avg ve cardinality, nested ile reverse_nested, Bookstore'un facet'li /books/search endpoint'i, 5 shard'da terms sayılarının yanılması ve doc_count_error_upper_bound, aynı raporun ES|QL karşılığı.
Sürüm notu: Bu yazıdaki örnekler Elasticsearch 9.5.6, .NET 10 ve Elastic.Clients.Elasticsearch 9.5.3 ile, 2. yazıda kurduğumuz lab'da ve 20.035 kitaplık books index'inde çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için arama sayfasının sol tarafını yapıyoruz: kategori, dil, fiyat aralığı ve puan kutuları, her seçeneğin yanında kaç kitap olduğu. Kullanıcı "roman" aradı ve kategori kutusunda "Historical Fiction"ı işaretledi. Sayıları üreten aggregation'ları aynı isteğe koyuyoruz ve seçilen kategoriyi 6. yazıdaki gibi bool'un filter'ına ekliyoruz:
GET /books/_search
{
"size": 0,
"query": {
"bool": {
"must": [ { "match": { "description.tr": "roman" } } ],
"filter": [ { "term": { "categories": "Historical Fiction" } } ]
}
},
"aggs": {
"categories": { "terms": { "field": "categories" } },
"prices": { "range": { "field": "price", "ranges": [ { "key": "0-200", "to": 200 }, { "key": "200-400", "from": 200, "to": 400 }, { "key": "400+", "from": 400 } ] } }
}
}
Kullanıcı kategori kutusunda ne görür? "Fiction 9, Classics 8" gibi diğer seçenekler hâlâ orada mı? Fiyat kutusu hangi kitapları sayar: "roman" geçen bütün kitapları mı, yalnız seçilen kategoridekileri mi?
Bucket ve Metric Aggregation'ları
Aggregation'lar arama sonucuyla aynı istekte, sorgunun eşleştirdiği document'lar üzerinde hesaplanıyor. İki ana türü var: bucket aggregation'lar document'ları gruplara ayırıyor (terms, range, date_histogram, histogram), metric aggregation'lar bir sayı hesaplıyor (avg, sum, min, max, cardinality). Önce filtresiz "roman" araması:
GET /books/_search?filter_path=hits.total,aggregations
{
"size": 0,
"query": { "match": { "description.tr": "roman" } },
"aggs": {
"categories": { "terms": { "field": "categories" } },
"languages": { "terms": { "field": "language" } },
"prices": { "range": { "field": "price", "ranges": [ { "key": "0-200", "to": 200 }, { "key": "200-400", "from": 200, "to": 400 }, { "key": "400+", "from": 400 } ] } },
"avg_price": { "avg": { "field": "price" } },
"years": { "date_histogram": { "field": "publishedOn", "calendar_interval": "year", "format": "yyyy", "min_doc_count": 1 } }
}
}
{"hits":{"total":{"value":9,"relation":"eq"}},"aggregations":{"categories":{"doc_count_error_upper_bound":0,"sum_other_doc_count":0,"buckets":[{"key":"Fiction","doc_count":9},{"key":"Classics","doc_count":8},{"key":"Historical Fiction","doc_count":1},{"key":"Science Fiction","doc_count":1}]},"languages":{"doc_count_error_upper_bound":0,"sum_other_doc_count":0,"buckets":[{"key":"tr","doc_count":9}]},"prices":{"buckets":[{"key":"0-200","to":200.0,"doc_count":5},{"key":"200-400","from":200.0,"to":400.0,"doc_count":3},{"key":"400+","from":400.0,"doc_count":1}]},"avg_price":{"value":267.6666666666667},"years":{"buckets":[{"key_as_string":"2016","key":1451606400000,"doc_count":1},{"key_as_string":"2018","key":1514764800000,"doc_count":1},{"key_as_string":"2019","key":1546300800000,"doc_count":1},{"key_as_string":"2020","key":1577836800000,"doc_count":1},{"key_as_string":"2021","key":1609459200000,"doc_count":3},{"key_as_string":"2022","key":1640995200000,"doc_count":1},{"key_as_string":"2023","key":1672531200000,"doc_count":1}]}}}
"size": 0 sonuç listesini değil yalnız aggregation'ları istiyor. Kategori sayılarının toplamı (19) kitap sayısından (9) büyük, çünkü categories çok değerli bir alan ve bir kitap birden çok bucket'a düşüyor; arayüzde bu normal ve beklenen bir durum. range'de from dahil, to hariç: 200 liralık bir kitap "200-400"te sayılıyor. date_histogram'daki min_doc_count: 1 boş yılları atlıyor; grafik çizerken boşlukları göstermek istiyorsan 0 bırak.
Aggregation'lar yalnız keyword, sayı, tarih gibi doc value'su olan alanlarda çalışıyor. title gibi bir text alanda terms denemenin fielddata hatasıyla bittiğini 3. yazıda gördük; mapping'deki .keyword alt alanları bunun için var.
Nested Aggregation: Yazarlar
Yazarlar nested olduğu için onlara kökteki bir aggregation erişemiyor; önce nested aggregation ile yazar document'larına inmek, gerekirse reverse_nested ile kitaba geri çıkmak gerekiyor. Bütün katalogda yazar rolleri, tekil yazar sayısı ve en çok kitabı olan üç yazarın kitaplarının ortalama fiyatı:
GET /books/_search?filter_path=aggregations
{
"size": 0,
"aggs": {
"authors": {
"nested": { "path": "authors" },
"aggs": {
"roles": { "terms": { "field": "authors.role" } },
"distinct_names": { "cardinality": { "field": "authors.name.keyword" } },
"top": {
"terms": { "field": "authors.name.keyword", "size": 3 },
"aggs": { "books": { "reverse_nested": {}, "aggs": { "avg_price": { "avg": { "field": "price" } } } } }
}
}
}
}
}
{"aggregations":{"authors":{"doc_count":20041,"top":{"doc_count_error_upper_bound":0,"sum_other_doc_count":19353,"buckets":[{"key":"Donald Morgan","doc_count":232,"books":{"doc_count":232,"avg_price":{"value":496.35258620689655}}},{"key":"Ada Wells","doc_count":228,"books":{"doc_count":228,"avg_price":{"value":473.2815789473684}}},{"key":"Barbara Hughes","doc_count":228,"books":{"doc_count":228,"avg_price":{"value":489.51842105263154}}}]},"distinct_names":{"value":128},"roles":{"doc_count_error_upper_bound":0,"sum_other_doc_count":0,"buckets":[{"key":"author","doc_count":20035},{"key":"translator","doc_count":6}]}}}}
doc_count: 20041 yazar kaydı sayısı (20.035 kitap ve 6 çevirmen). reverse_nested olmadan avg_price çalışmazdı; price kitabın alanı, yazar document'ında yok. cardinality tekil değer sayısını HyperLogLog++ algoritmasıyla yaklaşık hesaplıyor: bellek kullanımı sabit, karşılığında büyük sayılarda küçük bir hata payı var. Varsayılan precision_threshold (3.000) altındaki sayılar pratikte kesin; 128 de kesin değer (29 seçilmiş yazar adı ve 100 sentetik ad, satıştan kaldırılan Moby-Dick'in yazarı hariç).
Facet'li Arama: Seçili Filtre Kendi Facet'ini Bozmasın
Tahmindeki isteğin cevabı:
{"hits":{"total":{"value":1}},"aggregations":{"categories":{"buckets":[{"key":"Fiction","doc_count":1},{"key":"Historical Fiction","doc_count":1}]},"prices":{"buckets":[{"key":"0-200","doc_count":0},{"key":"200-400","doc_count":1},{"key":"400+","doc_count":0}]}}}
Kategori kutusunda yalnız "Fiction 1" ve "Historical Fiction 1" kaldı. Aggregation'lar sorgunun sonucu üzerinde çalıştığı için, filtreyi sorguya koyduğun anda kategori facet'i yalnız seçili kategorideki tek kitabı sayıyor. Kullanıcı "Classics"e geçmek istese seçeneği göremiyor; önce filtreyi kaldırması gerekiyor. Facet'li bir sayfada beklenen davranış bu değil.
post_filter tam bu iş için var: aggregation'lar hesaplandıktan sonra yalnız sonuç listesine uygulanıyor.
GET /books/_search
{
"size": 0,
"query": { "match": { "description.tr": "roman" } },
"post_filter": { "term": { "categories": "Historical Fiction" } },
"aggs": { "categories": { ... }, "prices": { ... } }
}
{"hits":{"total":{"value":1}},"aggregations":{"categories":{"buckets":[{"key":"Fiction","doc_count":9},{"key":"Classics","doc_count":8},{"key":"Historical Fiction","doc_count":1},{"key":"Science Fiction","doc_count":1}]},"prices":{"buckets":[{"key":"0-200","doc_count":5},{"key":"200-400","doc_count":3},{"key":"400+","doc_count":1}]}}}
Sonuç yine tek kitap ama kategori kutusu bütün seçenekleri gösteriyor. Şimdi ters bir sorun var: fiyat kutusu da filtreyi görmüyor ve "0-200: 5" diyor, oysa sonuçta bu aralıkta hiç kitap yok. Kullanıcı tıklarsa boş sayfa görür. Doğru kural şu: her facet, kendisi hariç bütün seçili filtrelerle sayılmalı. Bunu her facet'i bir filter aggregation'ının içine koyarak yapıyoruz:
"aggs": {
"categories": { "terms": { "field": "categories" } },
"prices": {
"filter": { "term": { "categories": "Historical Fiction" } },
"aggs": { "prices": { "range": { "field": "price", "ranges": [ ... ] } } }
}
}
{"hits":{"total":{"value":1}},"aggregations":{"categories":{"buckets":[{"key":"Fiction","doc_count":9},{"key":"Classics","doc_count":8},{"key":"Historical Fiction","doc_count":1},{"key":"Science Fiction","doc_count":1}]},"prices":{"prices":{"buckets":[{"key":"0-200","doc_count":0},{"key":"200-400","doc_count":1},{"key":"400+","doc_count":0}]}}}}
Kategori facet'i kendi filtresini görmüyor ve bütün seçenekleri sayıyor; fiyat facet'i kategori filtresini görüyor ve doğru sayıları veriyor. Birden çok filtre seçildiğinde her facet'in filter'ı "diğer bütün seçili filtreler" oluyor. Bu kalıbın adı çoğu arama arayüzünde multi-select facet.
Bookstore'un Facet'leri
Kalıbı Bookstore.Search'te yeni bir BookFacets sınıfına koyduk. Seçili filtreler facet adıyla bir sözlükte duruyor; aynı sözlük hem post_filter'ı hem her facet'in filtresini üretiyor:
public static Dictionary<string, Aggregation> Aggregations(BookSearchFilter filter)
{
Dictionary<string, Query> selected = SelectedFilters(filter);
return new Dictionary<string, Aggregation>
{
[Category] = Facet(selected, Category, new Aggregation { Terms = new TermsAggregation { Field = "categories", Size = 20 } }),
[Language] = Facet(selected, Language, new Aggregation { Terms = new TermsAggregation { Field = "language", Size = 5 } }),
[Price] = Facet(selected, Price, new Aggregation
{
Range = new RangeAggregation
{
Field = "price",
Ranges =
[
new AggregationRange { Key = "0-200", To = 200 },
new AggregationRange { Key = "200-400", From = 200, To = 400 },
new AggregationRange { Key = "400-700", From = 400, To = 700 },
new AggregationRange { Key = "700+", From = 700 }
]
}
}),
[Rating] = Facet(selected, Rating, new Aggregation
{
Range = new RangeAggregation
{
Field = "averageRating",
Ranges = [new AggregationRange { Key = "4+", From = 4 }, new AggregationRange { Key = "3+", From = 3 }]
}
})
};
}
// Bir facet kendi filtresi hariç bütün seçili filtreleri görür; böylece diğer seçenekleri sayılarını korur.
private static Aggregation Facet(Dictionary<string, Query> selected, string name, Aggregation values)
{
List<Query> others = [.. selected.Where(f => f.Key != name).Select(f => f.Value)];
return new Aggregation
{
Filter = new BoolQuery { Filter = others },
Aggregations = new Dictionary<string, Aggregation> { [Values] = values }
};
}
Fiyat filtresi artık lte yerine lt kullanıyor; maxPrice=200 seçen kullanıcı "0-200" facet'iyle aynı kümeyi görmeli ve range aggregation'da üst sınır hariç. Endpoint'te sorguya yalnız arama metni gidiyor, filtreler post_filter'a. 6. yazıda söz verdiğimiz sıralama da eklendi; her sıralamanın sonunda isbn tie-breaker olarak var ki aynı fiyatlı kitaplar sayfalar arasında yer değiştirmesin:
SearchRequest<Book> request = new()
{
From = from,
Size = size,
TrackTotalHits = new TrackHits(true),
// Sorguya yalnız metin girer; böylece her facet metnin eşleştiği her şeyi sayar.
Query = BookSearchQuery.Build(new BookSearchFilter(q), SearchTuning.Tuned),
// Seçili filtreler aggregation'lardan sonra, yalnız sonuç listesini daraltır.
PostFilter = BookFacets.PostFilter(filter),
Aggregations = BookFacets.Aggregations(filter),
Sort = BuildSort(sort),
Highlight = string.IsNullOrWhiteSpace(q) ? null : BuildHighlight(),
Suggest = string.IsNullOrWhiteSpace(q) ? null : BuildDidYouMean(q)
};
curl "http://localhost:5080/books/search?q=roman&category=Classics&maxPrice=200&pageSize=2"
{"total":5,"page":1,"pageSize":2,"items":[{"isbn":"9781000000214","title":"Bin Dokuz Yüz Seksen Dört","authors":["George Orwell"],"language":"tr","price":195.00,"score":9.158556,"titleHighlight":null,"descriptionHighlight":"Büyük Birader'in her şeyi izlediği bir totaliter devlette Winston Smith'in başkaldırısı; distopya <mark>romanı</mark>."},{"isbn":"9781000000078","title":"Yaprak Dökümü","authors":["Reşat Nuri Güntekin"],"language":"tr","price":175.00,"score":8.661255,"titleHighlight":null,"descriptionHighlight":"Ali Rıza Bey'in ailesinin değişen değerler karşısında dağılışını anlatan <mark>roman</mark>."}],"didYouMean":null,"facets":[{"name":"category","values":[{"key":"Classics","count":5},{"key":"Fiction","count":5},{"key":"Science Fiction","count":1}]},{"name":"language","values":[{"key":"tr","count":5}]},{"name":"price","values":[{"key":"0-200","count":5},{"key":"200-400","count":3},{"key":"400-700","count":1},{"key":"700+","count":0}]},{"name":"rating","values":[{"key":"3+","count":5},{"key":"4+","count":5}]}]}
curl "http://localhost:5080/books/search?category=Mystery&minRating=4&sort=price_desc&pageSize=2"
{"total":1362,"page":1,"pageSize":2,"items":[{"isbn":"9782000006350","title":"Window of the Wild Bridge","authors":["Ken Turner"],"language":"en","price":899.90,"score":null,...},{"isbn":"9782000169369","title":"A Window in House 58","authors":["Alan Parker"],"language":"en","price":899.90,"score":null,...}],"didYouMean":null,"facets":[{"name":"category","values":[{"key":"Science Fiction","count":1448},{"key":"History","count":1418},{"key":"Fiction","count":1413},{"key":"Travel","count":1407},{"key":"Fantasy","count":1363},{"key":"Mystery","count":1362},{"key":"Classics","count":26},{"key":"Historical Fiction","count":2},{"key":"Software","count":2},{"key":"Architecture","count":1},{"key":"Programming","count":1}]},{"name":"language","values":[{"key":"en","count":1362}]},{"name":"price","values":[{"key":"0-200","count":185},{"key":"200-400","count":336},{"key":"400-700","count":504},{"key":"700+","count":337}]},{"name":"rating","values":[{"key":"3+","count":2780},{"key":"4+","count":1362}]}]}
İlk istekte iki filtre seçili. Kategori facet'i fiyat filtresini görüyor (200 liranın altındaki 5 kitap), fiyat facet'i kategori filtresini görüyor ve kendi seçimine rağmen "200-400: 3" gibi diğer aralıkları gösteriyor. İkinci istekte arama metni yok ve fiyata göre sıralı; skor hesaplanmadığı için score null ve kategori facet'i, puanı 4 ve üstü olan bütün kategorilerdeki kitapları sayıyor.
Bu adımda iki hatayla karşılaştım ve ikisi de not edilmeye değer. İlki: filtre seçilmemişken bütün aramalar 503 döndü, log'da query malformed, empty clause found vardı. PostFilter'ı selected.Count == 0 ? null : new BoolQuery { ... } diye yazmıştım; ifadenin tipi BoolQuery olduğu için null bir BoolQuery, client'ın implicit dönüşümüyle boş bir Query'ye çevrildi ve "post_filter":{} olarak gitti. Açık bir if ile null döndürmek sorunu çözdü. İkincisi bir relevance hatası: 7. yazıda "bunu mu demek istediniz?" önerisini sonuç olsa da döndürüyorduk ve "roman" araması "road" önerdi, çünkü suggester yalnız başlık term'lerine bakıyor ve "roman" hiçbir başlıkta geçmiyor. Öneri artık yalnız üçten az sonuç olduğunda dönüyor.
terms Sayıları Neden Yaklaşık?
terms aggregation her shard'da kendi en sık shard_size değerini seçip koordinatör node'a gönderiyor, koordinatör bunları birleştiriyor. Bir değer bir shard'ın listesine girmediyse o shard'daki sayısı toplama hiç katılmıyor. Tek shard'lı books'ta bu sorun yok; etkisini görmek için aynı veriyi 5 shard'lı books-agg index'ine _reindex ile kopyaladım ve en sık beş başlığı istedim (sentetik katalogda aynı başlık birçok kez geçiyor):
PUT /books-agg
{ "settings": { "number_of_shards": 5, "number_of_replicas": 0 }, "mappings": { "dynamic": false, "properties": { "title": { "type": "keyword" }, "categories": { "type": "keyword" } } } }
POST /_reindex?refresh=true&filter_path=total,created
{ "source": { "index": "books", "_source": ["title", "categories"] }, "dest": { "index": "books-agg" } }
{"total":20035,"created":20035}
GET /books/_search?filter_path=aggregations
{ "size": 0, "aggs": { "titles": { "terms": { "field": "title.keyword", "size": 5, "shard_size": 5 } } } }
{"aggregations":{"titles":{"doc_count_error_upper_bound":0,"sum_other_doc_count":19897,"buckets":[{"key":"The Winter Kingdom","doc_count":31},{"key":"The Little Window","doc_count":28},{"key":"The Winter Library","doc_count":27},{"key":"The Glass Station","doc_count":26},{"key":"The Golden Forest","doc_count":26}]}}}
GET /books-agg/_search?filter_path=aggregations
{ "size": 0, "aggs": { "titles": { "terms": { "field": "title", "size": 5, "shard_size": 5, "show_term_doc_count_error": true } } } }
{"aggregations":{"titles":{"doc_count_error_upper_bound":40,"sum_other_doc_count":19986,"buckets":[{"key":"The Paper Letter","doc_count":11,"doc_count_error_upper_bound":32},{"key":"The Distant City","doc_count":10,"doc_count_error_upper_bound":32},{"key":"The Hidden Letter","doc_count":10,"doc_count_error_upper_bound":32},{"key":"The Golden Season","doc_count":9,"doc_count_error_upper_bound":32},{"key":"The Little Window","doc_count":9,"doc_count_error_upper_bound":32}]}}}
# shard_size verilmeden (varsayılan: size * 1.5 + 10 = 17)
{"aggregations":{"titles":{"doc_count_error_upper_bound":34,"sum_other_doc_count":19951,"buckets":[{"key":"The Golden Forest","doc_count":22,"doc_count_error_upper_bound":14},{"key":"The Little Window","doc_count":16,"doc_count_error_upper_bound":20},{"key":"The Secret Harbour","doc_count":16,"doc_count_error_upper_bound":20},{"key":"The Little Forest","doc_count":15,"doc_count_error_upper_bound":20},{"key":"The Lost Clock","doc_count":15,"doc_count_error_upper_bound":20}]}}}
# "shard_size": 500
{"aggregations":{"titles":{"doc_count_error_upper_bound":5,"sum_other_doc_count":19897,"buckets":[{"key":"The Winter Kingdom","doc_count":31},{"key":"The Little Window","doc_count":28},{"key":"The Winter Library","doc_count":27},{"key":"The Glass Station","doc_count":26},{"key":"The Secret Mountain","doc_count":26}]}}}
Tek shard'da gerçek liste "The Winter Kingdom 31" ile başlıyor. Beş shard'da shard_size: 5 ile listenin tamamı yanlış: gerçekte 31 kitabı olan başlık hiç yok, birinci sıradaki "The Paper Letter" 11 kitapla gösteriliyor. Varsayılan shard_size ile de durum değişmedi. 500 ile sıralama doğruya döndü (beşinci sıradaki 26'lık eşitlik dışında). doc_count_error_upper_bound, bir bucket'ın sayısının en fazla ne kadar eksik olabileceğinin üst sınırı: shard'ların listeye almadığı en büyük sayıların toplamı. 0 ise sayı kesin.
Bu örnek bilerek en kötü durum: binlerce farklı başlık neredeyse eşit sıklıkta. Facet'lerde bu sorun pratikte çıkmıyor; categories 11, language 2 farklı değer içeriyor ve size (bizde 20) değer sayısından büyük olduğu için her shard bütün değerleri gönderiyor ve sayılar kesin. Sorun, yazar ya da yayınevi gibi yüksek cardinality'li alanlarda "en çok satan 10 yazar" gibi raporlarda çıkar. Orada ya shard_size'ı büyüt ve doc_count_error_upper_bound'u kontrol et ya da bütün değerleri sayfalayarak dolaşan composite aggregation'ı kullan. İşimiz bitince books-agg'i sildik.
ES|QL ile Aynı Rapor
Elasticsearch'ün Query DSL'inin (JSON ile yazılan, şimdiye kadar kullandığımız sorgu dili) yanında, adım adım yazılan bir sorgu dili de var: ES|QL (Elasticsearch Query Language; komutları pipe karakteriyle zincirleyen, tablo döndüren sorgu dili). Aynı kategori raporu:
POST /_query?format=txt
{ "query": "FROM books | STATS books = COUNT(*) BY categories | SORT books DESC | LIMIT 5" }
books | categories
---------------+---------------
3410 |Mystery
3385 |Travel
3341 |History
3337 |Fantasy
3320 |Science Fiction
POST /_query?format=txt
{ "query": "FROM books | WHERE MATCH(description.tr, \"roman\") | STATS books = COUNT(*), avg_price = AVG(price) BY categories | SORT books DESC" }
books | avg_price | categories
---------------+-----------------+------------------
9 |267.6666666666667|Fiction
8 |261.125 |Classics
1 |195.0 |Science Fiction
1 |320.0 |Historical Fiction
Sayılar terms aggregation'ınkiyle aynı; çok değerli categories alanında ES|QL de her değer için ayrı bir grup oluşturuyor. Rapor ve analiz için ES|QL daha okunur: SQL'e alışık bir geliştirici STATS ... BY'ı hemen anlıyor ve sonucu tablo olarak alıyor. Arama sayfası için ise Query DSL'de kalıyoruz, çünkü tek istekte relevance'a göre sıralı sonuçlar, highlight, suggester ve birbirinden bağımsız filtrelenen facet'ler gerekiyor. ES|QL'i .NET'ten çağırmayı 10. yazıda göreceğiz.
Üretimde Dikkat
Facet sayısını ve bucket sayısını sınırla. Her facet her aramada hesaplanıyor; 20 facet'li bir sayfa, arama süresinin çoğunu aggregation'a harcayabilir. terms'te size'ı arayüzde gerçekten gösterdiğin kadar tut.
Aggregation'ları cache'le. Arama metni olmayan, yalnız filtre içeren istekler (kategori sayfaları gibi) her kullanıcı için aynı sonucu veriyor. size: 0'lı istekler Elasticsearch'ün shard request cache'ine de giriyor; arama sayfasının ilk açılışı gibi popüler istekleri ayrıca uygulama tarafında kısa süre cache'lemek de mümkün.
doc_count_error_upper_bound'u izle. Çok shard'lı bir index'te bir raporun sayıları kesin olmalıysa bu değerin 0 olduğunu kontrol et; değilse shard_size'ı artır ya da composite ile bütün değerleri dolaş.
Fiyat aralıklarını veriye göre seç. Sabit aralıklar katalog değiştikçe dengesizleşiyor; bizim sentetik katalogda "400-700" en kalabalık aralık. percentiles aggregation'ı ya da periyodik bir rapor, aralıkları güncel tutmaya yardım eder.
Sık Düşülen Tuzaklar
Seçili filtreyi sorguya koymak. Facet kendi seçeneklerini kaybeder; kullanıcı başka bir seçeneğe geçmek için önce filtreyi kaldırmak zorunda kalır.
post_filter'ı tek başına kullanmak. Seçili filtre hiçbir facet'e yansımaz ve kullanıcı tıkladığında boş sonuç veren sayılar görür; her facet diğer filtrelerle sarılmalı.
text alan üzerinde aggregation açmak. Fielddata'yı açmak bütün term'leri heap'e yükler ve analiz edilmiş parçaları sayar ("kürk", "mantolu" ayrı ayrı); doğru alan keyword alt alanı.
Yüksek cardinality'li alanda küçük shard_size ile terms. Çok shard'lı bir index'te yanlış ilk 10 listesi üretir ve hata sessizdir; sayılar makul göründüğü için kimse fark etmez.
Nested alanı kökten saymak. authors.name.keyword üzerinde kökte terms boş döner; önce nested, kitap alanlarına geri dönmek için reverse_nested.
Küçük Bir Deneme
★☆☆ Endpoint'e yayın yılı facet'i ekle (date_histogram, yıllık). Hangi filtre parametresiyle seçilecek ve SelectedFilters'a nasıl eklenecek?
★★☆ Kategori seçimini çoklu yap (category=Fiction&category=Classics). Aynı facet içinde seçimler "veya", farklı facet'ler arasında "ve" olmalı: terms sorgusuyla nasıl kurarsın? Kategori facet'inin sayıları değişiyor mu?
★★★ books-agg'i yeniden oluştur ve "en çok kitabı olan 10 yazar" raporunu nested terms ile çalıştır. shard_size'ı ne kadar büyütünce sonuç tek shard'lı books ile aynı oluyor? Aynı raporu composite aggregation ile yazıp bütün yazarları sayfalayarak dolaş ve sonucu karşılaştır.
Bir sonraki yazıda bu endpoint'lerin arkasındaki .NET koduna yakından bakıyoruz: Elastic.Clients.Elasticsearch 9'da fluent ve object initializer sözdizimi, sorguları test edilebilir bir builder'a toplamak, source serializer ayarları, hata ve 429 yönetimi, ES|QL'i .NET'ten çağırmak ve Testcontainers ile gerçek bir Elasticsearch'e karşı integration testi.