Vector Store Python Tanpa NumPy
Related articles semantic di VPS 1 GB ternyata cukup pakai dictionary Python dan cosine loop, tanpa numpy dan tanpa vector database baru.
Waktu ngerjain commit f9d508d, saya sempat mikir: kok nggak pakai pgvector atau install numpy sekalian?
Fitur yang mau masuk cukup serius. Artikel perlu punya embedding, endpoint related, dan hasil semantic search. Embedding yang dipakai juga bukan angka asal-asalan. bge-m3 menghasilkan vector 1024 dimensi, mendukung sequence sampai 8192 token, dan emang direkomendasikan untuk pipeline retrieval RAG hybrid.[2]
Insting awal saya: berarti butuh vector database.
Tapi konteks aplikasinya kecil. VPS cuma punya RAM 1 GB, aplikasi jalan dengan satu uvicorn worker, dan jumlah artikel baru sekitar 80. Kalo saya menambah database vector atau dependency besar untuk kebutuhan sekecil itu, saya cuma sedang menambah komponen yang harus dirawat.
Jadi saya tahan dulu proses install numpy.
Vector store-nya cuma dictionary
Implementasinya berakhir di file api/app/modules/articles/infrastructure/vector_store.py.
Nama VectorStore terdengar lebih besar daripada isinya. Data disimpan dalam dictionary in-process, dengan slug artikel sebagai key dan row metadata sebagai value. Cosine similarity dihitung pakai Python murni. Nggak ada NumPy, nggak ada service baru, dan nggak ada migration database vector.
Kira-kira bentuk perhitungannya sesederhana ini:
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if not norm_a or not norm_b:
return 0.0
return dot / (norm_a * norm_b)
Itu bukan library vector search. Cuma loop atas angka.
Setiap artikel yang masuk lewat intelligence ingest langsung di-upsert ke store. Saat ada request related, store mencari artikel dengan cosine score minimal 0.30, lalu mengurutkannya berdasarkan kemiripan.
Ada satu pengecualian yang sengaja dibuat: artikel dalam translation_group yang sama tidak boleh muncul sebagai related. Jadi artikel versi EN nggak akan merekomendasikan versi ID-nya sendiri. Secara semantic, keduanya pasti mirip. Secara pengalaman membaca, rekomendasi kayak itu nggak berguna.
Store juga punya ensure_fresh. Isi dictionary di-reload setelah TTL 300 detik. Tujuannya bukan mengejar sistem cache yang rumit, tapi mencegah data baru hanya terlihat oleh proses ingest sementara isi store lama terus dipakai.
Karena aplikasi cuma jalan dengan satu worker, model ini masih masuk akal. Kalo nanti worker bertambah atau proses mulai tersebar di beberapa instance, asumsi ini selesai berlaku.
Validasi embedding dan metadata
Endpoint intelligence ingest sekarang menolak embedding yang ukurannya bukan tepat 1024. Nilai NaN dan inf juga ditolak. Kalo validasi gagal, ValueError diterjemahkan menjadi HTTP 422.
Validasi ini sengaja dilakukan di boundary endpoint. Vector yang salah lebih baik gagal saat ingest daripada diam-diam masuk ke store dan menghasilkan score aneh ketika dipakai mencari related.
Payload-nya juga bisa membawa tldr, dengan batas maksimal 1200 karakter. Embedding dan TLDR disimpan lewat metadata artikel. Embedding memakai key internal _emb_vec, supaya jelas bahwa data itu bukan metadata yang ditampilkan kayak SEO title atau description.
Bagian repository ikut berubah. Ada method update_meta() yang melakukan merge per-key, bukan mengganti seluruh object metadata.
Ini penting karena writer intelligence nggak boleh menghapus metadata SEO yang udah ada. Kalo payload baru cuma punya _emb_vec dan tldr, field SEO lain tetap utuh. Tanpa merge, satu proses ingest bisa menghapus kerja writer lain secara diam-diam.
MariaDB sendiri tetap dipakai untuk penyimpanan utama. FULLTEXT search masih punya batasan: fitur itu bekerja pada kolom CHAR, VARCHAR, dan TEXT.[1] Embedding tidak dipaksa masuk ke sana. Data vector tinggal di metadata dan di-load ke store saat dibutuhkan.
Cukup untuk ukuran aplikasi ini
Delapan puluh artikel dengan vector 1024 float emang bukan dataset besar. Bahkan kalo setiap artikel menyimpan vector penuh, jumlah operasi cosine saat mencari related masih kecil untuk proses Python sederhana.
Di sini saya lebih memilih implementasi yang membosankan. Satu dictionary, satu loop, satu TTL, dan satu worker. Tidak ada pgvector yang harus dioperasikan, tidak ada NumPy yang harus dimuat di VPS 1 GB, dan tidak ada abstraction tambahan sebelum benar-benar dibutuhkan.
Bukan berarti vector database atau NumPy selalu berlebihan. Kalo artikel udah ribuan, request related mulai padat, atau deployment memakai banyak worker dan instance, pencarian linear in-process akan jadi batas yang jelas. Saat itu barulah masuk akal memindahkan vector ke storage bersama atau memakai index khusus.
Untuk sekarang, VectorStore ini bukan pengganti database vector. Ia cuma lapisan kecil yang cukup untuk kebutuhan semantic related di aplikasi yang masih kecil.
Saya pribadi pilih solusi ini. Embedding-nya tetap serius karena memakai dimensi resmi bge-m3, tapi cara mengonsumsinya dibuat semurah mungkin untuk infrastruktur yang tersedia.[2]
Artikel terkait: cerita endpoint neighbors lama yang mengambil related articles secara paralel dan LLM router di container.