Skip to content
Consultation

A Python Vector Store Without NumPy

Adityo Guni Waluyo

The semantic related layer for 80 articles turned out to be a Python dict and a cosine loop, no numpy and no vector database.

I had the pip install numpy command half-typed in my terminal. Then I stopped and counted: 80 articles times 1024 floats per embedding. That's roughly 320 KB of floats, smaller than a single screenshot on this blog. Why was I about to pull in a heavy compiled dependency for this?

The project already generates embeddings via bge-m3, 1024 dimensions and 8192 sequence length [2], through a remote model. The hard part (calling the model, validating dimensions, storing the vectors) was done. What I needed on the consumption side was just: given one article, find the closest others. That's a cosine similarity search over a handful of rows.

So I wrote a Python class that wraps a dict.

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)

That is the whole scoring function. The store around it is about 40 lines; it reloads rows from the database on first access or when the 300-second TTL expires. Cosine similarity is a loop over two lists: sum(a*b for a,b in zip(vec_a, vec_b)) divided by the product of magnitudes. No vectorized computation, no SIMD, no library. At 80 rows the loop has almost nothing to chew on. There is nothing to optimize yet.

The related() method takes a slug, grabs its embedding, runs cosine against every other row, filters out anything below 0.30 score, and excludes articles sharing the same translation_group. That last part matters: the system produces English and Indonesian versions of each article as twins. You don't want the EN article recommending its own ID translation as "related". That's not a relation, that's a copy.

One design choice I'm happy with: update_meta() in the repository merges per-key instead of replacing the whole meta JSON column. The intelligence system writes _emb_vec and tldr into meta. But other systems write SEO fields into the same column. A wholesale replace would silently wipe those. The merge means each writer owns its keys and ignores the rest.

The ingest endpoint validates two things strictly: embeddings must be exactly 1024 floats, and every value must be finite: no NaN, no inf. Invalid input returns 422. The tldr gets capped at 1200 characters. These are the only gates. If the embedding model produces garbage dimensions or the LLM hallucinated a tldr with Unicode edge cases, the endpoint refuses it rather than polluting the store.

I also exposed /related as a route and a tldr field in the article meta response. The tldr is just a string the LLM generates during ingest. Nothing fancy, but it lets the frontend show a summary without hitting the LLM again.

The contrast here is intentional. The embedding side is serious: bge-m3 is a proper multilingual retrieval model [2], validated at exactly 1024 dimensions, with finite-value checks. The consumption side is deliberately cheap: a dict, a cosine loop, a TTL. The old related-articles flow leaned on tags alone, with parallel list fetches I described earlier. Precomputed vectors replace that guesswork.

I did consider pgvector or a dedicated vector database. Both solve a real problem at scale. But "scale" here means 80 articles on a 1 GB VPS running a single uvicorn worker. A vector database adds operational complexity (another service, memory overhead, connection pooling) for a dataset that fits in a Python dictionary with room to spare. If the article count hits 10,000, I'll revisit. For now, the lazy solution is the correct one.

MariaDB's FULLTEXT index, for what it's worth, only works on CHAR, VARCHAR, and TEXT columns [1], so it can't index JSON or binary vector data. So there was never a "just use a database index" option for semantic similarity anyway. The dict-and-loop approach isn't a compromise. It's the simplest thing that actually works.

Sources

Related articles