Mert Özen Her Satırda Daha İleri
Veritabanı Redis — #11

Redis JSON ve Query Engine: JSON Document'lar, FT.CREATE, FT.SEARCH, FT.AGGREGATE ve NRedisStack

Mert Özen 14 Kas 2026 13 dk 10 görüntülenme
Redis JSON ve Query Engine: JSON Document'lar, FT.CREATE, FT.SEARCH, FT.AGGREGATE ve NRedisStack

Redis 8'de JSON document'ları JSONPath ile kısmen güncelleyebilir ve FT.CREATE ile kurduğun index üzerinden FT.SEARCH ile filtreleyip sıralayabilirsin; yazma komutu döndüğünde arama yeni değeri görür. Gerçek çıktılarla NRedisStack ile katalog araması, tag escape'i, çok değerli alanlarda GROUPBY'ın ilk değeri sayması, Türkçe stemming ve büyük İ sınırları, 100.000 kitapta index maliyeti ve valkey-search farkları.

Sürüm notu: Bu yazıdaki örnekler Redis 8.10.3 (redis:8), Valkey 9.1.3 (valkey/valkey-bundle:9), .NET 10, StackExchange.Redis 3.4.35 ve NRedisStack 1.8.0 ile çalıştırılmıştır.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) katalog araması için her kitabı Redis'te bir JSON document olarak tuttuğumuzu düşün. Açıklamalar Türkçe:

JSON.SET bookstore:catalog:9780141439518 $ '{"isbn":"9780141439518","title":"Pride and Prejudice","author":"Jane Austen","year":1813,"priceKurus":15990,"stock":8,"categories":["romance","classic"],"description":"Bennet ailesinin kızları, evlilik ve önyargılar üzerine bir İngiliz klasiği."}'

Middlemarch'ın açıklamasında ise "Bir İngiliz taşra kasabasında evlilikler, hırslar ve reformlar." yazıyor. Aynı sekiz kitap üzerinde iki index kuruyoruz: idx:catalog LANGUAGE turkish ile, idx:catalog-en dil belirtmeden. Sonra açıklamada "evlilikler", "ingiliz" ve "İngiliz" arıyoruz. Hangi sorgu kaç kitap bulur?

127.0.0.1:6379> FT.SEARCH idx:catalog "@description:evlilikler" RETURN 1 title
1) (integer) 2
2) "bookstore:catalog:9780141439549"
3) 1) "title"
   2) "Middlemarch"
4) "bookstore:catalog:9780141439518"
5) 1) "title"
   2) "Pride and Prejudice"
127.0.0.1:6379> FT.SEARCH idx:catalog-en "@description:evlilikler" RETURN 1 title
1) (integer) 1
2) "bookstore:catalog:9780141439549"
3) 1) "title"
   2) "Middlemarch"
127.0.0.1:6379> FT.SEARCH idx:catalog "@description:ingiliz" RETURN 1 title
1) (integer) 0
127.0.0.1:6379> FT.SEARCH idx:catalog "@description:İngiliz" RETURN 1 title
1) (integer) 2
2) "bookstore:catalog:9780141439549"
3) 1) "title"
   2) "Middlemarch"
4) "bookstore:catalog:9780141439518"
5) 1) "title"
   2) "Pride and Prejudice"

Türkçe stemming (kelimeyi köküne indirme) açık olan index "evlilikler" sorgusuyla "evlilik" geçen kitabı da buldu; varsayılan İngilizce index yalnız kelimenin aynısını buldu. Ama küçük harfle yazılan "ingiliz" iki index'te de hiçbir şey bulmadı, çünkü "İngiliz" büyük noktalı İ ile yazılmış ve küçültüldüğünde "ingiliz" olmuyor. Bu yazıda Redis'in JSON tipini ve Query Engine'ini (eski adıyla RediSearch) anlatıyoruz: JSON document'lar ve kısmi güncelleme, FT.CREATE ile index, FT.SEARCH ile filtre, sıralama ve sayfalama, FT.AGGREGATE, .NET'te NRedisStack, Türkçe aramanın sınırları, Valkey tarafı ve Redis'in ne zaman doğru arama motoru olmadığı.

JSON Tipi: Document Saklamak ve Kısmi Güncelleme

