{"slug": "finding-related-posts-with-embeddings", "title": "Finding related posts with embeddings", "summary": "Dries Buytaert, founder and CTO of Acquia, added a related-posts feature to his blog using embeddings from BAAI's bge-base-en-v1.5 model, which returns 768-dimensional vectors per post. He applies mean-centering to correct for anisotropy, as raw cosine similarity between random posts on his blog averages 0.64, making unrelated posts appear similar.", "body_md": "I added a new feature to my blog: a list of related posts at the bottom of each post. I implemented it using embeddings, and this note documents how.\n\nI looked at how other content management systems identify related posts: most use shared tags, backlinks, manual curation, or embeddings. I chose embeddings, which compare the meaning of each post, because they can uncover connections without shared tags, existing links, or manual curation.\n\nAn embedding model reads text and returns a vector: a long list of numbers. The model I use, [ bge-base-en-v1.5](https://huggingface.co/BAAI/bge-base-en-v1.5) from the Beijing Academy of Artificial Intelligence (BAAI), returns 768 numbers for each post. I started with a smaller model that returns 384 numbers and moved up because the matches were better. BAAI's own benchmarks point the same way, though the gap is modest.\n\nYou can think of those 768 numbers as coordinates in a high-dimensional meaning space, where each dimension captures some pattern the model learned from text. For one of my posts, the first handful of those coordinates looks something like this:\n\n```\n[ 0.021, -0.045, 0.038, -0.012, 0.007, ..., 0.019 ]   (768 numbers total)\n```\n\nConceptually, it is a bit like tagging each blog post with hundreds of auto-generated tags, except that these tags are unnamed (they are just numbers) and distributed (meaning is spread across all of them). Together, the 768 numbers place the post near other posts with similar meaning.\n\nThis is what lets two posts match even when they use different words. During training, the model learns that certain words and phrases appear in similar contexts or play similar roles, so it places them near each other in the space. It does not need \"car\" and \"automobile\" to share any letters to learn that they are used in related ways.\n\nOnce every post has an embedding vector, the next question is how to compare them. This is where I had to dust off a little math. Fortunately, it turned out to be mostly high-school math: averages, angles, and multiplication.\n\nThe standard way to compare two vectors is [cosine similarity](https://en.wikipedia.org/wiki/Cosine_similarity). Imagine each vector as an arrow pointing away from the origin. Cosine similarity measures the angle between two of these arrows and then takes the cosine of that angle, which is where the name comes from.\n\nTwo arrows pointing almost the same way sit at a small angle, and the cosine of a small angle is close to 1, so the posts are related. As the arrows spread apart, the cosine falls: at a right angle it is 0, and for arrows pointing in opposite directions it drops to -1, so unrelated posts score closer to 0 or even negative.\n\nIn practice, these raw cosine values can be misleading, because embedding models rarely spread their vectors evenly in every direction. They tend to pack most vectors into a narrow cone, a property called [anisotropy](https://arxiv.org/abs/1907.12009), so the scores cluster in a high, narrow band. On my blog, the raw cosine similarity between two randomly chosen posts is almost always between 0.5 and 0.75, with a median of 0.64.\n\nThe practical effect is that almost any two posts look somewhat similar. An old post about [the founding of Acquia](https://dri.es/acquia-first-decade-the-founding-story) shows the problem. It covers a lot of ground: Drupal, my PhD, Red Hat and IBM backing Linux, venture capital, and personal reflection. Because it touches so many subjects, its vector sits close to the average of all my posts, and it scored high against almost the entire archive. Its best match scored 0.876, and its hundredth best still scored 0.770.\n\nAnisotropy has several known fixes, from lightest to heaviest. The lightest is *mean-centering*, which is what I use and what the rest of this section explains.\n\n[All-but-the-top](https://arxiv.org/abs/1702.01417) removes the average and the next few strongest directions. [Whitening](https://arxiv.org/abs/2103.15316) stretches the space so every direction carries equal weight (the name comes from white noise). I have not tried these others. Mean-centering is one subtraction per vector with no matrix algebra, which keeps the code plain PHP, and it was enough.\n\nYou compute the average vector across all posts and subtract it from every post's vector. Subtracting the average vector from each post removes what all posts have in common, so what remains is what makes each post distinct. That average points down the middle of the cone, the direction my whole blog tends to lean.\n\nA modern model like `bge-base-en-v1.5`\n\nalready suffers less from anisotropy than older or simpler encoders: it is trained with contrastive learning, which pushes unrelated texts apart, and version 1.5 was tuned specifically to spread out its similarity scores. On my corpus, centering still made the scores much more useful.\n\nAn example might help. Imagine three posts with only two numbers each instead of 768:\n\n```\nA = (0.90, 0.10)\nB = (0.85, 0.80)\nC = (0.80, 0.75)\n```\n\nAt first glance, all three posts look somewhat similar. In every post the first number is high and close to the others (0.90, 0.85 and 0.80), so it dominates the comparison. But a number that barely changes from post to post tells you little about how they differ, so that first number is not very useful.\n\nThe average (mean) of the three vectors is:\n\n```\nmean = (0.85, 0.55)\n```\n\nNow subtract that average from each post:\n\n```\nA = ( 0.05, -0.45)\nB = ( 0.00,  0.25)\nC = (-0.05,  0.20)\n```\n\nNow the picture is clearer. B and C both have a positive second number, so they point in roughly the same direction; A's second number is negative, so it points somewhere else.\n\nBefore centering, everything looked similar. After centering, the comparison focuses on what is different from the average.\n\nAfter centering, each vector has a length as well as a direction. Length says how far a post sits from the average, and direction says in what way it differs.\n\nI want to rank posts by what they are about, not by how unusual they are, so only the direction matters. Hence, we normalize each vector by dividing it by its own length, which scales it to length 1 and moves it onto the unit circle (or, in 768 dimensions, the unit sphere), leaving only its direction.\n\nIt also makes the comparison cheaper. Cosine similarity is normally the dot product divided by the product of the two vectors' lengths. If both vectors have length 1, that denominator is 1 × 1 = 1, so the expression reduces to the dot product alone: multiply the two lists number by number, then add the results.\n\nUsing the same example, the centered vectors for B and C are:\n\n```\nB = ( 0.00, 0.25)\nC = (-0.05, 0.20)\n```\n\nFirst, normalize each vector to length 1. A vector's length is the square root of the sum of its squared numbers (good old Pythagoras, only with more numbers). B has length √(0.00² + 0.25²) = 0.25, while C has length √((-0.05)² + 0.20²) ≈ 0.206, so dividing each vector by its own length gives:\n\n```\nB ≈ ( 0.00, 1.00)\nC ≈ (-0.24, 0.97)\n```\n\nThen take the dot product:\n\n```\n(0.00 × -0.24) + (1.00 × 0.97) = 0.97\n```\n\nThat is a strong match: the closer the score is to 1, the more the two posts point in the same direction. B and C are nearly aligned.\n\nA, after normalization, points mostly downward. Next to B:\n\n```\nA ≈ ( 0.11, -0.99)\nB ≈ ( 0.00,  1.00)\n```\n\nMultiplying them the same way:\n\n```\n(0.11 × 0.00) + (-0.99 × 1.00) = -0.99\n```\n\nThat is not a match at all.\n\nThe production code does the same arithmetic, just with 768 numbers per post instead of two:\n\n``` php\npublic static function center(array $raw): array {\n  if ($raw === []) {\n    return [];\n  }\n  $mean = array_fill(0, count(reset($raw)), 0.0);\n  foreach ($raw as $vector) {\n    foreach ($vector as $i => $value) {\n      $mean[$i] += $value;\n    }\n  }\n  $count = count($raw);\n  foreach ($mean as $i => $sum) {\n    $mean[$i] = $sum / $count;\n  }\n  $centered = [];\n  foreach ($raw as $nid => $vector) {\n    $norm = 0.0;\n    foreach ($vector as $i => $value) {\n      $vector[$i] = $value - $mean[$i];\n      $norm += $vector[$i] * $vector[$i];\n    }\n    // A vector sitting exactly on the mean centers to zero; fall back to 1.0\n    // so the division below never hits a zero norm.\n    $norm = sqrt($norm) ?: 1.0;\n    foreach ($vector as $i => $value) {\n      $vector[$i] = $value / $norm;\n    }\n    $centered[$nid] = $vector;\n  }\n  return $centered;\n}\n\npublic static function topMatches(array $source, array $pool, int $self): array {\n  $scores = [];\n  foreach ($pool as $nid => $vector) {\n    if ($nid === $self) {\n      continue;\n    }\n    $similarity = 0.0;\n    foreach ($source as $i => $value) {\n      $similarity += $value * $vector[$i];\n    }\n    $scores[$nid] = $similarity;\n  }\n  arsort($scores);\n  return array_keys(array_slice($scores, 0, 3, TRUE));\n}\n```\n\nWhile my explanation was long, both PHP methods are relatively short. In `center()`\n\n, each vector has the corpus mean subtracted, then is divided by its own length. In `topMatches()`\n\n, I calculate the cosine similarity between one post and every other post, then keep the three highest.\n\nYou might expect a vector database to replace all of this. It would replace some of it: storing a vector and asking for the closest three would remove `topMatches()`\n\n, but it would not remove `center()`\n\n. Centering is optional, but it meaningfully improved my results.\n\nA vector database likely makes centering harder. Today I store raw vectors and subtract the average when I compare them, so a new post does not change anything I have stored. A vector database would search what I stored, so the subtraction would have to happen before storing. I'd have to update all stored vectors for every new post or every edit, which feels more complex. Maybe vector databases have a good answer for that; I have not looked.\n\nYou might wonder how expensive it is to generate these embeddings and compare all these vectors. It turns out to be fast and cheap.\n\nThere are two kinds of work, and they happen at different times. Generating an embedding calls an AI model, but happens only once after a post is created or edited. Ranking uses ordinary PHP arithmetic and happens occasionally, when Drupal rebuilds a page's cached related-post list.\n\nI run the model on Cloudflare Workers AI. To generate an embedding, my server makes an HTTPS call that passes the post's text to Cloudflare, which runs the model and returns the 768-number vector. That round trip takes about 250ms. It happens on the first view after a post is created or edited, and the vector is then cached. The model is deterministic, so the same text always produces the same 768 numbers.\n\nCloudflare bills Workers AI usage in units it calls Neurons and includes 10,000 free each day. Embedding my full archive of roughly 1,500 posts used roughly 4,000 Neurons, and a new post costs about three. Embedding my blog is basically free.\n\nCalculating the related posts never calls the AI model. It all happens in [Drupal](https://www.drupal.org/), my website's content management system. When Drupal needs to build one of the related posts lists, it loads all the stored vectors, centers them, and scores the current post against all the others: roughly 1,500 dot products, each over 768 numbers. This takes around 250ms on my site. After a list has been built, it is cached.\n\nIn other words, my website never loads model weights; it just stores the 768 numbers that come back. The machine-learning compute lives at Cloudflare's edge, and my server stays a plain PHP application. None of this needs a vector database or a machine-learning framework: one HTTP call generates the embedding, a key-value store caches it, and a few dozen lines of arithmetic choose the related posts.\n\nTags are too blunt, backlinks only capture the links I remembered to make, and manual curation does not scale. All three need me to notice the connection first. Using embeddings might sound a bit scary, but they turned out to be easy to implement, fully automated, and able to surface posts I would never have thought to link.\n\nPlus, it was fun to dust off a little math. And as I write this, I wonder which posts this one will match. I am eager to hit 'Save' and find out, and you can scroll down and see for yourself.", "url": "https://wpnews.pro/news/finding-related-posts-with-embeddings", "canonical_source": "https://dri.es/finding-related-posts-with-embeddings", "published_at": "2026-08-26 08:40:02+00:00", "updated_at": "2026-08-26 10:13:29.329391+00:00", "lang": "en", "topics": ["machine-learning", "natural-language-processing", "ai-tools"], "entities": ["Dries Buytaert", "Acquia", "BAAI", "bge-base-en-v1.5"], "alternates": {"html": "https://wpnews.pro/news/finding-related-posts-with-embeddings", "markdown": "https://wpnews.pro/news/finding-related-posts-with-embeddings.md", "text": "https://wpnews.pro/news/finding-related-posts-with-embeddings.txt", "jsonld": "https://wpnews.pro/news/finding-related-posts-with-embeddings.jsonld"}}