E-ticarette arama ve filtreleme: FTS5, facet'ler ve Türkçe karakterler
Bir e-ticaret sitesinde arama kutusunu kullanan ziyaretçi, ne istediğini bilen ziyaretçidir. Ona "sonuç bulunamadı" demek, kasaya gelmiş müşteriyi geri çevirmek gibi. En sık görülen sebep de karmaşık bir şey değil: arama LIKE '%çadır%' ile yapılıyor, müşteri telefondan "cadir" yazıyor ve hiçbir şey dönmüyor.
Portföyümdeki outdoor e-ticaret mağazası, kamp, tırmanış, bisiklet, yürüyüş, giyim ve ayakkabı kategorilerinde 732 ürün ve 2.710 varyantlı bir çalışma. 6 ana ve 24 alt kategori, 18 marka var. Arama ve filtrelemeyi bu katalog üzerinde nasıl kurduğumu anlatıyorum.
LIKE neden yetmiyor
LIKE '%kelime%' üç sorun taşıyor. Birincisi hız: baştaki yüzde işareti yüzünden veritabanı indeksi kullanamaz, her aramada tüm tabloyu tarar. Birkaç yüz üründe fark etmezsiniz, on binlerce üründe fark edersiniz. İkincisi sıralama: eşleşme var ya da yok; hangi ürünün daha alakalı olduğunu bilemez. Üçüncüsü dil: "Çadır", "çadır", "CADIR" ve "cadir" dört ayrı kelime gibi davranır.
Tam metin arama (full-text search) bu üçünü çözer. Metni kelimelere böler, ters bir indeks kurar ("cadir" kelimesi şu ürünlerde geçiyor) ve sonuçları alaka puanına göre sıralayabilir.
SQLite FTS5 ile arama
Bu mağazada veritabanı SQLite; tam metin arama için FTS5 eklentisi kullanılıyor. Ürün adı, marka, kategori, SKU ve açıklama tek bir sanal tabloda indeksleniyor:
CREATE VIRTUAL TABLE products_fts USING fts5(
name, brand, category, sku, description,
content='', tokenize='unicode61'
);
content='' tabloyu içeriksiz (contentless) yapıyor; metnin kendisi ikinci kez saklanmıyor, yalnızca indeks tutuluyor. Ürünün asıl verisi zaten products tablosunda. Bunun bir bedeli var: içeriksiz tablodan satır silemiyorsunuz. Bu yüzden katalog değiştiğinde indeksi baştan kuruyorum. Bu katalog boyutunda bu yaklaşım rahatça işliyor; çok büyük ve sık değişen bir katalogda içerik tablosuna bağlı (external content) bir FTS tablosu ve tetikleyiciler daha doğru olur.
Arama sorgusu, filtrelerle aynı WHERE cümlesine bir alt sorgu olarak ekleniyor:
SELECT ... FROM products p
WHERE p.id IN (SELECT rowid FROM products_fts WHERE products_fts MATCH ?)
AND p.category_id = ?
Bu çalışmada README'deki ölçüm, anlık aramada sorgu süresinin tipik olarak 1 ile 5 ms arasında olması. Süreyi arayüzde de gösteriyorum; yavaşlayan bir sorguyu ilk fark eden ben olayım diye.
Türkçe karakterler: indeksi ve sorguyu aynı şekilde katlayın
FTS5'in unicode61 ayrıştırıcısı büyük/küçük harf farkını kaldırıyor ve istenirse aksan işaretlerini de silebiliyor. Ama Türkçe için tek başına yeterli değil. Noktasız "ı" bir aksanlı harf değil, ayrı bir harf; aksan temizliği onu "i" yapmaz. Büyük "İ" ile "I" harflerinin küçültülmesi de dile göre değişen bir iş.
Çözüm basit: hem indekse yazarken hem sorguda metin aynı fonksiyondan geçirilip ASCII'ye katlanıyor.
const TR: Record<string, string> = {
ç: "c", ğ: "g", ı: "i", ö: "o", ş: "s", ü: "u",
Ç: "c", Ğ: "g", I: "i", İ: "i", Ö: "o", Ş: "s", Ü: "u",
};
export const fold = (s: string) =>
s.split("").map((c) => TR[c] ?? c).join("")
.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
Böylece "Çadır", "çadır" ve "cadir" indekste de sorguda da "cadir" oluyor. Katlamanın bir yan etkisi var: "sık" ile "sik" ayırt edilemez hâle geliyor. Outdoor ürün kataloğunda bunun pratik bir zararı yok; telefondan Türkçe karakter kullanmadan yazan müşteriyi yakalamak çok daha değerli.
İkinci önemli parça, kullanıcı girdisini güvenli bir FTS sorgusuna çevirmek:
export function ftsQuery(q: string) {
const words = fold(q).replace(/[^a-z0-9\s]/g, " ")
.trim().split(/\s+/).filter(Boolean).slice(0, 6);
return words.map((w) => `"${w}"*`).join(" ");
}
"Çadır 2" yazılınca sorgu "cadir"* "2"* oluyor. Her kelime tırnak içinde, sonunda yıldız var; yani önek araması yapılıyor ve kullanıcı "cad" yazarken bile sonuçlar gelmeye başlıyor. Harf ve rakam dışındaki her şeyi temizlemek de önemli: FTS5'in kendi sorgu dili var (OR, NOT, NEAR, iki nokta, parantez) ve kullanıcının yazdığını olduğu gibi MATCH'e verirseniz ya sorgu hata verir ya da beklenmedik sonuç döner. Kelime sayısını da 6 ile sınırlıyorum.
Facet'li filtreler ve doğru sayımlar
Facet, filtre seçeneğinin yanında o seçenekte kaç ürün olduğunu gösteren sayıdır: "Marka: X (12), Y (8)" gibi. Outdoor e-ticaret mağazasında alt kategori, marka, fiyat aralığı ve stok durumunun yanında her alt kategoriye özel teknik özellikler de filtrelenebiliyor: çadırda kişi sayısı, uyku tulumunda konfor sıcaklığı, fenerde lümen, montta su geçirmezlik.
Burada en sık yapılan hata, facet sayımlarını tüm seçili filtrelerle hesaplamak. Kullanıcı X markasını seçtiğinde marka listesi sadece "X (12)" gösterir, diğer markalar sıfıra düşer ya da kaybolur. Kullanıcı ikinci bir marka ekleyemez.
Doğrusu, her facet'in sayımını kendi boyutu hariç diğer tüm filtrelerle hesaplamak. Marka sayımlarında marka filtresi uygulanmaz, fiyat aralığı hesaplanırken fiyat filtresi uygulanmaz. Kodda bunu, filtre koşullarını boyut adıyla bir sözlükte tutup gerektiğinde birini dışarıda bırakarak yapıyorum:
const build = (exclude: string[] = []) => {
const w = [...where], p = [...params];
for (const [k, d] of Object.entries(dims))
if (!exclude.includes(k)) { w.push(d.sql); p.push(...d.params); }
return { sql: w.length ? "WHERE " + w.join(" AND ") : "", params: p };
};
const forBrands = build(["brand"]); // marka sayımı, marka filtresi hariç
Teknik özellikler ürün satırında JSON olarak duruyor ve SQLite'ın JSON1 fonksiyonlarıyla (json_extract) filtreleniyor. Her alt kategori için hangi özelliğin facet olacağı katalog şemasında tanımlı; kullanıcı URL'ye rastgele bir özellik adı yazarsa o koşul sorguya hiç girmiyor.
Filtre durumu URL'de tutuluyor. Bunun iki faydası var: filtrelenmiş sayfanın bağlantısı paylaşılabiliyor ve geri düğmesi beklendiği gibi çalışıyor. Arayüz, seçime tıklandığında sunucunun cevabını beklemeden iyimser olarak güncelleniyor.
Eş anlamlılar
Müşteri "mont" yazıyor, katalogda ürünün adı "ceket". "Uyku tulumu" yerine "tulum", "kafa lambası" yerine "fener" aranıyor. Mağaza çalışmamda eş anlamlı desteği yok; bu yüzden nasıl eklerdim, onu anlatayım.
En az zahmetli yol, sorgu genişletme. Küçük bir eş anlamlı tablosu tutup sorguyu kurarken kelimeyi grubuyla değiştiriyorsunuz: FTS5'te ("mont"* OR "ceket"*) gibi. Eş anlamlıları indekse değil sorguya eklemek, listeyi değiştirdiğinizde indeksi yeniden kurma zorunluluğunu da kaldırıyor. Listeyi de tahmin ederek değil, "sonuç bulunamadı" dönen aramaların kaydından çıkarıyorum. Birkaç haftalık kayıt, en çok kaçan kelimeleri gösteriyor.
PostgreSQL kullanıyorsanız
Aynı fikirler PostgreSQL'de de geçerli, araçlar farklı:
- Tam metin arama için
tsvectorsütunu ve GIN indeksi. PostgreSQL'in hazırturkishmetin arama yapılandırması kelimeleri köklerine indiriyor; "çadırlar" araması "çadır" geçen ürünü de buluyor. - Aksan ve Türkçe harf farkları için
unaccenteklentisi. Burada da indekse ve sorguya aynı işlemi uygulamak şart; tavsiyem, kendi kataloğunuzdaki kelimelerle bir test listesi yazıp beklediğiniz eşleşmeleri doğrulamanız. - Yazım hataları için
pg_trgmeklentisi. Üçlü harf benzerliğiyle "cadr" yazan kullanıcıya "cadir" önerebilirsiniz. - Eş anlamlılar için PostgreSQL'in eş anlamlı sözlüğü de var, ama burada da sorgu genişletmeyi tercih ediyorum.
On binlerce ürüne, çok dilli kataloğa ya da gelişmiş alaka ayarına ihtiyacınız varsa Meilisearch, Typesense veya Elasticsearch gibi ayrı bir arama motoru düşünülebilir. Birkaç bin ürüne kadar ise veritabanının kendi tam metin araması çoğu mağazaya yeter ve ayrı bir servisi senkron tutma derdini ortadan kaldırır.
Arama kutusu için kontrol listesi
- "cadir", "ÇADIR" ve "çadır" aynı sonucu veriyor mu?
- Yazarken önek eşleşmesiyle sonuç geliyor mu?
- Kullanıcı girdisi arama sorgu diline temizlenerek mi çevriliyor?
- Facet sayımları kendi boyutu hariç hesaplanıyor mu?
- Filtreler URL'de mi, paylaşılan bağlantı aynı sayfayı açıyor mu?
- Sonuç bulunamayan aramalar kaydediliyor mu?
- Arama süresi ölçülüyor mu?
Mağazanızda arama ve filtreleme beklediğiniz gibi çalışmıyorsa bana yazabilirsiniz, birlikte bakarız.