Searching by "Meaning," Not "Surface Text" — Implementing Semantic Search Without Adding a Dedicated Vector DB
Introduction
I added title search to a self-built, all-in-one platform for video and music. Ordinary keyword search was already there, and I added “search by meaning” on top of it — without adding a dedicated vector database.
The story of driving up raw search accuracy with numbers is in Improving RAG Retrieval, Measurement-Driven. This article is about how I implemented “search by meaning” lightly.
Here’s what it actually looks like on screen. Switch to “Search by meaning (AI)” and enter the mood you’re looking for as a plain sentence — something like “a movie to watch alone on a rainy day.” What comes back isn’t titles containing that phrase, but titles close in meaning.

Keyword search can only find “surface text”
Keyword search returns titles that contain the words you typed. So if you search “a bittersweet story of parting,” a masterpiece whose description doesn’t happen to contain that phrase won’t come up, no matter how well its content actually matches.
[Keyword search] "bittersweet parting" ─▶ only hits titles that "contain" that phrase (surface match)
→ titles that don't literally say "bittersweet" get missed
What I wanted to search by wasn’t surface text — it was meaning.
Turn both titles and queries into “meaning vectors”
That’s where embedding comes in. An embedding is a title’s or sentence’s “meaning” converted into a sequence of a few hundred numbers (a vector). Sentences close in meaning land close together as vectors, too.
Turn each title’s description into a vector, turn the search query into a vector too, and return results ordered by how close the vectors are (= how close the meaning is). That’s semantic search. For vectorization, I run a model that converts text into embeddings (bge-m3) locally on Ollama, on my own machine.
[Semantic search] "bittersweet parting" ─▶ vectorize ─▶ return titles close in meaning (meaning, not surface text)
To measure “closeness,” I use cosine similarity — a method that measures closeness by how aligned the direction of two vectors is; the more aligned they are, the closer they’re considered “in meaning.”
At this scale, you don’t need a dedicated vector DB
Mention semantic search and people usually reach for adding a dedicated vector database — for PostgreSQL, that means adding pgvector (an extension that adds vector search to the DB). It’s the standard choice, but it’s one more dependency and one more thing to operate.
But the catalog I’m dealing with is about 1,000 titles. At this scale, a dedicated DB isn’t necessary. At startup, compute the vector for every title once and keep it in memory, then just compute cosine similarity inside the app on every search — that’s plenty fast.
At startup: vectorize every title once, keep it in memory as an "index" (~1,000 titles, bge-m3)
Query sentence ─▶ vectorize ─┐
├─▶ cosine similarity in memory ─▶ return in order of closeness
Index (vectors of all titles) ─┘
│
└─ the same index is reused for ─┬─ search (query → titles)
├─ similarity (title → similar titles)
└─ recommendations (average vector of history → titles)
※ No dedicated vector DB (pgvector, etc.) has been added
In code, the contents of the index are quite plain.
// Load every title's vector into memory at startup. No dedicated vector DB is used.
class EmbeddingIndexService {
private Map<Integer, float[]> vectors; // title ID → semantic vector
// Return titles close to the query vector, ordered by closeness
List<Scored> topSimilar(float[] query, int limit) {
return vectors.entrySet().stream()
.map(e -> new Scored(e.getKey(), cosine(query, e.getValue()))) // closeness of direction = closeness of meaning
.sorted(comparingDouble(Scored::score).reversed())
.limit(limit)
.toList();
}
}
And the pleasant surprise is that this one index can be reused everywhere. Feed in a query → “search.” Feed in a title → “titles like this one.” Feed in the average vector of a user’s watch history → “recommended for you” — the same topSimilar covers all of it.
”Close in meaning” doesn’t mean “okay to show”
There’s one hole in semantic search that’s known from the start. Embeddings only know “closeness in meaning” — they know nothing about differences in kind (video vs. music) or business rules like “is it okay to show this.” So just because something is close, a title from a different category can sneak in.
This problem comes bundled with semantic search from the moment you add it, and you need to screen the retrieved results at the exit. That story is in The trap where a different category sneaks into vector search; the story of screening retrieved results by relevance before handing them off is in Placing a Small Relevance Critic in Front of RAG.
Being able to start light was itself the key insight of this design. Before setting up a dedicated DB, “search by meaning” stands up perfectly well with in-memory storage and plain cosine similarity, right at hand. You can move to a dedicated DB later, once it’s needed, once the scale has grown. Don’t reach for heavy tooling from the start — that’s the path I chose for this feature.
Related articles
- The story of driving up search accuracy with numbers is in Improving RAG Retrieval, Measurement-Driven.
- The trap of a different category that’s merely close in meaning sneaking in is covered in The trap where a different category sneaks into vector search.
- The story of repurposing this index for “recommendations,” never leaving members with no history with an empty screen, is in Building Recommendations from the “Centroid Vector” of Watch History.
- The story of drawing on this index with a “titles like this one” face, used for browsing on the detail page, is in Don’t Let a Title’s Detail Page Be a Dead End — Sending Viewers to the Next Title with “For Fans of This Title”.