paint-brush
PostgreSQL Verileri için Sağlam Bir Vektör Gömme Oluşturma Sistemi Mimarisiile@timescale
8,028 okumalar
8,028 okumalar

PostgreSQL Verileri için Sağlam Bir Vektör Gömme Oluşturma Sistemi Mimarisi

ile Timescale16m2023/11/14
Read on Terminal Reader

Çok uzun; Okumak

"Verileri PostgreSQL tablolarına sorunsuz bir şekilde gömmek için son teknoloji bir çözüm olan PgVectorizer dünyasına dalın. Basitlik, dayanıklılık ve yüksek performans sağlamak için yapılan karmaşık teknik tasarım kararları ve yapılan ödünler hakkında bilgi edinin. Bu sistem, Zaman Ölçeği ile donatılmıştır. Vector Python kitaplığı, geliştiricilere verileri zahmetsizce senkronize etme ve güncelleme olanağı vererek anlamsal aramadan üretken yapay zekaya kadar PostgreSQL'e dayalı uygulamalara yeni bir verimlilik düzeyi getiriyor."
featured image - PostgreSQL Verileri için Sağlam Bir Vektör Gömme Oluşturma Sistemi Mimarisi
Timescale HackerNoon profile picture


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.


Önceki bir blog gönderisinde, yerleştirmeleri oluşturma ve yönetme sürecine ilişkin adım adım kılavuzun ayrıntılarını vermiştik PostgreSQL'de bulunan veriler için PgVektörleştirici – PostgreSQL'de bulunan veriler için basit ve esnek yerleştirme oluşturma sistemimiz. Örnek olarak PostgreSQL veritabanında depolanan verilere sahip bir blog uygulamasını kullanarak Python, LangChain ve Timescale Vector kullanarak güncel vektör yerleştirmelerin nasıl oluşturulacağını ve tutulacağını ele aldık.


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.

PostgreSQL Verileri için Yüksek Performanslı Vektörleştirici Tasarımı (PgVectorizer)

Öncelikle kurduğumuz sistemin nasıl çalışacağını anlatalım. Zaten okuduysanız bu bölümü atlamaktan çekinmeyin. PgVectorizer yazısı .


sistem görünümü

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: Zaman Ölçeği Vektörü Python kütüphanesi PostgreSQL kullanarak vektör verileriyle çalışmaya yönelik bir kitaplık. İşi başarıyla tamamlamak için bu kitaplığa yeni işlevler eklendi: PgVektörleştirici —PostgreSQL verilerinin yerleştirilmesini mümkün olduğunca basit hale getirmek için.


İşte kararlaştırdığımız mimari:


Verileri mevcut bir PostgreSQL tablosuna gömmek için basit ve dayanıklı bir sistem için referans mimarisi. Bir blog uygulamasının örnek kullanım durumunu kullanıyoruz, dolayısıyla yukarıdaki tablo adlarını kullanıyoruz.


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:


  • Blog_work_queue tablosundaki bir satırı okuyun ve kilitleyin
  • Blog tablosundaki ilgili satırı okuyun
  • Blog satırındaki veriler için bir yerleştirme oluşturma
  • Gömmeyi blog_embedding tablosuna yazın
  • blog_work_queue tablosundaki kilitli satırı silin


Bu sistemi çalışırken görmek için kullanım örneğine bakın. Bu blog yazısında OpenAI, LangChain ve Timescale Vector'u kullanarak PostgreSQL tablosunda yerleştirmeler oluşturun ve sürdürü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.


Blog_work_queue tablosuyla blog tablosundaki değişikliği izleyin

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.


Alternatif 1: blog_work_queue tablosu için birincil veya benzersiz bir anahtar uygulayın.

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.


Alternatif 2: Güncel bir yerleştirmenin mevcut olup olmadığını takip etmek için blog tablosuna bir sütun ekleyerek yapılması gereken işi takip edin.

Ö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.


Alternatif 3: Yerleştirmeleri doğrudan tetikleyicide oluşturun.

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!


Yerleştirmeleri oluşturma

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. LangChain ve LlamaIndex.


  • 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. PgVectorizer blog yazısı . Öyleyse iş kuyruğunu nasıl işlediğimize daha derinlemesine bakalım.

İş kuyruğunu işle

Ö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).

Alternatif 4: Başka bir tablo kullanarak öneri kilitlerini kullanmaktan kaçının.

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.

Sonuç ve Sonraki Adımlar

PgVectorizer'ı tanıttık ve PostgreSQL'de depolanan verilerden vektör yerleştirmeleri oluşturma konusunda uzman bir sistemin ana hatlarını çizdik. ve bunları otomatik olarak güncel tutuyoruz. Bu mimari, yerleştirmelerin sürekli gelişen verilerle senkronize kalmasını sağlayarak ekleme, değiştirme ve silme işlemlerine sorunsuz bir şekilde yanıt verir.


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?


İşte öğrenme yolculuğunuza devam etmek için bazı kaynaklar :



Matvey Arye'nin yazdığı.