Redis 8 ile JSON tipi ve Query Engine ayrı bir modül kurmadan geliyor; redis:8 image'ında MODULE LIST ikisini de gösteriyor. 04'te sepeti hash olarak tutmuştuk: hash düz bir field-value listesi, iç içe yapı ve dizi tutamaz. JSON tipi ise bir document'ı ağaç olarak saklar ve JSONPath (JSON içindeki bir değeri adresleyen yol ifadesi) ile parçalarına tek tek erişir:

docker compose -f lab/compose.yaml exec -T redis redis-cli < lab/scripts/11-json.txt
127.0.0.1:6379> JSON.SET bookstore:catalog:9780441013593 $ '{"isbn":"9780441013593","title":"Dune","author":"Frank Herbert","year":1965,"price":299.90,"stock":12,"categories":["science-fiction","classic"]}'
OK
127.0.0.1:6379> JSON.GET bookstore:catalog:9780441013593 $.title $.price
"{\"$.title\":[\"Dune\"],\"$.price\":[299.9]}"
127.0.0.1:6379> JSON.NUMINCRBY bookstore:catalog:9780441013593 $.stock -2
"[10]"
127.0.0.1:6379> JSON.SET bookstore:catalog:9780441013593 $.price 279.90
OK
127.0.0.1:6379> JSON.ARRAPPEND bookstore:catalog:9780441013593 $.categories '"bestseller"'
1) (integer) 3
127.0.0.1:6379> JSON.GET bookstore:catalog:9780441013593 '$.categories.length()'
"[3]"
127.0.0.1:6379> JSON.GET bookstore:catalog:9780441013593 '$.price * 100'
"[27989.999999999996]"
127.0.0.1:6379> MEMORY USAGE bookstore:catalog:9780441013593
(integer) 368

Stok düşürmek, fiyat değiştirmek ve kategori eklemek document'ın tamamını okuyup yazmadan, sunucuda tek komutla oldu. JSON'u string olarak tutsaydık her değişiklikte document'ı okuyup C#'ta değiştirip geri yazmamız gerekirdi; 03'teki GET + SET yarışı burada da geçerli olurdu. length() ve $.price * 100 gibi ifadeler Redis 8.10'un JSONPath genişletmeleri; sürüm notları fonksiyonlar, aritmetik operator'ler ve in/nin gibi üyelik testleri listeliyor.

Son JSON.GET'e bak: 279,90 × 100 = 27.989,999999999996. JSON'daki sayılar double (kayan noktalı sayı) olarak saklanıyor ve 279,90 ikili sistemde tam ifade edilemiyor. Para tutarını JSON'da kesirli sayı olarak tutma; 09'daki fiyat hash'inde yaptığımız gibi kuruş cinsinden tam sayı (priceKurus) kullan. Katalog document'larının geri kalanı bu yüzden priceKurus alanıyla yazılmış (lab/scripts/11-catalog.txt).

Index Kurmak: FT.CREATE ve Alan Tipleri

JSON document'ları saklamak arama yapmak için yetmez: "fiyatı 200 TL'nin altındaki klasikler" sorusunu sormak için bir index gerekir. Query Engine'de index, belirli bir prefix ile başlayan key'leri izleyen bir yapı:

FT.CREATE idx:catalog ON JSON PREFIX 1 bookstore:catalog: LANGUAGE turkish SCHEMA $.title AS title TEXT WEIGHT 2 $.author AS author TEXT $.description AS description TEXT $.categories[*] AS category TAG $.priceKurus AS price NUMERIC SORTABLE $.year AS year NUMERIC SORTABLE $.stock AS stock NUMERIC

Her alan bir JSONPath ve bir takma adla tanımlanıyor; sorgularda takma ad kullanılıyor. TEXT kelimelere bölünür, stemming uygulanır ve skorlanır (başlıkta geçen kelime WEIGHT 2 ile açıklamada geçenden iki kat değerli). TAG kelimelere bölünmeyen, birebir eşleşen değerler için: kategori, durum, dil kodu. $.categories[*] dizinin her elemanını ayrı bir tag yapıyor. NUMERIC aralık sorgusu, SORTABLE ise o alana göre hızlı sıralama sağlıyor. 07'deki depo konumları gibi koordinatlar için GEO tipi de var.

