Mert Özen Her Satırda Daha İleri
Veritabanı Elasticsearch ile Arama — #06

Query DSL 1: match, multi_match, term, bool Query, Filter Context ve Pagination

Mert Özen 14 Kas 2026 15 dk 8 görüntülenme
Query DSL 1: match, multi_match, term, bool Query, Filter Context ve Pagination

Kullanıcının yazdığı metni text alanlara match ile, kesin değerleri keyword alanlara term ile ve tıklanan her filtreyi bool query'nin filter bölümüne koyarsın; filtre must'a girince skor şişer ve cache kaybolur. Gerçek çıktılarla multi_match'in best_fields, most_fields ve cross_fields tipleri, nested yazar araması, Bookstore'un /books/search endpoint'i, from/size'ın 10.000 sınırı, PIT ile search_after ve query_string yerine simple_query_string.

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 çalıştırılmıştır. books index'i 5. yazının sonundaki hâlinde: SQL Server'dan senkronize edilen 20.035 kitap.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için arama sayfasının arka ucunu yazmaya başlıyoruz. 5. yazıdaki deneme index'i books-load'a artık ihtiyacımız yok; Dev Tools'ta DELETE /books-load ile sil ki aramalara karışmasın (sebebini 10. yazıda göreceğiz). Önce Kibana Dev Tools'ta üç küçük sorgu:

GET /books/_search
{ "query": { "term": { "title": "İnce Memed" } } }

GET /books/_search
{ "query": { "term": { "title": "ince" } } }

GET /books/_search
{ "query": { "term": { "language": "TR" } } }

Kataloğumuzda "İnce Memed" adında bir kitap var. Bu üç sorgudan hangileri sonuç döndürür? Bir de şunu düşün: arama kutusuna suç ve ceza (rusça yazan bir kullanıcı, sorguyu query_string ile kuran bir API'den ne alır?

match ve term: Analiz Edilen ve Edilmeyen

Üç sorgunun cevabı sırasıyla 0, 2 ve 0 sonuç:

GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "term": { "title": "İnce Memed" } } }
{"hits":{"total":{"value":0,"relation":"eq"}}}

GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "term": { "title": "ince" } } }
{"hits":{"total":{"value":2,"relation":"eq"},"hits":[{"_score":10.77075,"_source":{"title":"İnce Memed"}},{"_score":9.605565,"_source":{"title":"İnce Memed 2"}}]}}

GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "term": { "title.keyword": "İnce Memed" } } }
{"hits":{"total":{"value":1,"relation":"eq"},"hits":[{"_score":8.99099,"_source":{"title":"İnce Memed"}}]}}

term sorgusu verdiğin değeri analiz etmez; index'te birebir aynı term'i arar. title alanı standard analyzer'ıyla index'lendiği için index'te "İnce Memed" diye bir term yok, "ince" ve "memed" diye iki term var. "ince" tam olarak index'teki term olduğu için ikinci sorgu buluyor; ama bu bir tesadüf, kullanıcı "İnce" yazsaydı yine boş dönecekti. title.keyword ise değeri olduğu gibi saklıyor ve tam başlık eşleşiyor. Aynı mantık language için de geçerli: keyword alanında "TR" ile "tr" farklı değerler, sorgu boş dönüyor.

match sorgusu ise metni önce alanın search analyzer'ından geçiriyor, çıkan term'lerle arıyor:

GET /books/_search?filter_path=hits.total,hits.hits._score,hits.hits._source.title
{ "query": { "match": { "title": "İnce Memed" } } }
{"hits":{"total":{"value":2,"relation":"eq"},"hits":[{"_score":21.5415,"_source":{"title":"İnce Memed"}},{"_score":19.21113,"_source":{"title":"İnce Memed 2"}}]}}

Kural basit: kullanıcının yazdığı metin text alanlara match ailesiyle, sistemin bildiği kesin değerler, yani dil kodu, kategori, ISBN (kitabın uluslararası standart numarası) ya da sipariş durumu, keyword alanlara term/terms ile gider. term'ü text alanda kullanmak, index'in içindeki analiz edilmiş biçimi tahmin etmeye çalışmak demek; 3. yazıda mapping'i iki alan olarak kurmamızın sebebi buydu.

