2026-08-19veri4 dk

Türkçe içerik için tam metin arama kurmak

Sondan eklemeli bir dilde gövdeleme tek başına yetmiyor. PostgreSQL üzerinde unaccent ve pg_trgm ile çalışır bir Türkçe arama kurulumu, indeksler ve küçültme tuzağı dahil.

Türkçe metinde arama kurmak, İngilizce metinde arama kurmaktan farklı bir problem. Sebep basit: Türkçe sondan eklemeli. "Fatura" araması "faturalarımızdan" kelimesini bulmalı, "kesildi" araması "kesilmiş"i yakalamalı. İngilizce'nin gövdeleme (stemming) algoritmaları bu yükü taşıyacak şekilde tasarlanmadı.

İkinci mesele karakter seti. Türkçe'de i/ı ve I/İ ayrımı var ve bu ayrım küçültme işlemini veritabanı yerelleştirmesine bağımlı hale getiriyor. Üçüncüsü, kullanıcılar Türkçe karakterle yazmıyor. "sogutma", "şoğutma", "soğutma" aynı sonucu vermeli.

Bu yazı PostgreSQL üzerinde çalışır bir kurulum kuruyor. Elasticsearch de bir seçenek ama zaten Postgres kullanan bir uygulamada ikinci bir servis işletmenin bedeli çoğu proje için karşılığını vermiyor.

Önce hangi konfigürasyonun elinizde olduğunu görün

PostgreSQL, Snowball gövdeleyicileriyle gelen bir dizi hazır metin arama konfigürasyonu barındırıyor. Hangilerinin sizin sürümünüzde bulunduğunu ezberden varsaymak yerine sorun:

SELECT cfgname FROM pg_ts_config ORDER BY cfgname;

Listede turkish varsa bir gövdeleyiciniz var demektir. Ama sevinmeden önce ne yaptığını görün:

SELECT to_tsvector('turkish', 'faturalarımızdan kesilen tutarlar');

Snowball'un Türkçe gövdeleyicisi eklerin bir kısmını atıyor, bir kısmını atmıyor. Sonuç genellikle beklediğinizden kısa bir listeye benziyor ve bazı kelimelerin gövdesi anlamsız çıkıyor. Bu, kütüphanenin kusuru değil; sondan eklemeli bir dili kural tabanlı bir gövdeleyiciyle çözmek zor bir iş.

Pratikte iyi çalışan yaklaşım, gövdelemeye bel bağlamak yerine iki tekniği birleştirmek: aksan/karakter normalizasyonu ve n-gram benzerliği.

unaccent ile karakter normalizasyonu

unaccent eklentisi Türkçe karakterleri ASCII karşılıklarına indiriyor. Kullanıcının "sogutma" yazıp "soğutma" bulmasını sağlayan parça bu.

CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT unaccent('Soğutma Şirketi ÇİĞLİ');

Varsayılan unaccent sözlüğü ı harfini her kurulumda beklediğiniz gibi ele almayabilir. Çıktıyı kendi sunucunuzda kontrol edin; eksikse $SHAREDIR/tsearch_data/unaccent.rules dosyasına kendi eşleştirmenizi ekleyip özel bir sözlük tanımlayabilirsiniz.

Bunu bir arama konfigürasyonuna bağlamak için simple üzerine kurulu, gövdeleme yapmayan ama normalizasyon yapan bir konfigürasyon işi görüyor:

CREATE TEXT SEARCH CONFIGURATION tr_simple (COPY = simple);
ALTER TEXT SEARCH CONFIGURATION tr_simple
  ALTER MAPPING FOR hword, hword_part, word
  WITH unaccent, simple;

Gövdeleme yapmadığınız için "fatura" araması "faturalar"ı bulmaz. O boşluğu bir sonraki adım kapatıyor.

pg_trgm ile ek toleransı ve yazım hatası toleransı

pg_trgm metni üç harflik parçalara bölüp benzerlik ölçüyor. Sondan eklemeli dilde beklenmedik biçimde iyi çalışıyor, çünkü kök ortak kaldığı sürece trigram örtüşmesi yüksek kalıyor.

CREATE EXTENSION IF NOT EXISTS pg_trgm;

SELECT similarity(unaccent('faturalarimizdan'), unaccent('fatura'));

Kelime içinde arama için word_similarity daha isabetli sonuç veriyor; tam dize benzerliğine değil, hedef metnin içindeki en iyi eşleşen parçaya bakıyor.

Bu iki tekniği tek bir sorguda birleştirmenin kabul edilebilir bir yolu, tam metin aramayı birincil filtre, trigramı yedek filtre olarak kullanmak:

SELECT id, baslik,
       ts_rank(arama_vektoru, sorgu) AS rank,
       word_similarity(unaccent(:q), unaccent(baslik)) AS sim
FROM belgeler,
     websearch_to_tsquery('tr_simple', unaccent(:q)) AS sorgu
WHERE arama_vektoru @@ sorgu
   OR unaccent(baslik) %> unaccent(:q)
ORDER BY rank DESC, sim DESC
LIMIT 20;

websearch_to_tsquery, kullanıcının tırnak ve - gibi işaretlerini doğal biçimde ele aldığı için plainto_tsquery'ye tercih edilir. Kullanıcının yazdığı metni doğrudan to_tsquery'ye vermeyin; sözdizimi hatası atar.

İndeksler olmadan hiçbiri anlamlı değil

Vektörü her sorguda hesaplamak yerine üretilmiş sütun olarak saklayın:

ALTER TABLE belgeler
  ADD COLUMN arama_vektoru tsvector
  GENERATED ALWAYS AS (
    to_tsvector('tr_simple', coalesce(baslik,'') || ' ' || coalesce(govde,''))
  ) STORED;

CREATE INDEX belgeler_fts_idx ON belgeler USING GIN (arama_vektoru);
CREATE INDEX belgeler_trgm_idx ON belgeler USING GIN (baslik gin_trgm_ops);

Üretilmiş sütun ifadesinin IMMUTABLE olması gerekiyor. unaccent() varsayılan haliyle IMMUTABLE değil, o yüzden yukarıdaki ifadede doğrudan çağrılmıyor; normalizasyon konfigürasyonun içinde yapılıyor. Aynı sebeple unaccent içeren bir ifade üzerine indeks kurmak isterseniz önce IMMUTABLE bir sarmalayıcı fonksiyon tanımlamanız gerekiyor. Bunu yaparken sözlük dosyasını değiştirdiğinizde indeksin sessizce tutarsız hale geleceğini unutmayın.

Küçültme tuzağı

Veritabanı kolasyonu Türkçe ise lower('I') sonucu ı olur, C kolasyonunda i olur. Uygulama katmanında JavaScript ya da Python ile küçülttüğünüz sorgu metniyle, veritabanının indekste sakladığı biçim birbirinden ayrışabilir.

Aynı sorun citext ve ILIKE kullanan sorgularda da çıkıyor. ILIKE, kolasyonun küçültme kurallarını uyguluyor; dolayısıyla "İzmir" araması Türkçe kolasyonlu bir sunucuda beklediğiniz gibi, C kolasyonlu bir sunucuda beklemediğiniz gibi davranıyor. Geliştirme makinenizle üretim sunucunuzun kolasyonu farklıysa hata yalnızca üretimde görünüyor. İkisini de sorun:

SELECT datname, datcollate, datctype FROM pg_database WHERE datname = current_database();

Kural basit: küçültmeyi tek bir yerde yapın. Ya hepsini veritabanına bırakın, ya hepsini uygulamada yapıp veritabanına küçültülmüş metin gönderin. İkisini karıştırdığınız anda, yalnızca büyük harfle başlayan kelimelerde ortaya çıkan ve yeniden üretmesi zor bir hata sınıfı doğuyor.

Ne zaman bu kurulum yetmez

Kayıt sayısı milyonu geçtiğinde, eş anlamlı sözlüğü gerektiğinde ya da alan bazlı ağırlıklandırma karmaşıklaştığında ayrı bir arama motoru mantıklı hale geliyor. O eşiğe kadar burada anlatılan kurulum, tek bir servisle ve ek bir işletme yükü olmadan çalışıyor.

Geçiş kararını erken vermeyin. Ayrı bir arama motoru, indeksleme kuyruğu, yeniden indeksleme işi, sürüm yükseltme bakımı ve veri tutarlılığı sorusu getiriyor. Postgres içinde kalmanın en büyük avantajı, arama sonucunu aynı sorguda yetkilendirme koşullarıyla birleştirebilmek. Kullanıcının yalnızca kendi şirketinin belgelerini görmesi gerektiğinde, ayrı motorla çalışan mimarilerde bu filtreyi iki katmanda tekrar yazmak zorunda kalıyorsunuz.

Ölçmeden geçmeyin: hangi sorguların sonuç döndürmediğini kaydeden bir tablo tutun. Arama kalitesini iyileştiren şey algoritma seçimi değil, boş dönen sorguların listesi. O listeyi haftada bir okumak, eş anlamlı sözlüğünüzü kimsenin tahmin edemeyeceği kadar isabetli kuruyor.