Dokümana göre index oluşturulduğunda var olan key'ler arka planda index'lenir (FT.INFO'daki percent_indexed), sonradan yazılan ya da değişen document'lar ise yazma komutu dönmeden index'e girer. Yani JSON.SET ya da JSON.NUMINCRBY döndüğünde arama yeni değeri görüyor; ayrı bir senkronizasyon job'ına gerek yok. Index'in kendisi key değil, FT._LIST ile listelenir ve FT.DROPINDEX ile silinir (document'lara dokunmadan).

FT.SEARCH: Filtre, Sıralama ve Sayfalama

docker compose -f lab/compose.yaml exec -T redis redis-cli < lab/scripts/11-search.txt
127.0.0.1:6379> FT.SEARCH idx:catalog "@category:{classic} @price:[-inf 20000]" SORTBY price ASC LIMIT 0 2 RETURN 2 title price
1) (integer) 3
2) "bookstore:catalog:9780141439587"
3) 1) "price"
   2) "14990"
   3) "title"
   4) "Emma"
4) "bookstore:catalog:9780141439518"
5) 1) "price"
   2) "15990"
   3) "title"
   4) "Pride and Prejudice"
127.0.0.1:6379> FT.SEARCH idx:catalog "austen" RETURN 1 title
1) (integer) 2
2) "bookstore:catalog:9780141439587"
3) 1) "title"
   2) "Emma"
4) "bookstore:catalog:9780141439518"
5) 1) "title"
   2) "Pride and Prejudice"
127.0.0.1:6379> FT.SEARCH idx:catalog "@category:{science\\-fiction} -@category:{dystopia}" RETURN 1 title
1) (integer) 2
2) "bookstore:catalog:9780553293357"
3) 1) "title"
   2) "Foundation"
4) "bookstore:catalog:9780441013593"
5) 1) "title"
   2) "Dune"
127.0.0.1:6379> FT.SEARCH idx:catalog "@category:{science-fiction}" RETURN 1 title
(error) SEARCH_SYNTAX Syntax error at offset 26 near fiction

Sorgu dili kısaca şöyle: @field:{value} tag, @field:[min max] sayı aralığı (-inf/+inf ile açık uçlu), alan belirtmeden yazılan kelime bütün TEXT alanlarında aranır (yazar adı "austen" iki kitabı buldu). Boşluk AND, | OR, başa konan - NOT demek. İlk sorgunun cevabındaki 3 toplam sonuç sayısı; LIMIT 0 2 yalnız ilk sayfayı döndürdü. RETURN vermezsen bütün document döner, listeleme sayfası için gereksiz trafik.

Son satır en sık karşılaşılan hata: tag değerindeki tire sorgu dilinde operator sayılıyor ve science-fiction syntax hatası veriyor. Tag ve kelime içindeki noktalama işaretlerini ters bölüyle escape etmen gerekiyor. Kullanıcıdan gelen metni sorguya doğrudan koymak da aynı sorunu doğurur; SQL injection kadar tehlikeli değil ama kullanıcının yazdığı bir tire ya da @ sorgunun anlamını değiştirebiliyor.

FT.AGGREGATE ve Çok Değerli Alanlar

Arama sayfasının yanındaki "Klasik (6), Bilim kurgu (3)" gibi sayılar (facet) için ilk akla gelen FT.AGGREGATE'in GROUPBY'ı:

127.0.0.1:6379> FT.AGGREGATE idx:catalog "*" GROUPBY 1 @category REDUCE COUNT 0 AS books REDUCE AVG 1 @price AS avgPrice SORTBY 2 @books DESC
1) (integer) 5
2) 1) "category"
   2) "science-fiction"
   3) "books"
   4) "2"
   5) "avgPrice"
   6) "26490"
3) 1) "category"
   2) "dystopia"
   3) "books"
   4) "2"
   5) "avgPrice"
   6) "19490"
4) 1) "category"
   2) "romance"
   3) "books"
   4) "2"
   5) "avgPrice"
   6) "15490"
5) 1) "category"
   2) "classic"
   3) "books"
   4) "1"
   5) "avgPrice"
   6) "21990"
6) 1) "category"
   2) "fantasy"
   3) "books"
   4) "1"
   5) "avgPrice"
   6) "24990"
127.0.0.1:6379> FT.SEARCH idx:catalog "@category:{classic}" LIMIT 0 0
1) (integer) 6