Kelimelerin Sırası ve Sayısı

match varsayılan olarak or ile çalışıyor: term'lerden biri geçen her document sonuç. Sıra da önemsiz. Sıra önemliyse match_phrase var; slop ise kelimeler arasında kaç pozisyon kaymaya izin verildiğini söylüyor:

GET /books/_search
{ "query": { "match": { "title.folded": "mantolu kurk" } } }
{"hits":{"total":{"value":1},"hits":[{"_score":19.785675,"_source":{"title":"Kürk Mantolu Madonna"}}]}}

GET /books/_search
{ "query": { "match_phrase": { "title.folded": "mantolu kurk" } } }
{"hits":{"total":{"value":0}}}

GET /books/_search
{ "query": { "match_phrase": { "title.folded": { "query": "mantolu kurk", "slop": 2 } } } }
{"hits":{"total":{"value":1},"hits":[{"_score":10.090446,"_source":{"title":"Kürk Mantolu Madonna"}}]}}

or ile and arasında bir ayar daha var: minimum_should_match. Üç kelimelik bir aramada "en az ikisi geçsin" demek, uzun ve gevşek sorgularda alakasız sonuçları budamanın en kolay yolu:

GET /books/_search
{ "size": 3, "query": { "match": { "description.tr": { "query": "istanbul aşk savaş" } } } }
{"hits":{"total":{"value":9},"hits":[{"_score":15.448057,"_source":{"title":"Huzur"}},{"_score":8.433546,"_source":{"title":"Savaş ve Barış"}},{"_score":8.376261,"_source":{"title":"Kürk Mantolu Madonna"}}]}}

GET /books/_search
{ "size": 3, "query": { "match": { "description.tr": { "query": "istanbul aşk savaş", "minimum_should_match": 2 } } } }
{"hits":{"total":{"value":1},"hits":[{"_score":15.448057,"_source":{"title":"Huzur"}}]}}

Dokuz sonuçtan biri kaldı; Huzur'un açıklamasında üç kelime de geçiyor. Değer sayı olarak (2), yüzde olarak ("75%") ya da kelime sayısına göre değişen bir ifadeyle verilebiliyor.

multi_match Tipleri: best_fields, most_fields, cross_fields

4. yazıdaki search komutu sekiz alanı tek bir multi_match ile arıyordu ve tipini vermemiştik; varsayılan best_fields. Tiplerin farkını görmek için en kısa yol _validate/query?rewrite=true: Elasticsearch sorguyu Lucene'in çalıştıracağı hâle çevirip gösteriyor. Kullanıcı "dune arrakis" yazıyor ve iki kelimenin de geçmesini istiyoruz:

GET /books/_validate/query?rewrite=true
{
  "query": {
    "multi_match": {
      "query": "dune arrakis", "type": "best_fields", "operator": "and",
      "fields": ["title.folded", "description.folded"]
    }
  }
}
+((+title.folded:dune +title.folded:arrakis) | (+description.folded:dune +description.folded:arrakis)) #FieldExistsQuery [field=_primary_term]

"type": "cross_fields"
+(title.folded:dune | description.folded:dune) +(title.folded:arrakis | description.folded:arrakis) #FieldExistsQuery [field=_primary_term]

best_fields'ta and her alanın içinde uygulanıyor: "dune" ve "arrakis" aynı alanda geçmeli. Türkçe Dune'un başlığında "dune", açıklamasında "arrakis" var; hiçbir alan ikisini birden içermiyor ve sonuç 0. most_fields da alan başına aynı kuralı uyguluyor, sonuç yine 0. cross_fields ise alanları tek bir büyük alan gibi düşünüyor: her kelime en az bir alanda geçsin. Sonuç Türkçe Dune, skoru 20.04.

Tiplerin skoru nasıl birleştirdiği de farklı. or ile (varsayılan) aynı sorguyu çalıştırınca iki Dune da geliyor; üç çalıştırmanın ve _explain cevabının yalnız ilgili kısımlarını alt alta yazdım:

