NOTES & IDEAS
"Related Posts" That Actually Understand the Post
Most "related posts" widgets just match shared tags or title words, so two articles about the same thing in different words never find each other. Here's how I made mine match by meaning, explained with a librarian.
Most "related posts" widgets are quietly fibbing. They match on shared tags or words in the title - so an article called "Scaling Postgres" never shows up next to "Database performance at scale," even though they're about the exact same thing. Different words, same meaning, and plain keyword matching is blind to it.
For Writer's Heaven I wanted related posts that match on meaning, not spelling. Here's the idea, with a librarian.
The whole idea: shelve books by what they're about
Picture two librarians.
The first shelves books by the words on the spine. Ask for something like "Scaling Postgres" and they'll only ever hand you books with those exact words on the cover. The book about "Database performance at scale" is two aisles away, never suggested, even though it's basically the same book.
The second librarian shelves by what each book is actually about. They've arranged the whole library so that books on similar topics physically stand near each other - regardless of the words on the spine. Now "find me related books" becomes dead simple: just grab the ones standing nearest on the shelf.
That second librarian is what I built. The trick is teaching a computer to give each article a spot based on its meaning, so similar articles end up close together.
How a computer gives an article a "spot"
You run the article's text through an embedding model - an AI that reads the text and turns it into a long list of numbers. Think of those numbers as coordinates: an address on a giant map of meaning. Articles about the same topic get addresses close together; unrelated ones land far apart. "Similar meaning" literally becomes "short distance."
A vector database is the tool that stores all those addresses and can instantly answer "which addresses are nearest to this one?" I used Qdrant.
There are only two things I ever ask it to do:
- Pin an article to its spot on the map (when it's created or edited).
- Search for the nearest pins to a given spot (to find related posts).
Setting up the map - only when needed, and never breaking the page
I create the "map" (Qdrant calls it a collection) the first time it's used, sized to match the embedding's number of coordinates, and told to measure closeness by direction (the right way to compare text meaning):
private async ensureCollection(dim: number): Promise<boolean> {
if (this.collectionReady) return true;
try {
const { collections } = await this.client.getCollections();
if (!collections.some((c) => c.name === COLLECTION)) {
await this.client.createCollection(COLLECTION, {
vectors: { size: dim, distance: 'Cosine' },
});
}
this.collectionReady = true;
return true;
} catch (err) {
this.logger.warn(`Qdrant ensureCollection failed: ${err.message}`);
return false; // related posts are a nice-to-have, not a must-have
}
}
Notice the return false when it fails. If the map service is down, search just
returns nothing and the page renders fine without a "related posts" box. A
suggestion feature should never take down the whole page.
The gotcha that cost me an afternoon
My posts live in MongoDB, where each one's ID is a 24-character string of hex like
6512a.... But Qdrant only accepts a plain number or a UUID as a pin label - it
flat-out rejects a raw Mongo ID.
I needed a label that's always the same for the same post, so re-pinning an edited post overwrites its old spot instead of creating a duplicate. So I pad the 24-character ID out to 32 and reshape it into the UUID format Qdrant wants:
static pointId(objectId: string): string {
const hex = objectId.padEnd(32, '0').slice(0, 32);
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-` +
`${hex.slice(16,20)}-${hex.slice(20,32)}`;
}
Same post ID in, same label out, every time. I also tuck the original post ID into the pin's notes, so a search result can point straight back to a real post without having to undo this transformation.
The search (and the "itself" trap)
Finding related posts is just "give me the nearest pins" - with one catch: the closest pin to any article is always itself (it's a perfect match with itself), so I have to drop it:
async search(vector: number[], limit: number, excludeObjectId?: string) {
if (!(await this.ensureCollection(vector.length))) return [];
const excludeId = excludeObjectId ? QdrantService.pointId(excludeObjectId) : null;
const results = await this.client.search(COLLECTION, {
vector,
limit: limit + (excludeId ? 1 : 0), // grab one extra to absorb the self-match
with_payload: true,
});
return results
.filter((r) => r.id !== excludeId) // throw out the article itself
.slice(0, limit)
.map((r) => ({ blogId: String(r.payload?.blogId ?? ''), score: r.score }))
.filter((h) => h.blogId);
}
The limit + 1 matters: if you want 5 related posts and the article itself is in the
results, asking for exactly 5 and then removing self leaves you with 4. Asking for
one extra keeps you at 5.
Why bother with a whole separate database?
You could store the coordinates in a normal column and compare them yourself in code - but that means loading every article and measuring all of them on every request. That's fine for 50 posts and falls apart at 50,000. A vector database keeps a smart index so "find the nearest" stays fast as the library grows. You're adding one piece of infrastructure to get search that scales - for a site that plans to grow, that's the right trade.
Takeaway
Matching text by meaning turns a word-matching problem into a distance problem: give every article a spot on a map of meaning, then ask for its nearest neighbors. The concept is clean; the fiddly bits are all in the seams - matching the coordinate count to your map's setup, reshaping your existing IDs into labels the map accepts, and remembering the nearest thing to anything is itself. Get those right, and "related posts" finally relate to the post, not just its tags.