Anlamsal arama ve öneri sistemlerinden üretken yapay zeka uygulamalarına ve artırılmış veri alma işlemlerine kadar uzanan uygulamalarda, bir PostgreSQL tablosuna depolanan verileri gömmek şüphesiz faydalıdır. Ancak PostgreSQL tablolarındaki veriler için yerleştirmeler oluşturmak ve yönetmek, eklemeleri tablo güncellemeleri ve silme işlemleriyle güncel tutmak, hatalara karşı dayanıklılık sağlamak ve bağımlı mevcut sistemleri etkilemek gibi dikkate alınması gereken birçok husus ve uç durum nedeniyle zor olabilir. masa.
Bu blog yazısında basitlik, dayanıklılık ve yüksek performans sağlamak için PgVectorizer'ı oluştururken teknik tasarım kararlarını ve yaptığımız tavizleri tartışacağız. Kendiniz yuvarlamak istiyorsanız alternatif tasarımları da tartışacağız.
Hadi içine atlayalım.
Öncelikle kurduğumuz sistemin nasıl çalışacağını anlatalım. Zaten okuduysanız bu bölümü atlamaktan çekinmeyin.
Açıklayıcı bir örnek olarak, aşağıdaki gibi tanımlanan bir tabloyu kullanarak PostgreSQL'de veri depolayan basit bir blog uygulaması kullanacağız:
CREATE TABLE blog ( id SERIAL PRIMARY KEY NOT NULL, title TEXT NOT NULL, author TEXT NOT NULL, contents TEXT NOT NULL, category TEXT NOT NULL, published_time TIMESTAMPTZ NULL --NULL if not yet published );
Blog yazısının içeriğine yerleştirmeler oluşturmak istiyoruz, böylece onu daha sonra anlamsal arama ve güç alma artırılmış üretimi için kullanabiliriz. Katıştırmalar yalnızca yayınlanmış bloglar için mevcut olmalı ve aranabilir olmalıdır (burada published_time
NOT NULL
).
Bu yerleştirme sistemini oluştururken, yerleştirme oluşturan herhangi bir basit ve dayanıklı sistemin sahip olması gereken bir dizi hedefi belirlemeyi başardık:
Orijinal tabloda değişiklik yapılmaz. Bu, halihazırda bu tabloyu kullanan sistem ve uygulamaların, yerleştirme sistemindeki değişikliklerden etkilenmemesini sağlar. Bu özellikle eski sistemler için önemlidir.
Tabloyla etkileşime giren uygulamalarda değişiklik yapılmaz. Tabloyu değiştiren kodu değiştirmek zorunda kalmak eski sistemler için mümkün olmayabilir. Aynı zamanda kötü bir yazılım tasarımıdır çünkü yerleştirme kullanmayan sistemleri, yerleştirmeyi oluşturan kodla birleştirir.
Kaynak tablodaki (bu durumda blog tablosu) satırlar değiştiğinde yerleştirmeleri otomatik olarak güncelleyin . Bu, bakım yükünü azaltır ve yazılımın sorunsuz olmasına katkıda bulunur. Aynı zamanda bu güncellemenin anlık veya aynı taahhüt dahilinde olması gerekmez. Çoğu sistem için "nihai tutarlılık" gayet iyidir.
Ağ ve hizmet arızalarına karşı dayanıklılık sağlayın: Çoğu sistem, OpenAI API gibi harici bir sisteme yapılan çağrı aracılığıyla yerleştirmeler oluşturur. Harici sistemin çöktüğü veya ağ arızasının meydana geldiği senaryolarda, veritabanı sisteminizin geri kalanının çalışmaya devam etmesi zorunludur.
Bu yönergeler, aşağıdakileri kullanarak uyguladığımız sağlam bir mimarinin temelini oluşturdu:
İşte kararlaştırdığımız mimari:
Bu tasarımda öncelikle blog tablosuna değişiklikleri izleyen bir tetikleyici ekliyoruz ve bir değişiklik görüldüğünde blog_work_queue tablosuna, blog tablosundaki bir satırın gömülmesiyle güncelliğini yitirdiğini belirten bir iş ekliyoruz.
Sabit bir programa göre, bir yerleştirme oluşturucu işi blog_work_queue tablosunu yoklayacak ve yapılacak bir iş bulursa bir döngü içinde aşağıdakileri yapacak:
Bu sistemi çalışırken görmek için kullanım örneğine bakın.
Blog uygulama tablomuz örneğine dönersek, yüksek düzeyde PgVectorizer'ın iki şey yapması gerekiyor:
Hangi satırların değiştiğini öğrenmek için blog satırlarındaki değişiklikleri izleyin.
Yerleştirmeler oluşturmak amacıyla değişiklikleri işlemek için bir yöntem sağlayın.
Her ikisinin de son derece eşzamanlı ve performanslı olması gerekir. Nasıl çalıştığını görelim.
Aşağıdakileri kullanarak basit bir iş kuyruğu tablosu oluşturabilirsiniz:
CREATE TABLE blog_embedding_work_queue ( id INT ); CREATE INDEX ON blog_embedding_work_queue(id);
Bu çok basit bir tablo ama dikkat edilmesi gereken bir nokta var: Bu tablonun benzersiz bir anahtarı yok. Bu, kuyruğu işlerken kilitleme sorunlarını önlemek için yapıldı, ancak bu, kopyalarımız olabileceği anlamına geliyor. Takas konusunu daha sonra aşağıdaki Alternatif 1'de tartışacağız.
Ardından blog
yapılan değişiklikleri izlemek için bir tetikleyici oluşturursunuz:
CREATE OR REPLACE FUNCTION blog_wq_for_embedding() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN IF (TG_OP = 'DELETE') THEN INSERT INTO blog_embedding_work_queue VALUES (OLD.id); ELSE INSERT INTO blog_embedding_work_queue VALUES (NEW.id); END IF; RETURN NULL; END; $$; CREATE TRIGGER track_changes_for_embedding AFTER INSERT OR UPDATE OR DELETE ON blog FOR EACH ROW EXECUTE PROCEDURE blog_wq_for_embedding(); INSERT INTO blog_embedding_work_queue SELECT id FROM blog WHERE published_time is NOT NULL;
Tetikleyici, blog_work_queue olarak değişen blogun kimliğini ekler. Tetikleyiciyi yüklüyoruz ve ardından mevcut blogları work_queue'ye ekliyoruz. Bu sıralama hiçbir kimliğin düşürülmediğinden emin olmak için önemlidir.
Şimdi bazı alternatif tasarımları ve bunları neden reddettiğimizi anlatalım.
Bu anahtarın tanıtılması mükerrer giriş sorununu ortadan kaldıracaktır. Bununla birlikte, bazı zorlukları da var, özellikle de böyle bir anahtar bizi tabloya yeni kimlikler eklemek için INSERT…ON CONFLICT DO NOTHING
yan tümcesini kullanmaya zorlayacağından ve bu yan tümce B ağacındaki kimliğin kilidini alır.
İşte ikilem: İşleme aşamasında, eşzamanlı işlemeyi önlemek için üzerinde çalışılan satırların silinmesi gerekir. Ancak bu silme işleminin gerçekleştirilmesi ancak ilgili yerleştirmenin blog_embeddings'e yerleştirilmesinden sonra yapılabilir. Bu, yarıda bir kesinti olması durumunda (örneğin, yerleştirme oluşturma işlemi silme işleminden sonra ancak yerleştirme yazılmadan önce çökerse) hiçbir kimliğin kaybolmamasını sağlar.
Artık benzersiz veya birincil bir anahtar oluşturduğumuzda, silme işlemini denetleyen işlem açık kalır. Sonuç olarak bu, söz konusu belirli kimlikler üzerinde bir kilit görevi görür ve yerleştirme oluşturma işinin tamamı boyunca bunların blog_work_queue'ye geri eklenmesini engeller. Ekleme oluşturmanın tipik veritabanı işleminizden daha uzun sürdüğü göz önüne alındığında, bu sorun anlamına gelir. Kilit, ana 'blog' tablosu için tetikleyiciyi durduracak ve birincil uygulamanın performansında düşüşe yol açacaktır. İşleri daha da kötüleştiren, birden fazla satırın toplu olarak işlenmesi durumunda kilitlenmelerin de potansiyel bir sorun haline gelmesidir.
Ancak ara sıra yinelenen girişlerden kaynaklanan potansiyel sorunlar, daha sonra açıklanacağı üzere işleme aşamasında yönetilebilir. Burada ara sıra bir kopya var ve bu, yerleştirme işinin gerçekleştirdiği iş miktarını yalnızca marjinal bir şekilde arttırdığı için bir sorun yok. Bu kesinlikle yukarıda bahsedilen kilitleme zorluklarıyla boğuşmaktan daha lezzetlidir.
Örneğin, değişiklik yapıldığında false olarak ayarlanan ve yerleştirme oluşturulduğunda true değerine çevrilen embedded
bir boolean sütunu ekleyebiliriz. Bu tasarımı reddetmenin üç nedeni var:
Yukarıda belirttiğimiz nedenlerden dolayı blog
tablosunu değiştirmek istemiyoruz.
Gömülü olmayan blogların bir listesini verimli bir şekilde almak, blog tablosunda ek bir dizin (veya kısmi dizin) gerektirir. Bu diğer işlemleri yavaşlatacaktır.
Bu, PostgreSQL'in MVCC yapısından dolayı artık her değişiklik iki kez (bir kez embedding=false ve bir kez embedding=true ile) yazılacağı için tablodaki karmaşayı artırır.
Ayrı bir work_queue_table bu sorunları çözer.
Bu yaklaşımın birkaç sorunu var:
Katıştırma hizmeti kapalıysa ya tetikleyicinin başarısız olması gerekir (işleminizi iptal eder) ya da kuyruğa eklenemeyen kimlikleri saklayan bir yedek kod yolu oluşturmanız gerekir. İkinci çözüm bizi önerdiğimiz tasarıma geri götürüyor, ancak bunun üzerine daha fazla karmaşıklık ekleniyor.
Bu tetikleyici, harici bir hizmetle iletişim kurmak için gereken gecikme nedeniyle muhtemelen veritabanı işlemlerinin geri kalanından çok daha yavaş olacaktır. Bu, tablodaki geri kalan veritabanı işlemlerinizi yavaşlatacaktır.
Kullanıcıyı oluşturma yerleştirme kodunu doğrudan veritabanına yazmaya zorlar. Yapay zekanın ortak dilinin Python olduğu ve oluşturma işleminin çoğu zaman başka birçok kitaplık gerektirdiği göz önüne alındığında, bu her zaman kolay ve hatta mümkün değildir (özellikle barındırılan bir PostgreSQL bulut ortamında çalışıyorsa). Veritabanının içinde veya dışında eklemeler oluşturma seçeneğiniz olan bir tasarıma sahip olmak çok daha iyidir.
Artık yerleştirilmesi gereken blogların bir listesi var, hadi listeyi işleyelim!
Gömme oluşturmanın birçok yolu vardır. Harici bir Python betiği kullanmanızı öneririz. Bu komut dosyası, iş kuyruğunu ve ilgili blog gönderilerini tarayacak, yerleştirmeleri oluşturmak için harici bir hizmeti çağıracak ve ardından bu yerleştirmeleri tekrar veritabanına depolayacaktır. Bu stratejinin gerekçesi şu:
Python Seçimi : Güçlü LLM geliştirme ve aşağıdaki gibi veri kitaplıkları ile vurgulanan, AI veri görevleri için zengin, eşsiz bir ekosistem sunduğu için Python'u öneriyoruz.
PL/Python yerine harici bir komut dosyası tercih etmek : Kullanıcıların, verilerini nasıl yerleştirecekleri üzerinde kontrole sahip olmalarını istedik. Ancak aynı zamanda birçok Postgres bulut sağlayıcısı, güvenlik endişeleri nedeniyle veritabanında rastgele Python kodunun çalıştırılmasına izin vermiyor. Bu nedenle, kullanıcıların hem yerleştirme komut dosyalarında hem de veritabanlarını nerede barındıracakları konusunda esnekliğe sahip olmalarını sağlamak için harici Python komut dosyalarını kullanan bir tasarım seçtik.
İşlerin hem performanslı hem de eşzamanlılık açısından güvenli olması gerekir. Eşzamanlılık, işler geride kalmaya başlarsa, zamanlayıcıların sistemin yetişip yükü kaldırmasına yardımcı olmak için daha fazla iş başlatabilmesini garanti eder.
Bu yöntemlerin her birinin nasıl kurulacağını daha sonra inceleyeceğiz, ancak önce Python betiğinin nasıl görüneceğine bakalım. Temel olarak komut dosyası üç bölümden oluşur:
İş kuyruğunu ve blog gönderisini okuyun
Blog yazısı için bir yerleştirme oluşturun
Gömmeyi blog_embedding tablosuna yazın
2. ve 3. adımlar, içinde tanımladığımız embed_and_write
geri çağrısı tarafından gerçekleştirilir.
Önce size kodu göstereceğiz, ardından oyundaki temel unsurları vurgulayacağız:
def process_queue(embed_and_write_cb, batch_size:int=10): with psycopg2.connect(TIMESCALE_SERVICE_URL) as conn: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: cursor.execute(f""" SELECT to_regclass('blog_embedding_work_queue')::oid; """) table_oid = cursor.fetchone()[0] cursor.execute(f""" WITH selected_rows AS ( SELECT id FROM blog_embedding_work_queue LIMIT {int(batch_size)} FOR UPDATE SKIP LOCKED ), locked_items AS ( SELECT id, pg_try_advisory_xact_lock( {int(table_oid)}, id) AS locked FROM ( SELECT DISTINCT id FROM selected_rows ORDER BY id ) as ids ), deleted_rows AS ( DELETE FROM blog_embedding_work_queue WHERE id IN ( SELECT id FROM locked_items WHERE locked = true ORDER BY id ) ) SELECT locked_items.id as locked_id, {self.table_name}.* FROM locked_items LEFT JOIN blog ON blog.id = locked_items.id WHERE locked = true ORDER BY locked_items.id """) res = cursor.fetchall() if len(res) > 0: embed_and_write_cb(res) return len(res) process_queue(embed_and_write)
Yukarıdaki kod parçasında yer alan SQL kodu, hem performans hem de eşzamanlılık açısından güvenli olacak şekilde tasarlandığından inceliklidir, o yüzden üzerinden geçelim:
Öğeleri iş kuyruğundan çıkarma : Başlangıçta sistem, toplu iş kuyruğu boyutu parametresi tarafından belirlenen, iş kuyruğundan belirli sayıda girişi alır. Eşzamanlı olarak çalıştırılan komut dosyalarının aynı kuyruk öğelerini işlemeyi denememesini sağlamak için FOR UPDATE kilidi alınır. SKIP LOCKED yönergesi, eğer herhangi bir giriş şu anda başka bir komut dosyası tarafından işleniyorsa, gereksiz gecikmelerden kaçınarak sistemin beklemek yerine onu atlamasını sağlar.
Blog kimliklerinin kilitlenmesi : Çalışma kuyruğu tablosunda aynı blog_id için yinelenen girişlerin olması ihtimalinden dolayı, söz konusu tablonun basitçe kilitlenmesi yeterli değildir. Aynı kimliğin farklı işler tarafından aynı anda işlenmesi zararlı olabilir. Aşağıdaki potansiyel yarış durumunu göz önünde bulundurun:
İş 1, sürüm 1'i alarak bir blogu başlatır ve ona erişir.
Blogda harici bir güncelleme meydana gelir.
Daha sonra, sürüm 2'yi alarak İş 2 başlar.
Her iki iş de yerleştirme oluşturma sürecini başlatır.
İş 2, blog sürümü 2'ye karşılık gelen yerleştirmeyi kaydederek sona erer.
İş 1, sonuç olarak, yanlışlıkla eski sürüm 1'i içeren sürüm 2'nin üzerine yazar.
Açık sürüm takibi getirilerek bu sorun giderilebilir ancak bu, performans avantajı olmaksızın ciddi bir karmaşıklığa neden olur. Seçtiğimiz strateji yalnızca bu sorunu azaltmakla kalmıyor, aynı zamanda komut dosyalarının eşzamanlı olarak çalıştırılmasıyla gereksiz işlemleri ve boşa giden işleri de önlüyor.
Bu tür diğer kilitlerle olası çakışmaları önlemek için tablo tanımlayıcının önüne eklenen bir Postgres tavsiye kilidi kullanılır. SKIP LOCKED'in daha önceki uygulamasına benzer olan try
çeşidi, sistemin kilitlerde beklemesini önlemesini sağlar. ORDER BY blog_id yan tümcesinin eklenmesi olası kilitlenmelerin önlenmesine yardımcı olur. Aşağıda bazı alternatifleri ele alacağız.
İş kuyruğunu temizleme : Komut dosyası daha sonra başarıyla kilitlediğimiz bloglara ilişkin tüm iş kuyruğu öğelerini siler. Bu kuyruk öğeleri Çoklu Sürüm Eşzamanlılık Denetimi (MVCC) aracılığıyla görünürse, bunların güncellemeleri alınan blog satırında gösterilir. Yalnızca satırları seçerken okunan öğeleri değil, verilen blog kimliğine sahip tüm öğeleri sildiğimizi unutmayın: bu, aynı blog kimliği için yinelenen girişleri etkili bir şekilde ele alır. Bu silme işleminin yalnızca embed_and_write() işlevi çağrıldıktan ve güncelleştirilmiş yerleştirmenin ardından kaydedilmesinden sonra gerçekleştirildiğini unutmamak çok önemlidir. Bu sıra, yerleştirme oluşturma aşamasında komut dosyası başarısız olsa bile hiçbir güncellemeyi kaybetmememizi sağlar.
Blogların işlenmesini sağlama: Son adımda, işlenecek blogları getiriyoruz. Sol birleştirmenin kullanımına dikkat edin: bu, blog satırı olmayan silinmiş öğeler için blog kimliklerini almamıza olanak tanır. Yerleştirmelerini silmek için bu öğeleri izlememiz gerekiyor. embed_and_write
geri çağrısında, silinen blog için nöbetçi olarak yayınlanmış_zamanın NULL olduğunu kullanırız (veya yayından kaldırılır, bu durumda yerleştirmeyi de silmek isteriz).
Sistem zaten öneri kilitleri kullanıyorsa ve çarpışmalardan endişeleniyorsanız, birincil anahtar olarak blog kimliğine sahip bir tablo kullanmak ve satırları kilitlemek mümkündür. Aslında bu kilitlerin başka hiçbir sistemi yavaşlatmayacağından eminseniz bu, blog tablosunun kendisi olabilir (unutmayın, bu kilitlerin yerleştirme işlemi boyunca tutulması gerekir, bu biraz zaman alabilir).
Alternatif olarak, sırf bu amaç için bir blog_embedding_locks tablosuna sahip olabilirsiniz. Bu tabloyu oluşturmayı önermedik çünkü alan açısından oldukça israfa yol açabileceğini düşünüyoruz ve tavsiye niteliğinde kilitler kullanmak bu yükü ortadan kaldırır.
Bu blog yazısında, yerleştirme oluşturma hizmetinin potansiyel kesinti sürelerini etkili bir şekilde yöneterek dayanıklılığa sahip bir sistemi nasıl oluşturduğumuzun perde arkasına bir bakış sunduk. Tasarımı, yüksek orandaki veri değişikliklerini yönetme konusunda ustadır ve artan yükleri karşılamak için eş zamanlı yerleştirme oluşturma süreçlerini sorunsuz bir şekilde kullanabilir.
Dahası, verileri PostgreSQL'e aktarma ve arka planda gömme oluşturmayı yönetmek için veritabanını kullanma paradigması, veri değişiklikleri sırasında gömme bakımını denetlemek için kolay bir mekanizma olarak ortaya çıkıyor. Yapay zeka alanındaki sayısız demo ve eğitim, belgelerden verilerin ilk oluşturulmasına odaklanıyor ve geliştikçe veri senkronizasyonunun korunmasıyla ilişkili karmaşık nüansları gözden kaçırıyor.
Ancak gerçek üretim ortamlarında veriler her zaman değişir ve bu değişimlerin izlenmesi ve senkronize edilmesinin karmaşıklığıyla boğuşmak önemsiz bir çaba değildir. Ancak bir veritabanı bunu yapmak için tasarlanmıştır! Neden sadece kullanmıyorsunuz?
Matvey Arye'nin yazdığı.