"type": "best_fields"  → Dune (tr) 11.964923, Dune (en) 11.964923
"type": "most_fields"  → Dune (tr) 20.039944, Dune (en) 11.964923
"type": "best_fields", "tie_breaker": 0.3 → Dune (tr) 14.387429, Dune (en) 11.964923

GET /books/_explain/9781000000191
"max plus 0.3 times others of:" 11.964923 (title.folded), 8.075021 (description.folded)

best_fields en iyi eşleşen alanın skorunu alıyor; açıklamada "arrakis" geçmesi Türkçe Dune'a hiçbir şey kazandırmıyor ve iki kitap eşit. most_fields bütün alanların skorlarını topluyor. tie_breaker: 0.3 ikisinin arası: en iyi alan artı diğerlerinin 0,3 katı (11,96 + 0,3 × 8,08 = 14,39). _explain bu hesabı document bazında gösteriyor; "bu kitap neden üstte?" sorusunun cevabı her zaman burada.

Kural olarak: kavram tek bir alanda yaşıyorsa (başlık ya da açıklama, hangisi daha iyi eşleşirse) best_fields ve küçük bir tie_breaker; aynı metni farklı analyzer'larla index'lediğin alt alanlarda (title, title.tr, title.folded) most_fields; bilgi alanlara bölünmüşse (ad ve soyad, başlık ve açıklama) cross_fields.

cross_fields'ın bir şartı var: alanları analyzer'larına göre grupluyor ve yalnız aynı analyzer'ı kullanan alanları tek alan gibi ele alıyor. Aynı sorguyu title (standard) ve description (search-time'da search_synonyms) üzerinde çalıştırdığımda rewrite, best_fields'inkiyle birebir aynı çıktı ve sonuç yine 0. Bu yüzden örnekte iki alan da tr_folded kullanan folded alt alanlarını seçtim.

Bir de yazar araması var. "sabahattin ali" için multi_match'e authors.name'i eklemek işe yaramıyor: sonuç 0. 3. yazıda yazarları nested yaptık; her yazar ayrı bir gizli Lucene document'ı ve kökteki bir sorgu onları görmüyor. Başlık ile yazarı birlikte aramak için iki sorguyu bool'un should'unda birleştiriyoruz:

GET /books/_search
{
  "size": 5,
  "query": {
    "bool": {
      "should": [
        { "multi_match": { "query": "sabahattin ali kürk", "fields": ["title", "title.folded"] } },
        { "nested": { "path": "authors", "score_mode": "max",
                      "query": { "match": { "authors.name": "sabahattin ali kürk" } } } }
      ]
    }
  }
}
{"hits":{"total":{"value":3},"hits":[{"_score":25.683447,"_source":{"title":"Kürk Mantolu Madonna"}},{"_score":15.790609,"_source":{"title":"Kuyucaklı Yusuf"}},{"_score":15.790609,"_source":{"title":"İçimizdeki Şeytan"}}]}}

"minimum_should_match": 2
{"hits":{"total":{"value":1},"hits":[{"_score":25.683447,"_source":{"title":"Kürk Mantolu Madonna"}}]}}

Hem başlıkta hem yazarda eşleşen Kürk Mantolu Madonna iki skoru toplayıp öne geçiyor; minimum_should_match: 2 ile yalnız o kalıyor. Yazar adını kök seviyede de aranabilir yapmanın yolu mapping'e copy_to ile bir alan ya da include_in_root eklemek; ikisi de yeniden index'leme istediği için 11. yazıdaki alias'lı reindex'e bırakıyoruz.

bool Query: must, should, filter, must_not

bool dört bölümden oluşuyor: must (eşleşmeli ve skora katkı verir), should (eşleşirse skor artar; must ya da filter yoksa en az biri eşleşmeli), filter (eşleşmeli ama skora katkı vermez) ve must_not (eşleşmemeli, skora katkı vermez). İlk ikisi query context'te, son ikisi filter context'te çalışıyor. Farkı aynı sorguyu iki şekilde yazarak görelim: "roman" kelimesi geçen, Türkçe ve 250 liradan ucuz kitaplar.