Sekiz kitabın altısı klasik, ama GROUPBY klasik için 1 saydı. Her kitap yalnız dizisinin ilk kategorisiyle gruplandı: Dune ["science-fiction","classic"] olduğu için yalnız bilim kurguya sayıldı. Dokümanın JSON index sınırları bölümü, birden çok değer döndüren bir JSONPath için SORTBY'ın yalnız ilk değere göre sıraladığını yazıyor; GROUPBY için bir şey söylemiyor, ama ölçümde o da ilk değeri kullandı. LOAD ile $.categories[*]'ı ayrıca yüklemek de sonucu değiştirmedi. Çok değerli bir tag için doğru sayıyı FT.TAGVALS ile değerleri alıp her biri için LIMIT 0 0'lı bir sayım sorgusu göndererek bulduk (aşağıda .NET kodu).

GROUPBY tek değerli alanlarda beklendiği gibi çalışıyor. Örneğin APPLY "floor(@year / 100) * 100" AS century ile yüzyıl bazında gruplayınca 1800'ler için 3 kitap ve ortalama 17.656 kuruş, 1900'ler için 5 kitap ve 23.390 kuruş çıktı. APPLY, FILTER, SORTBY ve REDUCE adımları sırayla çalışan bir pipeline oluşturuyor; rapor tarzı sorular için kullanışlı.

.NET'ten NRedisStack ile Katalog Araması

StackExchange.Redis'te JSON ve FT.* komutları için tipli method yok. Redis'in .NET kütüphanesi NRedisStack, aynı IDatabase üzerine extension method'lar ekliyor (db.JSON(), db.FT(), 07'deki Bloom filter için db.BF()); bağlantı yine 02'deki tek multiplexer. Bookstore.Api açılışta index'i yoksa oluşturuyor:

// FT.CREATE idx:catalog ON JSON PREFIX 1 bookstore:catalog: LANGUAGE turkish SCHEMA ...
public static async Task EnsureIndexAsync(IDatabase db)
{
    ISearchCommandsAsync ft = db.FT();
    if ((await ft._ListAsync()).Any(name => name.ToString() == Index))
    {
        return;
    }
    await ft.CreateAsync(Index,
        new FTCreateParams().On(IndexDataType.JSON).Prefix(RedisKeys.CatalogPrefix).Language("turkish"),
        new Schema()
            .AddTextField(new FieldName("$.title", "title"), weight: 2.0)
            .AddTextField(new FieldName("$.author", "author"))
            .AddTextField(new FieldName("$.description", "description"))
            .AddTagField(new FieldName("$.categories[*]", "category"))
            .AddNumericField(new FieldName("$.priceKurus", "price"), sortable: true)
            .AddNumericField(new FieldName("$.year", "year"), sortable: true)
            .AddNumericField(new FieldName("$.stock", "stock")));
}

Arama endpoint'i sorguyu parçalardan kuruyor ve kullanıcıdan gelen her değeri escape ediyor; kategori sayıları ise Task.WhenAll ile birlikte gönderilen sayım sorgularından geliyor:

Query query = new Query(parts.Count == 0 ? "*" : string.Join(' ', parts))
    .ReturnFields("title", "price")
    .Limit((pageNo - 1) * pageSize, pageSize);
if (string.IsNullOrWhiteSpace(q))
{
    query.SetSortBy("price", ascending: true);                                // kelime yoksa relevance anlamsız, fiyata göre sırala
}
SearchResult result = await redis.GetDatabase().FT().SearchAsync(Index, query);

// Facet sayıları: FT.TAGVALS + her kategori için bir FT.SEARCH ... LIMIT 0 0, hepsi birlikte gönderilir
RedisResult[] values = await ft.TagValsAsync(Index, "category");
string[] categories = [.. values.Select(v => v.ToString())];
SearchResult[] counts = await Task.WhenAll(categories.Select(c =>
    ft.SearchAsync(Index, new Query($"@category:{{{Escape(c)}}}").Limit(0, 0))));

