Semantic Search on a 1GB VPS Without a Vector DB
Pure-Python cosine, vectors in MariaDB postmeta, embeddings computed locally. Semantic features with no new service on a small server.
TL;DR
Daripada pasang vector database di VPS 1GB yang pasti kehabisan RAM, penulis hitung embedding bge-m3 secara lokal lalu simpan vektornya sebagai JSON di MariaDB postmeta. Cosine similarity dijalankan pakai Python murni tanpa numpy, tetap cepat untuk katalog puluhan artikel sekaligus jadi dup-check untuk cron penulis AI. Skema ini cukup sampai ribuan artikel; setelah itu baru pertimbangkan sqlite-vec atau pgvector.
Ever opened a terminal, typed htop, and stared at RAM usage hitting 850MB out of 1GB? That was the "reality check" moment on my adityo.web.id server.
This blog runs on a lightweight stack: FastAPI, MariaDB, and Redis on the backend, a Next.js frontend on Vercel. The challenge was simple: I wanted a semantic "related articles" recommendation feature plus an automatic dup-check, so the AI writer cron wouldn't write about topics it had already covered.
The solution in almost every modern tutorial? Install a vector database (Qdrant, Milvus, or pgvector), add numpy/scipy, and run an embedding model on the server. On a 1GB VPS, that scenario dies instantly. The embedding model alone needs hundreds of MBs to GBs, numpy adds tens of MBs of RSS, and one extra service means the memory left for FastAPI is gone.
So I chose a more pragmatic architecture. Here is the breakdown.
Split the load: local embeddings, pure-Python cosine
The key is separating heavy computation from lightweight storage.
One, embeddings on a local machine. Vectors are not computed on the VPS. I compute them locally using the bge-m3 model via Ollama. It is multilingual (dense, sparse, colbert), 1024 dimensions, 8192 max sequence, a great fit for a mix of Indonesian and English. The VPS only stores the finished vectors as JSON in the MariaDB postmeta table.
Two, pure-Python cosine similarity. No numpy. For 70 to 100 articles at 1024 dimensions, we are talking about roughly 100 thousand float operations per query. It finishes in under a millisecond to tens of milliseconds with a plain Python loop. No ANN index needed for a catalog this small.
def cosine_similarity(v1: list[float], v2: list[float]) -> float:
dot = sum(a * b for a, b in zip(v1, v2))
n1 = sum(a * a for a in v1) ** 0.5
n2 = sum(b * b for b in v2) ** 0.5
return dot / (n1 * n2) if n1 and n2 else 0.0
# Structure in MariaDB postmeta:
# meta_key = 'embedding_vector', meta_value = '[0.012, -0.045, ..., 0.089]' (JSON)
Three, thresholds and protection. Similarity at 0.90 or above counts as a strong duplicate, 0.75 to 0.90 is a gray zone that needs manual calibration, below that is safe. The writer cron checks new topics against existing article vectors first. Public endpoints get rate limiting and a disk cache for LLM answers so the VPS CPU and RSS stay protected. Heavy computation must never be triggerable without a guard.
When this scheme breaks
Honestly: this approach breaks when your catalog reaches thousands of articles, vector dimensions bloat, or queries per second gets high. At that point, a real vector database finally makes sense.
If you are near that edge but still want simplicity, there is a lightweight middle ground: sqlite-vec, claimed as "An extremely small, 'fast enough' vector search SQLite extension that runs anywhere!" A good example of vector search via an extension instead of a memory-hungry full service.
Meanwhile, if your database is already PostgreSQL, pgvector is the elegant way out: "Open-source vector similarity search for Postgres". But this blog runs MariaDB; adopting pgvector would mean an unnecessary database migration. So JSON in MariaDB plus pure Python stays the sweet spot.
Stop over-engineering small blogs
There is a dangerous trend in web development: treating "cutting-edge technology" cosmetics as a measure of architectural quality. That is not a valid reason to add a new service to a 1GB server.
For small to medium blogs, a vector database is over-engineering. Its operational complexity (monitoring, backups, RAM usage) far outweighs the value it delivers.
My final call stands: vectors live in postmeta, cosine runs at the application level in pure Python, heavy embeddings are offloaded to a local machine. The result: a working semantic feature, SEO protected from duplicate content, and a 1GB VPS that still breathes.