GET /books/_search
{
  "size": 4,
  "query": {
    "bool": {
      "must": [ { "match": { "description.tr": "roman" } } ],
      "filter": [
        { "term": { "language": "tr" } },
        { "range": { "price": { "lte": 250 } } }
      ]
    }
  }
}
{"hits":{"total":{"value":5},"hits":[{"_score":6.9856987,"_source":{"title":"Yaprak Dökümü","price":175.0}},{"_score":6.9856987,"_source":{"title":"Yaban","price":155.0}},{"_score":6.9856987,"_source":{"title":"Bin Dokuz Yüz Seksen Dört","price":195.0}},{"_score":6.713835,"_source":{"title":"İçimizdeki Şeytan","price":190.0}}]}}

"must": [ match, term, range ]
{"hits":{"total":{"value":5},"hits":[{"_score":14.09749,"_source":{"title":"Yaprak Dökümü","price":175.0}},{"_score":14.09749,"_source":{"title":"Yaban","price":155.0}},{"_score":14.09749,"_source":{"title":"Bin Dokuz Yüz Seksen Dört","price":195.0}},{"_score":13.825626,"_source":{"title":"İçimizdeki Şeytan","price":190.0}}]}}

Sonuç kümesi aynı, sıralama da bu örnekte aynı; ama skor 6,99'dan 14,10'a çıktı. Eklenen yaklaşık 7 puanın hiçbiri kullanıcının yazdığıyla ilgili değil: 20.035 kitabın yalnız 21'i Türkçe olduğu için language: tr term'i yüksek bir IDF (inverse document frequency; nadir term'e daha çok puan veren çarpan) alıyor, range de sabit 1 ekliyor. Bu sabit, skoru başka sinyallerle birleştirdiğin anda (7. yazıdaki function_score ve boost'lar) sıralamayı bozar. İkinci fark performans: filter context'teki sorgular skor hesaplamıyor ve Elasticsearch sık kullanılan filtrelerin sonucunu node seviyesindeki query cache'te bir bitset olarak tutabiliyor. Yalnız filtreden oluşan bir sorguda bütün skorlar 0:

GET /books/_search
{ "size": 2, "query": { "bool": { "filter": [ { "term": { "language": "tr" } }, { "range": { "price": { "lte": 250 } } } ] } } }
{"hits":{"total":{"value":11},"hits":[{"_score":0.0,"_source":{"title":"Kuyucaklı Yusuf","price":210.0}},{"_score":0.0,"_source":{"title":"İçimizdeki Şeytan","price":190.0}}]}}

Pratik kural: kullanıcının yazdığı metin must/should'a, kullanıcının tıkladığı her şey (kategori, dil, fiyat aralığı, stokta olanlar) filter'a. exists (alan dolu mu) ve range da çoğunlukla filtre.

Bookstore'un Arama Endpoint'i

Bu kararları Bookstore.Api'deki yeni GET /books/search endpoint'ine taşıyoruz. Sorgu, BookSearchEndpoints.BuildQuery'de object initializer sözdizimiyle kuruluyor; fluent sözdizimiyle farkı 10. yazının konusu:

public static Query BuildQuery(string? text, string? category, string? language, decimal? minPrice, decimal? maxPrice)
{
    // Query context: skoru kullanıcının yazdığı belirler.
    Query textQuery = string.IsNullOrWhiteSpace(text)
        ? new MatchAllQuery()
        : new BoolQuery
        {
            Should =
            [
                new MultiMatchQuery { Query = text, Fields = Fields.FromStrings(TextFields) },
                // Yazarlar nested (3. yazı): kökteki multi_match onları göremez.
                new NestedQuery
                {
                    Path = "authors",
                    ScoreMode = ChildScoreMode.Max,
                    Query = new MatchQuery { Field = "authors.name", Query = text }
                }
            ],
            MinimumShouldMatch = 1
        };

    // Filter context: evet/hayır koşulları, skor yok, cache'lenebilir.
    List<Query> filters = [];
    if (!string.IsNullOrWhiteSpace(category))
    {
        filters.Add(new TermQuery { Field = "categories", Value = category });
    }
    if (!string.IsNullOrWhiteSpace(language))
    {
        filters.Add(new TermQuery { Field = "language", Value = language });
    }
    if (minPrice is not null || maxPrice is not null)
    {
        filters.Add(new NumberRangeQuery
        {
            Field = "price",
            Gte = minPrice is null ? null : (double)minPrice.Value,
            Lte = maxPrice is null ? null : (double)maxPrice.Value
        });
    }

    return new BoolQuery { Must = [textQuery], Filter = filters };
}