// Sorgu dilindeki karakterler (-, @, {, |, : ...) escape edilmeli, yoksa "science-fiction" syntax hatası verir
public static string Escape(string value)
{
    StringBuilder sb = new(value.Length);
    foreach (char c in value)
    {
        if (!char.IsLetterOrDigit(c) && c != '_')
        {
            sb.Append('\\');
        }
        sb.Append(c);
    }
    return sb.ToString();
}
curl "http://localhost:5080/catalog/search?category=science-fiction&maxPrice=25000"
{"query":"@category:{science\\-fiction} @price:[-inf 25000]","total":2,"page":1,"hits":[{"isbn":"9780060850524","title":"Brave New World","priceKurus":19990},{"isbn":"9780553293357","title":"Foundation","priceKurus":22990}]}
curl "http://localhost:5080/catalog/categories"
[{"category":"classic","books":6},{"category":"science-fiction","books":3},{"category":"dystopia","books":2},{"category":"romance","books":2},{"category":"fantasy","books":1}]
curl -X PATCH http://localhost:5080/catalog/books/9780441013593/price -H "Content-Type: application/json" -d '{"priceKurus":24990}'
{"isbn":"9780441013593","priceKurus":24990}
curl "http://localhost:5080/catalog/search?category=science-fiction&maxPrice=25000"
{"query":"@category:{science\\-fiction} @price:[-inf 25000]","total":3,"page":1,"hits":[{"isbn":"9780060850524","title":"Brave New World","priceKurus":19990},{"isbn":"9780553293357","title":"Foundation","priceKurus":22990},{"isbn":"9780441013593","title":"Dune","priceKurus":24990}]}

PATCH endpoint'i yalnız $.priceKurus'u değiştiriyor (JSON.SET … XX; document yoksa 404) ve Dune bir sonraki aramada yeni fiyatıyla listeye girdi. İki ayrıntıyı MONITOR ile gördüm. Birincisi, NRedisStack her FT.SEARCH'e DIALECT 2 ekliyor; sunucunun varsayılanı ise CONFIG GET search-default-dialect ile 1 çıktı. redis-cli'den denediğin sorgu ile uygulamanın gönderdiği sorgu farklı dialect'le çalışabilir; ikisini aynı tutmak için redis-cli'de de DIALECT 2 yaz. İkincisi, kategori sayılarını ilk denediğimde listede 0 kitaplı bir bestseller vardı: FT.TAGVALS, artık hiçbir document'ta olmayan bir değeri index'in temizlik işi (GC) çalışana kadar döndürmeye devam etti. Endpoint bu yüzden 0 sayılı değerleri eliyor.

Türkçe Arama: Stemming'in ve Büyük İ'nin Sınırları

Redis'in stemming dokümanı Snowball kütüphanesini kullandığını ve desteklenen diller arasında turkish'i sayıyor. Önce Tahmin Et'te "evlilikler" ile "evlilik" eşleşti; "kızlar"/"kızları" ve "savaşlar"/"savaş" de eşleşti. Ama her ek doğru sonuç vermiyor:

127.0.0.1:6379> FT.SEARCH idx:catalog "@description:gezegenlerde" RETURN 1 title
1) (integer) 0
127.0.0.1:6379> FT.EXPLAIN idx:catalog "@description:gezegenlerde"
"@description:UNION {\n  @description:gezegenlerde\n  @description:+gezegen(expanded)\n  @description:gezegen(expanded)\n}\n"
127.0.0.1:6379> FT.EXPLAIN idx:catalog "@description:gezegeni"
"@description:UNION {\n  @description:gezegeni\n  @description:+gezege(expanded)\n  @description:gezege(expanded)\n}\n"

Dune'un açıklamasındaki "gezegeni" kelimesi "gezege" köküne, sorgudaki "gezegenlerde" ise "gezegen" köküne indi; kökler farklı olduğu için eşleşme yok. Aynı durum "hazinesi" ile "hazinesine" arasında da çıktı. Snowball'un Türkçe stemmer'ı kural tabanlı, sözlük kullanmıyor; eklemeli bir dilde bazı kelime çiftlerini aynı köke indiremiyor. FT.EXPLAIN bir sorgunun hangi terimlere açıldığını gösterdiği için böyle bir durumda ilk bakılacak yer.

