tsvector ile mesaj içeriği arama süresini nasıl milisaniyelere çektik?

postgresql

1 ay önce 0 yorum

Insider’da mindbehind (sohbet odaklı müşteri desteği yazılımı) geliştirirken bir yavaşlık dikkatimizi çekti. Müşteri temsilcileri mesajlarda arama yaparken bekliyorlardı veya aramaları hata ile sonuçlanıyordu. Bu hatanın sebebi mesajların mongodb’de bir dizi içerisinde olması ve bu nested değeri “like” anahtar kelimesi ile arıyormuşuz. Ve bu değere göre index yapmak oldukça maliyetli ve gereksizdi.

Peki neden gereksizdi?

Bazen her şeyin aranmasına ihtiyaç duymayız öyle değil mi? Diyelim ki mesajlarda bir link paylaşılmış olsun. Bu değeri bulmak temsilcinin ihtiyacı olamayan bir durum. Temsilci aslında hangi kişilerle ne tür bir konuşma yaptığını bilmek istiyor. Bu durumda gerçekten mesajın içindeki tüm değerleri kontrol etmeye gerek olmadığını görmüş oluyoruz. Ancak biz like sorgusu ile tüm mesajlara bakmak istiyoruz. 

Bu gereksiz tutumu, kelimelerle arama yaparak çözebileceğimizi keşfettik. İlk başlarda bu işi yapması için Lucene veya Elastic Search gibi 3. parti çözümler aklımıza geldi ancak ayarlaması ve kurması oldukça uzun sürecekti. 

Başka bir çözüm olamaz mıydı?

Aslında halihazırda verileri PostgreSql üzerinde tutuyorduk. Kullandığımız bu veritabanı bize bir çözüm sunabilir miydi onu aradık ve tsvector’un bizim isterimize çok iyi yanıt verdiğini keşfettik.

Full-text Search

Bizim cümle içinde bir arama yapmaya ihtiyacımız yoktu. Sadece birkaç kelimeyi içeren mesaja ait konuşmayı bulmak bizim için yeterliydi. Örneğin mesaj içerisinde şöyle bir konuşma geçmiş olsun: “Merhaba, şu anda kargomun nerede olduğunu bilmek istiyorum.” Ve temsilci kargosunu arayan müşterileri bulmak isterse “kargo” araması yeterlidir öyle değil mi?

Veya şöyle bir sıkıntı olabilir içerisinde hem kargo hem iade içeren bir konuşmayı bulmak istese bu durumda kullanıcı da şöyle bir mesaj yazmış olsa:

“kargom nererede eğer verilmediyse iade etmek istiyorum.”

Temsilci “kargo” ve “iade” kelimelerini aratmak isteseydi bunu like ile yapmak işi çözmezdi like ‘%kargo iade%’. Çünkü bu kelimeler cümlenin içinde herhangi bir noktada olabilir. Bunu çözmek için şöyle bir sorgu yazmak gerekirdi ki bu da oldukça kötü bir kullanım olurdu.
like ‘%kargo%’ OR like ‘%iade%’. Biz böyle bir sorgu denediğimizde neredeyse %50 performans kaybetmiş oluyorduk.

Aslında olayın performans sorununun yanısıra bizim full-text search’e ihtiyacımızın olduğunu da anlamış olduk. Bizim için konuşmada ilgili kelimelerin bulunması da gerekiyordu. Konunun başlığı olan olayın keşfi ve asıl konumuz olan o şeye geçelim.

tsvector

Öncelikle birkaç örnek göstermek istiyorum. Ortada tablo falan yok iken düz bir sorgu ile bu işi yapmaya çalışalım.

select to_tsvector('kargom nererede eğer verilmediyse iade etmek istiyorum.')
 @@ to_tsquery('kargo:* & iade:*')
 as "found";

Yukarıdaki sorgunun sonucunda “found” true olarak gelecektir. Eğer cümlenin içinden iade kelimesini çıkarırsak false olacaktır. Sorgudaki görünen @@ to_tsquery ifadesi anlaşılır görünüyor. “kargo” ile başlayıp devam eden kelimeler ve “iade” ile başlayıp devam eden kelimeler sorgulanıyor.

to_tsvector raw string değeri vector haline getiren bir method. Aslında tip dönüşümü yaptığını söyleyebiliriz. Sadece onu çalıştırdığımızda nasıl değer döndürdüğünü görelim:

select to_tsvector('kargom nererede eğer verilmediyse iade etmek istiyorum.')
-- result: 'etmek':6 'eğer':3 'iad':5 'istiyorum':7 'kargom':1 'nerered':2 'verilmediys':4

Görüldüğü üzere bazı kelimelerin tamamı vectorize edilmemiş daha kısa bir hale indirgenmiş. Bunun bir sebebi ise sürecin sözcükbirim hale getirilmesini amaçlamasıdır. Yazı içerisindeki noktalama işaretinin de yok sayıldığını görebiliriz. Burada aranacak metin değerinin dil bağımlı olması da söz konusu olabilir çünkü isteğinize göre sözcükbirim oluşturmanız gerekebilir. Biz tablo tasarımı gereği dil bazlı ayırma işini yapmadık ve tüm içerikler için İngilizce dil ayarını kabul ettik. Aşağıda yukarıdaki değerin aynısını verdiğimizde çıkan sonucu da görelim:

select to_tsvector('english', 'kargom nererede eğer verilmediyse iade etmek istiyorum.')
-- result: 'etmek':6 'eğer':3 'iad':5 'istiyorum':7 'kargom':1 'nerered':2 'verilmediys':4

select to_tsvector('turkish', 'kargom nererede eğer verilmediyse iade etmek istiyorum.')
-- result: 'etmek':6 'ia':5 'istiyor':7 'kargo':1 'nerere':2 'verilmedi':4

Burada başka bir alfabe ile yazılmış kelimelerde nasıl arama yapacağımıza dair bir soru oluşabilir, onun için ek bir şey yapmamıza gerek kalmıyor örnek gösterecek olursak şu şekilde olurdu:

select to_tsvector('english', 'أريد إعادة حمولتي إذا لم يتم تسليمها.')
-- result: 'أريد':1 'إذا':4 'إعادة':2 'تسليمها':7 'حمولتي':3 'لم':5 'يتم':6

Ancak yukarıdaki örnek daha düşük versiyonlu PostgreSQL veritabanlarında çalışmayabilir, boş sonuç döndürecektir (NULL değil).

Sadede gelelim

Görüldüğü üzere aranacak metinden sözcükbirimler oluşturmayı ve arayacağımız zaman da sözcükbirim ile arama yapılacağını görmüş olduk. Müşteri temsilcilerimiz uzun bir cümle aratacaklarını varsayalım. “İade etmek istiyorum şeklinde bir ifadeyi aramak istesinler.

select to_tsquery('english', 'iade etmek istiyorum')
-- result: SQL Error [42601]: ERROR: syntax error in tsquery: "iade etmek istiyorum"

select plainto_tsquery('english', 'iade etmek istiyorum')
-- result: 'iad' & 'etmek' & 'istiyorum'

select to_tsvector('english', 'kargom nererede eğer verilmediyse iade etmek istiyorum.')
@@ plainto_tsquery('english', 'iade etmek istiyorum')
-- result: true

select to_tsvector('english', 'kargom nererede eğer verilmediyse iade etmekde istiyorum.')
@@ plainto_tsquery('english', 'iade etmek istiyorum')
-- result: false (Çünkü etmekde şeklinde bir kelime eklendi)

Görüldüğü üzere birkaç örnek ile neyin bulunup neyin bulunamayacağını görmüş olduk. Biz kullacının girdiği kelimeleri tam olarak içermeyen ama onlar ile başlayıp devam eden kelimeleri içeren kayıtları bulmak istediğimiz için, aranacak değeri application katmanında sorgu haline getirip psql’e o şekilde teslim ettik. Örneğin:

select to_tsvector('english', 'kargom nererede eğer verilmediyse iade etmekde istiyorum.')
@@ to_tsquery('english', 'iade:* & etmek:* & istiyorum:*')
-- result: true

Başka bir mevzu ise…

Aranacak değerlerin ne olacağına karar vermekti. Çünkü vector arama belirli bir kısma kadar size yardım edebiliyor. 

Sistemi basit düşünelim “conversations” tablosu ve “messages” tablosu olduğunu düşünelim. Biz aslında mesajları değil konuşmaları elde etmek istiyoruz değil mi? O yüzden conversation tablosundan messages tablosuna join atarak ek bir performans götürüsü oluşturmuş olurduk. conversation içerisindeki değerleri de mesaj tablosuna vermiş olsak bu sefer de normalizasyon kuralına uymamış ve güncelleme problemi oluşturmuş olurduk.

Bulduğumuz çözüm oldukça basit oldu, conversations tablosuna messages_vector adında bir kolon ekledik. Ne zaman mesaj tablosuna bir kayıt gerçekleşirse biz de bu değeri güncelleyen bir trigger yazdık. Böylelikle geçmiş mesajların içeriğini tabloya kaydedersek ve trigger’ımız çalışırsa artık arama işi için ek bir şey düşünmemize gerek kalmazdı. Sunucu katmanında da ek bir kod yazmamıza gerek kalmadı.

Örnek bir trigger aşağıdaki gibi olur:

-- fonksiyon tanımlanıyor
CREATE OR REPLACE FUNCTION my_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE conversations
    SET messages_vector = COALESCE(messages_vector, '') || ' ' || to_tsvector(NEW.content)
    WHERE id = NEW.conversation_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- trigger atanıyor
CREATE TRIGGER messages_insert_trigger
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION my_trigger_function();

Index?

Elbette conversations tablosundaki messages_vector kolonuna index eklersek arama performansını yakalamış oluruz:

CREATE INDEX IF NOT EXISTS idx_messages_vector
ON public.conversations
USING GIN(messages_vector)

İşte bu kadar!

39 milyon satırdan oluşan ve 390 milyon mesaja sahip sistemden “merhaba” içeren konuşmaları bulmak için sorguyu izlerken biraz keyif yapalım:

SELECT id FROM conversations
WHERE messages_vector @@ to_tsquery('merhaba:*')
LIMIT 200

-- result: 200 row(s) fetched - 0.092s

 

Düşündüklerin nedir ?

Abdurrahman Eker

(1010 Eylül 11111001100)

  • Full Stack Developer Turkey/Sivas
  • İnternette Avare Kodcu
  • coffee
  • github
  • instagram
  • linkedin
  • youtube
  • Yeni içeriklerden haberdar olmak ister misin ?