Endpoint sayfa parametrelerini doğruluyor, sorguyu çalıştırıyor ve sonucu API'nin döndürdüğü küçük bir record'a çeviriyor:

int from = (currentPage - 1) * size;
if (from + size > MaxResultWindow)
{
    return Results.Problem("Only the first 10,000 results can be paged. Narrow the search with filters.", statusCode: StatusCodes.Status400BadRequest);
}

SearchResponse<Book> response = await client.SearchAsync<Book>(s => s
    .From(from)
    .Size(size)
    .TrackTotalHits(new TrackHits(true))
    .Query(BuildQuery(q, category, language, minPrice, maxPrice)), cancellationToken);
dotnet run --project src/Bookstore.Api

curl "http://localhost:5080/books/search?q=kurk%20sabahattin&pageSize=2"
{"total":3,"page":1,"pageSize":2,"items":[{"isbn":"9781000000030","title":"Kürk Mantolu Madonna","authors":["Sabahattin Ali"],"language":"tr","price":199.00,"score":27.68098},{"isbn":"9781000000047","title":"Kuyucaklı Yusuf","authors":["Sabahattin Ali"],"language":"tr","price":210.00,"score":7.8953047}]}

curl "http://localhost:5080/books/search?q=roman&language=tr&maxPrice=250&pageSize=3"
{"total":5,"page":1,"pageSize":3,"items":[{"isbn":"9781000000078","title":"Yaprak Dökümü","authors":["Reşat Nuri Güntekin"],"language":"tr","price":175.00,"score":7.4347095},{"isbn":"9781000000139","title":"Yaban","authors":["Yakup Kadri Karaosmanoğlu"],"language":"tr","price":155.00,"score":7.1454363},{"isbn":"9781000000214","title":"Bin Dokuz Yüz Seksen Dört","authors":["George Orwell"],"language":"tr","price":195.00,"score":6.9856987}]}

curl "http://localhost:5080/books/search?category=Mystery&minPrice=800&pageSize=2"
{"total":395,"page":1,"pageSize":2,"items":[{"isbn":"9782000000075","title":"A Bridge in Road 36","authors":["Ken Walker"],"language":"en","price":808.90,"score":1},{"isbn":"9782000000532","title":"The Quiet Station","authors":["Alan Hayes"],"language":"en","price":826.90,"score":1}]}

Türkçe karaktersiz "kurk" başlığın folded alt alanında, "sabahattin" nested yazar sorgusunda eşleşti ve iki skor toplandı. Arama metni olmayan üçüncü istekte match_all her kitaba 1 veriyor; sıralama o durumda anlamsız ve bir sıralama alanı (fiyat, yayın tarihi) vermek gerekiyor. Sıralama ve facet'ler 9. yazıda bu endpoint'e eklenecek.

Pagination: from/size, 10.000 Sınırı, search_after ve PIT

from/size sayfalama ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" yazısındaki offset pagination'ın aynısı ve aynı maliyete sahip: 500. sayfayı vermek için her shard ilk 10.000 sonucu sıralayıp koordinatör node'a göndermek zorunda. Elasticsearch bu yüzden from + size'ı varsayılan olarak 10.000 ile sınırlıyor:

GET /books/_search
{ "from": 9990, "size": 20, "query": { "match_all": {} } }
{"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"Result window is too large, from + size must be less than or equal to: [10000] but was [10010]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."}], ...},"status":400}

Sınırı index.max_result_window ile büyütmek mümkün ama çözüm değil; bellek ve CPU maliyeti sayfa numarasıyla birlikte büyüyor. Endpoint'imiz aynı kontrolü Elasticsearch'e gitmeden yapıyor ve 501. sayfayı (20'lik) 400 ile reddediyor. Kullanıcı arayüzü için bu sınır sorun değil: kimse 500 sayfa ilerlemiyor, filtreyle daraltıyor.

Toplam sayı da bir sınırla geliyor. track_total_hits varsayılan olarak 10.000'e kadar kesin sayıyor, sonrasında sayımı bırakıyor:

GET /books/_search?filter_path=hits.total
{ "size": 0, "query": { "match_all": {} } }
{"hits":{"total":{"value":10000,"relation":"gte"}}}

GET /books/_search?filter_path=hits.total
{ "size": 0, "track_total_hits": true, "query": { "match_all": {} } }
{"hits":{"total":{"value":20035,"relation":"eq"}}}

relation: gte "en az bu kadar" demek. "10.000+ sonuç" yazan bir arayüz için varsayılan yeterli ve daha hızlı; endpoint'te kesin sayı istediğimiz için TrackHits(true) verdik. Büyük index'lerde bunu bir maliyet olarak ölçmek gerekiyor.

Bütün index'i dolaşman gerekiyorsa (dışa aktarma, bir başka sisteme toplu yükleme, toplu e-posta listesi) doğru araç search_after ile point in time (PIT; index'in belirli bir andaki hâlini sabitleyen, süreli bir okuma görüntüsü). PIT açıkken gelen yazmalar sayfaları kaydırmıyor; search_after ise "bir önceki sayfanın son sıralama değerinden sonrasını ver" diyor, böylece her sayfa aynı maliyette. Elastic'in dokümantasyonu derin sayfalama için artık scroll API'yi değil bu ikiliyi öneriyor (hata mesajı hâlâ scroll'dan söz etse de). Lab konsoluna bir export komutu ekledik:

OpenPointInTimeResponse pit = await client.OpenPointInTimeAsync(SearchClientFactory.BooksIndex, p => p.KeepAlive("1m"));
string pitId = pit.Id;
ICollection<FieldValue>? after = null;
try
{
    while (true)
    {
        // PIT ile index adı verilmez: PIT index'i ve o anki hâlini zaten sabitliyor.
        SearchResponse<Book> page = await client.SearchAsync<Book>(s =>
        {
            s.Size(pageSize)
             .Pit(p => p.Id(pitId).KeepAlive("1m"))
             .Sort(so => so.Field("isbn", f => f.Order(SortOrder.Asc)))
             .TrackTotalHits(new TrackHits(false));
            if (after is not null)
            {
                s.SearchAfter(after);
            }
        });
        if (page.Hits.Count == 0)
        {
            break;
        }

        pages++;
        books += page.Hits.Count;
        Hit<Book> last = page.Hits.Last();
        after = last.Sort!.ToList();
        lastIsbn = last.Source!.Isbn;
        pitId = page.PitId ?? pitId;
    }
}
finally
{
    await client.ClosePointInTimeAsync(c => c.Id(pitId));
}
dotnet run --project src/Bookstore.SearchLab -- export
Exported 20,035 books in 21 pages of 1,000, last ISBN 9782000199991, 664 ms

Üç ayrıntı önemli. Sıralama benzersiz olmalı; isbn her kitapta farklı. Benzersiz bir alanın yoksa PIT ile gelen _shard_doc sıralaması tie-breaker olarak kullanılabiliyor. keep_alive bütün işlem için değil, bir sonraki isteğe kadar geçen süre için; her istekte uzatılıyor. PIT bir kaynak tutuyor (eski segment'lerin silinmesini engelliyor), bu yüzden finally'de kapatılıyor. PIT açmak için index üzerinde read yetkisi yetiyor; cluster yetkisi olmayan uygulama API key'imizle sorun çıkmadı.

Kullanıcıya dönük bir "daha fazla yükle" listesi için PIT'e gerek yok: search_after'ı son sonucun sıralama değerleriyle (skor ve isbn) tek başına kullanmak yetiyor; araya giren bir refresh sırayı biraz kaydırabilir ama bu bir arama sayfası için kabul edilebilir. Çözüm Desenleri serisinin "Pagination Stratejileri Derinlemesine: Offset, Keyset ve Opaque Cursor Token" yazısı bu cursor'ı istemciye opak bir token olarak vermeyi anlatıyor.

Kullanıcı Girdisi: query_string Yerine simple_query_string

query_string güçlü: AND/OR, parantez, alan adı, wildcard, regex, range. Kibana'nın arama çubuğu gibi uzman kullanıcıya dönük ekranlar için yazılmış. Arama kutusundaki metni doğrudan ona vermek ise üç sorun çıkarıyor:

GET /books/_search
{ "query": { "query_string": { "query": "suç ve ceza (rusça", "fields": ["title", "description"] } } }
{"error":{"root_cause":[{"type":"query_shard_exception","reason":"Failed to parse query [suç ve ceza (rusça]", ...}], ...},"status":400}

GET /books/_search
{ "query": { "query_string": { "query": "stockQuantity:[0 TO 10]", "fields": ["title", "description"] } } }
{"hits":{"total":{"value":1060},"hits":[{"_source":{"title":"Yaban"}},{"_source":{"title":"Domain-Driven Design"}},{"_source":{"title":"Harbour of the Last Island"}}]}}

GET /books/_search
{ "size": 1, "query": { "query_string": { "query": "*a*", "fields": ["title", "description"] } } }
{"hits":{"total":{"value":10000},"hits":[{"_source":{"title":"İnce Memed"}}]}}

Kapanmayan bir parantez 400 dönüyor ve kullanıcı "arama çalışmıyor" görüyor. İkinci sorguda fields'a rağmen kullanıcı alan adı yazarak stoğu 10'un altındaki 1.060 kitabı listeletti; göstermek istemediğin bir alanı sorgulatmak bilgi sızdırmak demek. Üçüncüsünde baştaki wildcard bütün term sözlüğünü dolaşan pahalı bir sorgu üretiyor. simple_query_string aynı girdide hata vermiyor, geçersiz kısımları yok sayıyor ve alan adı sözdizimini tanımıyor:

GET /books/_search
{ "size": 3, "query": { "simple_query_string": { "query": "suç ve ceza (rusça", "fields": ["title", "description"] } } }
{"hits":{"total":{"value":14},"hits":[{"_source":{"title":"Suç ve Ceza"}},{"_source":{"title":"Savaş ve Barış"}},{"_source":{"title":"İnce Memed 2"}}]}}

Bir tuzağı var: "-" NOT anlamına geliyor ve default_operator: or ile "hobbit -çeviri" araması "hobbit geçenler ya da çeviri geçmeyenler" oluyor:

{ "simple_query_string": { "query": "hobbit -çeviri", "fields": ["title", "description"] } }
{"hits":{"total":{"value":10000},"hits":[{"_source":{"title":"Hobbit"}},{"_source":{"title":"The Hobbit"}},{"_source":{"title":"İnce Memed"}}]}}

"default_operator": "and"
{"hits":{"total":{"value":1},"hits":[{"_source":{"title":"The Hobbit"}}]}}

"flags": "PHRASE|AND|OR|PRECEDENCE"
{"hits":{"total":{"value":6},"hits":[{"_source":{"title":"Hobbit"}},{"_source":{"title":"Savaş ve Barış"}},{"_source":{"title":"The Hobbit"}}]}}

Operatör sözdizimi kullanıcılarına bir şey ifade etmiyorsa (çoğu kitapçı müşterisi için öyle) en güvenlisi bizim endpoint'teki gibi düz multi_match. Tırnakla ifade araması gibi birkaç operatör sunmak istiyorsan simple_query_string'i flags ile sınırla ve default_operator'ı bilinçli seç. Son örnekte NOT kapalı olduğu için "-çeviri" düz bir kelime oldu, açıklama alanındaki eş anlamlılar (çeviri, translated, translation) devreye girdi ve çeviri kitaplar da geldi.

Üretimde Dikkat

Sorguyu loglarken metni değil şekli logla. Kullanıcının arama metni kişisel veri içerebilir; hangi filtrelerin kullanıldığı, kaç sonuç döndüğü ve süre (took) çoğu analiz için yeterli. Sıfır sonuçlu aramaların sayısı ise ayrıca izlenmeli; 7. yazıda "bunu mu demek istediniz?" önerisini bu listeden besleyeceğiz.

track_total_hits: true'nun bedelini ölç. Kesin sayı, eşleşen her document'ın sayılması demek; milyonlarca document'ta sorgu süresini belirgin biçimde uzatabiliyor. Arayüzde "10.000+ sonuç" yeterliyse varsayılanı kullan ya da sınırı bir sayı olarak ver.

Sayfa boyutunu ve sayfa sayısını sunucuda sınırla. pageSize=10000 isteyen bir istemci, tek istekte 10.000 document'ın _source'unu çektirebilir. Endpoint'te 50 üst sınır ve 10.000 pencere kontrolü bu yüzden var.

Filtreleri filter'da tut ki cache'lensin. Node query cache, sık tekrarlanan filtre sonuçlarını segment başına bitset olarak saklıyor; language: tr gibi her aramada tekrar eden bir filtre, ikinci seferden itibaren neredeyse bedava.

Sık Düşülen Tuzaklar

text alanda term kullanmak. Sonuç ya boş ya da tesadüfen doğru; analiz edilmiş term'i tahmin etmeye çalışma, match kullan ya da keyword alt alanına git.

Filtreyi must'a koymak. Sonuç kümesi aynı kalır ama skor kullanıcının metniyle ilgisiz bir sabitle şişer ve cache'lenmez.

cross_fields'ı farklı analyzer'lı alanlarda kullanıp çalıştığını sanmak. Elasticsearch alanları analyzer'a göre gruplar; gruplar arasında davranış best_fields'tir. _validate/query?rewrite=true ile kontrol et.

Nested alanı kök sorguda aramak. authors.name'i multi_match'e eklemek hata vermez, sessizce hiçbir şey bulmaz.

Derin sayfalama için max_result_window'u büyütmek. Sınırı değil yöntemi değiştir: arayüzde filtre, toplu işte PIT ve search_after.

Kullanıcı girdisini query_string'e vermek. Sözdizimi hataları 400'e, alan adları bilgi sızıntısına, baştaki wildcard'lar pahalı sorgulara dönüşür.

Küçük Bir Deneme

★☆☆ /books/search endpoint'ine "yalnız stokta olanlar" filtresi ekle (inStock=true → stockQuantity için range gt: 0). Filtre filter'da mı? Skorlar değişiyor mu?

★★☆ search komutundaki sekiz alanlı multi_match'i best_fields, most_fields ve tie_breaker: 0.3 ile çalıştır. "ince memed" ve "novel" aramalarında ilk beş sonuç ve skorlar nasıl değişiyor? Aynı metnin farklı analyzer'larla index'lendiği alt alanlarda hangisi daha mantıklı?

★★★ Endpoint'e after parametresiyle "daha fazla yükle" sayfalaması ekle: yanıtta son sonucun sıralama değerlerini (_score, isbn) Base64 bir token olarak döndür, bir sonraki istekte search_after'a ver. Arada bir kitabın fiyatını değiştirip refresh olunca sayfalar kayıyor mu?

Bir sonraki yazıda bu endpoint'in skorlarını ciddiye alıyoruz: tahmini ^3/^2 ağırlıklarını _rank_eval ile ölçerek ayarlamak, puan ve yenilik sinyalini function_score ile eklemek, yazım hatalarına fuzziness, eşleşen kelimeyi işaretlemek için highlight ve sıfır sonuçlu aramalara "bunu mu demek istediniz?" önerisi.