Büyük harf sorunu daha temel: "İngiliz" küçültüldüğünde Unicode kurallarına göre "i" + birleşik nokta işareti oluyor ve "ingiliz" ile aynı terim olmuyor. Index Türkçe'ye özel küçük harf dönüşümü yapmıyor. Uygulama tarafında hem document'ları hem sorguyu aynı şekilde normalize edersen (örneğin index'lenecek ayrı bir alana ToLower(new CultureInfo("tr-TR")) ile küçültülmüş ve aksanları sadeleştirilmiş metin yazarak) bu sorun kalkar. Türkçe arama kalitesi kritikse PostgreSQL'in Türkçe configuration'ı ve unaccent'i (PostgreSQL serisinin "PostgreSQL Full-Text Search: tsvector, tsquery, Türkçe Configuration, unaccent ve pg_trgm" yazısı) ya da Elasticsearch'ün Türkçe analyzer'ı (Elasticsearch ile Arama serisinde) daha fazla kontrol veriyor.

Ölçek, Valkey ve Ne Zaman Başka Bir Arama Motoru

Index'in bedelini görmek için 100.000 üretilmiş kitabı hem JSON hem hash olarak yazıp ikisini de index'ledim:

dotnet run --project src/Bookstore.RedisLab -- catalog-scale
100,000 books: JSON 45.6 MB, hash 46.1 MB
MEMORY USAGE one book: json=464 B, hash=533 B
idx:bench-json: indexed 100,000 docs in 6,130 ms, index memory 41.4 MB
idx:bench-hash: indexed 100,000 docs in 5,700 ms, index memory 41.4 MB
@category:{science\-fiction} @price:[-inf 20000]: 7,947 matches, p50 1.69 ms, p99 3.19 ms
@description:(ejderha hazine): 20,362 matches, p50 2.25 ms, p99 5.28 ms

Bu veri setinde JSON ile hash arasında bellek farkı yok denecek kadar az; index ise verinin kendisi kadar (41,4 MB) yer tutuyor. Yani Redis'te arama, kataloğu belleğe iki kez sığdırmak demek. Sorgular milisaniye mertebesinde, ama bu süreler tek client'la ve boş bir sunucuda; 20.000 eşleşmeli bir metin sorgusu sıralama yaptığı için filtreli sorgudan yavaş.

Valkey'de JSON ve arama çekirdekte değil; valkey/valkey image'ında FT.SEARCH bilinmeyen komut. Modüller valkey/valkey-bundle image'ında geliyor (lab'da bundle profiliyle, 6381 portunda). Aynı index'i orada denedim:

127.0.0.1:6381> FT.CREATE idx:catalog ON JSON PREFIX 1 bookstore:catalog: LANGUAGE turkish SCHEMA $.title AS title TEXT
(error) Bad arguments for LANGUAGE: Unknown argument `turkish`
127.0.0.1:6381> FT.CREATE idx:catalog ON JSON PREFIX 1 bookstore:catalog: SCHEMA $.title AS title TEXT WEIGHT 2
(error) Invalid field type for field `$.title`: The `WEIGHT` clause with a value other than `1.0` is not supported.
127.0.0.1:6381> FT.CREATE idx:catalog ON JSON PREFIX 1 bookstore:catalog: SCHEMA $.title AS title TEXT $.categories[*] AS category TAG $.priceKurus AS price NUMERIC
OK
127.0.0.1:6381> FT.SEARCH idx:catalog "@title:dune" RETURN 1 title
1) (integer) 1
2) "bookstore:catalog:9780441013593"
3) 1) "title"
   2) "Dune"
127.0.0.1:6381> FT.SEARCH idx:catalog "@category:{classic}" LIMIT 0 0
1) (integer) 1

valkey-search (image'daki MODULE LIST'te sürüm numarası 66049) metin aramayı destekliyor, ama Türkçe stemming ve alan ağırlığı yok; çok elemanlı kategori dizilerinde 6 yerine 1 klasik buldu, yalnız tek elemanlı diziyi eşleştirdi. Yani komut adları aynı olsa da Redis'teki index tanımı ve sorgular Valkey'e olduğu gibi taşınmıyor; kullandığın her özelliği hedef sürümde dene.

Ne zaman Redis Query Engine? Veri zaten Redis'teyse, arama ihtiyacı filtre + sıralama + basit metin aramasıysa ve gecikme kritikse (sepetin yanında "stokta olan, 200 TL altı klasikler" gibi) iyi bir seçim. Relevance ayarı, eş anlamlılar, gelişmiş dil analizi, highlight ve büyük veri gerekiyorsa Elasticsearch; veri zaten SQL Server ya da PostgreSQL'deyse ve arama ikincil bir özellikse veritabanının kendi full-text araması daha az parça demek. Vector ve hybrid arama (FT.HYBRID) RAG (Retrieval-Augmented Generation; modelin cevabını aranıp bulunan dokümanlarla besleme) serisinin "Hybrid Search ve Reciprocal Rank Fusion: Keyword ile Vector Aramayı Birleştirmek" yazısının konusu.

Üretimde Dikkat

Katalogun asıl kaydı SQL Server'da kalmalı; Redis'teki document'lar bir arama kopyası. Kopyayı güncellemenin yolu sipariş olaylarında yaptığımız gibi bir event'le (10'daki stream) ya da periyodik bir senkronizasyon job'ıyla; Redis'i tek kaynak yaparsan persistence ve bellek kararları (12) doğrudan veri kaybı riskine dönüşür. Index tanımı değişecekse yeni adla ikinci bir index kur (idx:catalog-v2), arka planda index'lenmesini bekle, sonra uygulamanın kullandığı alias'ı FT.ALIASUPDATE ile yeni index'e çevir; Redis 8.10 alias'ları listelemek için FT.ALIASLIST'i ekledi. Sorgu zaman aşımı varsayılan olarak 500 ms (search-timeout) ve search-on-timeout varsayılanı return: süre dolunca hata değil eksik sonuç dönüyor. Eksik sonucu tam sonuç sanmamak için NRedisStack'in SearchResult.Warnings alanına bak; belgeye göre bu alan yalnız RESP3 (Redis protokolünün pub/sub ve uyarı gibi ek bilgileri taşıyabilen 3. sürümü) bağlantılarında doluyor. Bellek planlamasında document'lar ile index'i birlikte hesapla: FT.INFO'daki total_index_memory_sz_mb bu yazıdaki ölçümde verinin neredeyse kendisi kadardı.

Sık Düşülen Tuzaklar

Redis'i kataloğun tek kaydı yapmak: bellek, persistence ve yeniden index'leme maliyeti hesaplanmadan asıl veritabanının yerine geçiyor.

Tire, nokta, @ gibi karakterleri escape etmeden sorguya koymak: science-fiction syntax hatası verdi.

Çok değerli bir alanda GROUPBY'a güvenmek: 6 klasik kitap 1 olarak sayıldı.

Fiyatı JSON'da kesirli sayı olarak tutmak: 279,90 × 100 = 27.989,999999999996.

redis-cli'de dialect belirtmeden denenen sorguyu uygulamadakiyle aynı sanmak: sunucu varsayılanı 1, NRedisStack 2 gönderiyor.

Türkçe stemming'in her eki çözdüğünü ve "ingiliz" aramasının "İngiliz"i bulacağını varsaymak.

Redis'teki index tanımını Valkey'e olduğu gibi taşımak: LANGUAGE turkish ve WEIGHT reddedildi.

Küçük Bir Deneme

★☆☆ idx:catalog'a $.stock AS stock NUMERIC zaten var. Arama endpoint'ine inStock=true parametresi ekle ve sorguya @stock:[1 +inf] koy. The Hobbit listeden düşüyor mu?

★★☆ Document'lara titleSearch adlı ikinci bir alan ekle: başlık ve açıklamanın tr-TR kültürüyle küçültülmüş hâli. Index'e bu alanı ekleyip "ingiliz" aramasının iki kitabı bulduğunu doğrula.

★★★ idx:catalog-v2 adıyla yazar alanı TAG olan yeni bir index kur, FT.ALIASADD/FT.ALIASUPDATE ile uygulamayı kesintisiz yeni index'e geçir. Geçiş sırasında arama endpoint'i hiç hata döndürdü mü?

Bir sonraki yazıda Redis'in verisini diske nasıl yazdığına ve belleğin nasıl yönetildiğine geçiyoruz: RDB (verinin belirli anlarda alınan snapshot'ı), AOF (yazma komutlarının sırayla eklendiği log dosyası) ve fsync, fork'un bellek maliyeti, maxmemory ve eviction policy'lerinin cache dışı kullanımdaki anlamı, büyük ve sıcak key'ler.