Drupal Planet
The Drop Times: When Websites Become Interfaces for Machines
AI assistants increasingly sit between people and websites, retrieving and interpreting information on their behalf. That shifts the problem beyond whether software can connect to a site at all. The system also has to make clear what information means, what software may access, and which rules govern what happens next.
The Drupal AI Initiative made the first part of that problem explicit on 24 August 2026. Fields, taxonomy, relationships, metadata, and other forms of structured content can give software information that is easier to identify and interpret than meaning conveyed mainly through page design. Permissions and workflows add another layer by distinguishing what is public, restricted, current, or subject to editorial control.
A more concrete access boundary appeared this week in Acquia DAM's MCP Server. Compatible AI assistants can search assets, retrieve metadata, and obtain links through an authenticated connection that follows the user's existing DAM permissions. The initial implementation is read-only: an assistant can retrieve permitted information but cannot edit metadata or upload files. The interface therefore exposes capability without giving the software unrestricted authority over the system behind it.
GovCMS addresses a different machine-readable problem. On 27 August 2026, Rules as Code became a production capability on the platform, allowing policy, legislation, and business rules to be represented as reusable digital assets and embedded in digital services. Its first production implementation is an eligibility checker for agencies considering GovCMS, and the underlying eligibility rules are also published as open source. GovCMS describes the approach as a foundation for future AI-assisted services rather than as an AI-agent implementation today.
These developments are not three versions of the same architecture. They expose three related requirements for systems that software increasingly has to interpret or use: meaning must be explicit, access must be constrained, and important rules should not have to be guessed from prose. For Drupal teams, the agentic web may therefore depend as much on long-standing work around content modelling, permissions, auditability, and governance as it does on new AI capabilities.
Follow The DropTimes on LinkedIn, X, Bluesky, and Facebook, or join #thedroptimes on Drupal Slack.
This issue of Editor’s Pick was written and curated by Allen Jason.
Acquia.com - Drupal Blog: Two Keynotes, One Question: Building Through Disruption at Drupal GovCon
Drupal Association blog: The Drupal Association Announces 2026 Board Election Winner
The Drupal Association is excited to announce the winner of 2026 Community At-Large Board Elections.
We extend a sincere thank you to Alejandro Moreno for his service and dedication, not only to Drupal, but to the Drupal community. Your time spent on the board made such a difference to the future of the Drupal project, and we thank you all for participating with grace, thoughtfulness, and insightful contributions.
We would like to congratulate and welcome our newest board member, Janna Malikova, for winning the community-elected seat during our 2026 At-Large Board Elections.
We cannot wait to see all the amazing things Janna will accomplish while on the Drupal Association Board.
Detailed Voting ResultsThere were 8 candidates in this year’s At-Large board member election. 307 voters cast their ballots out of a pool of 1274 eligible voters. Eligible voters are active Drupal Association members, known as Ripple Makers, whose membership must have been active at least 24 hours before voting opened.
Under Approval Voting, each eligible voter can give a vote to one or more candidates.
The final total of votes were as follows:
Candidate
Votes
Scott Falconer
74
James Abrahams
131
Bert Boerland
120
Chris Kelly
26
Darren Oh
57
Janna Malikova
141
Helge Notø
74
Matthew Saunders
128
On behalf of all the staff and board of the Drupal Association, a heartfelt Drupal Thanks to all of you who stood for the elections this year. It truly is a big commitment to contribution, the Drupal Association, and the community, and we are so grateful for all of your voices. Thank you for your willingness to serve, and we hope you’ll consider participating again in 2027!
Specbee: Specbee is now a Drupal AI Initiative Partner. What does it mean for your platform?
#! code: Drupal 11: Migrating From Jadu Into LocalGov Drupal: Part 3
This is the second article in a series looking at migrating from Jadu into a LocalGov Drupal (LGD) site for the Central Bedfordshire site. In the first article we looked at the Jadu API and setting things up so that we could make calls to the API and parse the XML data using the migration systems available.
In the second article we looked at reproducing Jadu URLs to create redirects for migrated content, even though the Jadu API doesn't contain any URL information.
Now that we have a the Jadu connection created and redirects working we can start tackling other aspects of the migration, so let's look at migrating the main content of the site from Jadu. We can pull structured pages of content out of the Jadu API and apply them to the structure of a LGD site, maintaining the same hierarchical structure and order of pages.
Much of the content of a LGD site is built using just a handful of content types, and Central Bedfordshire was built in the same way. LGD comes with a number of content types that allow content editors to add content in different ways, which allows for some decent customisation in the structure and layout of a site. Not only that, but as we are using Drupal it is possible to customise this structure as much as we need to suit the needs of the site.
For Central Bedfordshire, we had the standard structure of the site built with Service Landing pages, which gave editors the ability to link out to other pages that contained all of the content. These inner pages were set up as Guide pages, and the migration of that content is what we will be focusing on in this article. Please note that I will need to gloss over a little bit of the detail in this article, but rest assured that the source code will be available soon.
philipnorton42 Sun, 08/30/2026 - 20:02Omega8.cc: BOA's Baby Pictures
Mike Herchel's Blog: It’s time to prepare your Drupal modules for Admin’s dark mode
Webpro Company blog: WebMCP and MCP: what is the difference and when should a website use each?
Talking Drupal: Talking Drupal #567 - Common Vulnerabilities & Exposures
Today we are talking about Security, Vulnerabilities, and how to avoid exposure with guest Dave Welch. We'll also cover Security Scanner as our module of the week.
For show notes visit: https://www.talkingDrupal.com/567
Topics- What Are CVEs
- CVE Lifecycle and Disclosure
- AI Era Security Challenges
- What CVE Program Excludes
- Patch Fast Reality
- Global Security Signals
- CVE Timing Judgment
- KEV Flags Explained
- CVE Updates Link Rot
- Who Decides CVE
- Sneaky Patch Dangers
- ADP Program Fixes
- Small Team Triage
- Vulnerability Tsunami AI
- Autonomous Security Future
- Legal Pressure Budgets
- Psalm PHP Static Analysis Tool
- SARIF format
- PHP ecosystem
- Council of roots
- How AI Broke Open Source Security: End-of-Life Software Is the Most Exposed
- CVE podcast
- Vulncon
- PSIRT
David Welch - github: dwelch2344 dwelch2344
HostsNic Laflin - nLighteneddevelopment.com nicxvan John Picozzi - epam.com johnpicozzi JD Flynn - dorficus
MOTW CorrespondentMartin Anderson-Clutz - mandclu.com mandclu
- Brief description:
- Have you ever wanted a fast way to catch the security mistakes that slip into custom Drupal code — especially the code your AI assistant just wrote — before it ships? There's a module for that.
- Module name/project name:
- Brief history
- How old: created in July 2026 by Mayank Gupta (mayankguptadotcom) of Acquia
- Versions available: 1.0.0, which works with Drupal 10.3 and 11
- Maintainership
- Actively maintained — created and shipped its first stable this summer, with steady development right through late July
- Security coverage
- Test coverage — and it's strong: unit and kernel tests, including a regression corpus built from real Drupal core advisories
- Documentation? In-depth README with a full check table and CI recipes, plus a CHANGELOG
- Number of open issues: 1 issue, not a bug
- Usage stats:
- 2 sites (it's brand new)
- Module features and usage
- Provide a Drush command, has no UI — you point drush security:scan at a module or any path, it reads the code statically, and prints a prioritized, OWASP-mapped list of things to review
- It's built for the age of AI-written code — the checks target the classes AI assistants keep reintroducing: routes with no access check, #markup and |raw XSS, missing CSRF tokens, unserialize() on untrusted data, hardcoded secrets
- Then there's an optional deep pass: with the Psalm static analysis scanning engine installed, it'll trace untrusted input across functions and files to catch cross-function issues. And it's honest about state — the report always says whether that deep pass ran, was skipped, or failed, so a failure never gets mistaken for a clean scan
- One nice detail under the hood: a tokenizer-backed "code map" that knows whether a match is real code, a comment, or a string — so it won't flag the word "unserialize" sitting in a doc comment. That kills the single biggest source of false positives
- The checks are regression-tested against real Drupal advisories (Drupalgeddon, Drupalgeddon2, the 2019 unserialize bug, etc) so a pattern that caused an actual CVE can't quietly come back in your custom code
- Output comes in three flavors: a readable table, JSON for CI and AI agents, and SARIF — which means findings show up as annotations right on your GitHub or GitLab merge-request diff instead of buried in a job log
- For adopting it on an existing codebase there's a baseline file — you fingerprint the findings you've reviewed, with a required reason on each, and they stop failing the build but never go invisible; every run still counts them
- It exits non-zero on error-level findings, so it drops straight into CI or a pre-commit hook
- And it's extensible — checks are Drupal plugins with a #[SecurityCheck] attribute, so any module can add its own or alter the ones that ship
- Big caveat, and the module says this itself: a finding means "review this," not "this is broken." Static analysis has false positives, and a clean scan doesn't prove the code is secure — access-control logic especially still needs human review
- I first heard about this module over beverages at Drupalcamp Asheville, so I know that this module was largely vibe-coded, after having an AI agent ingest every single Drupal security team CVE. So I like to think of this module as security pattern recognition tool, but of course it does even more
Dries Buytaert: Open Source is a cost-allocation system
Open Source is usually described as a licensing model, a development model, or a production model. All three descriptions are useful, but they leave something out. Every Open Source ecosystem is also a cost-allocation system.
Keeping software relevant and dependable requires people to write code, review contributions, prepare releases, investigate security reports, operate infrastructure, write documentation, answer questions, and support upgrades and migrations. Someone always bears those costs.
A proprietary vendor can tie access to payment: every license or subscription sold helps fund developers, security work, infrastructure, and releases.
Open Source breaks the link between access and payment. Anyone can redistribute the software at any price, including zero. Payment is therefore not a condition of using or redistributing it, and those rights do not themselves provide a durable mechanism for funding production and maintenance.
But separating payment from access does not make the costs disappear. Costs are distributed across maintainers, employers, foundations, sponsors, and users. A maintainer may volunteer their time. An employer may pay a developer to contribute. A foundation may operate infrastructure.
Because these costs are distributed rather than collected through a single transaction, they are harder to see and harder to fund.
Technical choices often shape where those costs fall. That allocation is not always deliberate; it can emerge slowly from decisions that were individually reasonable and become visible only years later.
Drupal's update service recently gave me a good example. Drupal sites periodically contact updates.drupal.org to ask whether new versions of Drupal or any installed add-ons are available. Drupal calls these add-ons "contributed projects", such as modules and themes maintained by members of the community.
The current design sends one request for Drupal itself and one for every contributed project installed on the site. A site with 100 contributed projects therefore makes 101 requests each time it checks for updates, by default, once a day.
There were good reasons for that design. Each project's release history could be stored as a static file and served repeatedly without rebuilding it for every request. The design was straightforward, and it worked.
But Drupal grew. Today, updates.drupal.org serves nearly a billion requests a month. The file containing Drupal Core's release history is roughly half a megabyte by itself. Based on the number of requests for that file, I estimate that Drupal Core update checks alone may account for roughly 50 to 75 TB of data transfer each month. Traffic for contributed projects comes on top of that.
No one chose to make updates.drupal.org transfer tens of terabytes of release data each month. That scale emerged gradually as Drupal grew, from a design that had been reasonable when the ecosystem was smaller.
Part of the challenge is that, in Open Source, the people who benefit, the people who decide, and the people who bear the costs are often different and may have no formal obligations to one another.
Site owners benefit from reliable update notifications, usually without paying the Drupal Association for them. Drupal Association staff operate the update service, while the Association bears its traffic and infrastructure costs. But the code that determines how sites make those requests lives in Drupal Core, where changes require the involvement of Core committers. The Core committers do not report to the Drupal Association, so the Association cannot change that behavior on its own.
In practice, Drupal Association staff and Core committers collaborate closely. That collaboration is important because decision-making authority and cost-bearing sit with different groups.
The people bearing material costs need a way to make those costs visible and influence the decision, even if they do not control it.
This example shows why every Open Source architecture is also a cost-allocation system. Good governance considers that allocation up front, monitors its effects over time, and revisits it when it becomes unsustainable.
Understanding an Open Source system therefore requires more than understanding its code or license. We also need to understand who benefits, who decides, and who bears the costs as the system grows. Once those relationships are visible, a community can decide whether the allocation is sustainable or whether the architecture should change.
LakeDrops Drupal Consulting, Development and Hosting: Six Small ECA Models That Make Drupal Editors Happy
This article introduces six new ECA Guide library recipes that improve the Drupal content editing experience through small, focused changes. They can draft metadata on first save, add access-aware edit links to teasers, prevent invalid dependent select values, offer field-level actions, fill several form fields without saving, and show contextual guidance beside a form. The point is not to apply all six recipes unchanged. They are well-architected starting points for a real editorial irritation. For editors, that means less repeated work and fewer avoidable mistakes. For organisations, it means more consistent content and a practical route to improving the CMS without turning every detail into a custom development project. The article also gives a simple choice: Field Widget Actions for one field, Form API Ajax for several form fields, and HTMX endpoints for nearby content. Production concerns still matter, especially permissions, validation, managed data, and AI review.
Berliners blog: Rebuilding a data-driven Drupal site: From ctools plugins to Layout Builder blocks
In the previous post, I described why we chose Layout Builder and how Layout Builder IPE restored the frontend editing workflow we had used with Panels IPE.
With the editing workflow in place, we still had to rebuild the elements that editors placed on their pages.
The Drupal 7 site used custom ctools content type plugins for maps, charts, figures, lists and tables. Editors placed and configured these plugins, while the plugins retrieved the information and rendered the result. I will call both these ctools plugins and the block plugins that replaced them page elements.
berliner Thu, 08/27/2026 - 15:06 TagsSpecbee: 16 Drupal AI modules that can make your website smarter
DDEV Blog: DDEV August 2026: Star Us on GitHub, PhpStorm Plugin Comes Home
If you use DDEV, a simple way to mention the project is to star the GitHub repository. Head over to github.com/ddev/ddev↗ and click Star. It takes five seconds, and it can help us — a star count is one of the things new users, sponsors, and and AI check before trusting an open source tool. If you're already a star, thank you!
DDEV IntelliJ/PhpStorm Plugin Lands in the DDEV OrgThe DDEV Integration plugin for IntelliJ/PhpStorm↗, maintained by @AkibaAT, has been transferred into the ddev GitHub organization. This was on our 2026 plans list, and it's great to see it land. Awesome maintainer AkibaAT has kept the plugin in excellent shape, and this move gives it a permanent home alongside the rest of the DDEV ecosystem.
What's New on the Blog- A Love Letter to the DDEV Community → Randy on what the DDEV community — real feedback, hard questions, generous contributions, genuine collaboration — makes this project what it is.
- Shopware on DDEV: notes from years of client projects → Benny Poensgen on why DDEV and Shopware fit together, from a first onboarding to conclusions drawn over years of agency practice.
- Umbraco on DDEV: .NET, SQL Server, and the generic project type → Lee Mills on running Umbraco CMS and .NET 10 under DDEV's generic project type, with a custom web image and Azure SQL Edge.
Knecht.works Ships Sandbox Rollback — Following up on last month's beta-tester call, the team at knecht.works has added sandbox rollback to their agency dashboard, letting each automated DDEV run boot into its own disposable environment. Read the update↗
TYPO3 Snapshot: Pull and Anonymize Production Data Locally — Ramon Herrmann released Snapshot, an open-source TYPO3 extension that pulls databases and fileadmin from live/staging into a local DDEV environment, with built-in anonymization for GDPR compliance. Read the announcement↗
Quick DDEV Previews: A Self-Hosted Preview Service — Matthias Andrasch built a proof-of-concept service that spins up DDEV preview environments from any branch of a connected GitHub repository, based on Samuel Reichör's technical work. Screencast: Using it on Hetzner VPS↗ • View the repo↗
Community Tutorials from Around the Web- Migrating a Local WordPress Site to DDEV on Windows/WSL2 (Spanish) → Adam Martín walks through moving a WordPress project from Local to DDEV running inside WSL2, including database import, URL fixes, SSL certificates, and troubleshooting port conflicts. Read on dev.adammartin.es↗
- Installing DDEV on Linux (French) → An updated walkthrough covering Docker prerequisites and DDEV installation on Ubuntu/Debian, Fedora, and openSUSE, plus mkcert certificate setup. Read on kgaut.net↗
- Global Commands for Database Dumps and Remote Imports (French) → ddev db-import and ddev db-export, a pair of global DDEV commands for restoring and exporting Drupal databases with drush cache-clear and login-link steps built-in, plus a follow-up set (db-prod-import, ssh-prod, and their preprod equivalents) for pulling a remote database in one step, packaged as the ddev-drupal-tools↗ add-on. Read the first post↗ • Read the follow-up↗
- DDEV + a-blogcms as a MAMP Alternative (Japanese) → An introduction to DDEV for a-blogcms developers used to MAMP, covering setup, useful commands, and Mailpit for email testing. Read on kazumich.com↗
- Running Drupal's GitLab CI Checks Locally → How Kalamuna's ddev checks and ddev checks-fixes commands mirror the Drupal.org GitLab CI template, so code that passes locally passes in CI. Read on kalamuna.com↗
Live training is back for the fall, three sessions open to everybody.
Upcoming DDEV Live Contributor and User Training SessionsSeptember 23, 2026 at 8:00 AM US Mountain / 10:00 AM US Eastern / 16:00 CEST — Managing Huge Databases with DDEV, with Moshe Weitzman
Covers the new embedded snapshot and uncompressed snapshot features shipping in the upcoming v1.25.4 DDEV release.
Add to Google Calendar • Download .icsOctober 21, 2026 at 8:00 AM US Mountain / 10:00 AM US Eastern / 16:00 CEST — Shopware with DDEV, with Benny Poensgen
A look at running Shopware on DDEV, from Benny's earlier post on the blog.
Add to Google Calendar • Download .icsNovember 11, 2026 at 8:00 AM US Mountain / 10:00 AM US Eastern / 16:00 CET — Advanced Coder.ddev.com Techniques
Using the coder CLI, local and web-based VS Code, and remote-controlling Claude, including the freeform template.
Add to Google Calendar • Download .ics
Zoom Join Info:
Link: Join Zoom Meeting
Passcode: 12345
DrupalCamp Tokyo 2026 — ANNAI presented on AI-driven Drupal development and sustainable open-source CMS strategy, including using DDEV with git worktree to run parallel Drupal environments. Read the report↗ (Japanese) — for English coverage of git worktree with DDEV, see Contributor Training: git worktree for Multiple DDEV Projects and Using git worktree with TYPO3.
Governance- The next DDEV advisory group meeting, open to everybody, is September 2, 2026 at 8:00 AM US Mountain / 10:00 AM US Eastern / 16:00 CEST. Add to Google Calendar • See the agenda. We love to hear from our community!
A steady month — thank you to everyone who contributes!
July 2026: ~$9,931/month (82.8% of goal)
August 2026: ~$10,038/month (83.7% of goal)
If DDEV has helped your team, consider sponsoring. → Become a sponsor↗
Contact us to discuss sponsorship options that work for your organization.
Stay in the Loop—Follow Us and Join the ConversationCompiled and edited with assistance from Claude Code.
Gspikes: Drupal vs WordPress Performance: What Core Web Vitals From 2.9 Million Sites Actually Show
The Drop Times: DrupalCamp Colorado 2026 Puts Long-Term Drupal Practice in Focus
Omega8.cc: Drupal CMS Runs on Ægir
Stuart Clark (Deciphered): Drupal site settings over JSON:API, per consumer
In April 2022 I wrote about decoupling configuration with Config Pages, and ended it with a guess at where I'd go next:
One of my future experiments with Druxt will be to look at the options of using the Drupal Consumers module alongside Drupal theme settings as a solution.
That experiment is now a module. Decoupled Settings serves the site and theme configuration Drupal already holds over JSON:API, and lets every frontend override just the values it needs. It went up on Drupal.org this week.
Core's JSON:API is an entity API. Content entities and config entities alike, so node types, image styles and views are all in there. What it has never exposed is simple configuration, and there's a good argument that it shouldn't by default, because plenty of what sits in system.site is nobody's business.
But a decoupled frontend still needs the boring stuff:
- The site name, for the document title
- The slogan
- The logo and the favicon
- The front page path, so the router knows what "home" means
So every decoupled build solves it locally, and most of the ways to solve it end up keeping a second copy of the site name somewhere.
Dries Buytaert: Finding related posts with embeddings
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.
I 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.
Embeddings turn meaning into numbersAn embedding model reads text and returns a vector: a long list of numbers. The model I use, 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.
You 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:
[ 0.021, -0.045, 0.038, -0.012, 0.007, ..., 0.019 ] (768 numbers total)Conceptually, 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.
This 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.
Raw cosine similarity makes everything look relatedOnce 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.
The standard way to compare two vectors is 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.
Two 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.
In 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, 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.
The practical effect is that almost any two posts look somewhat similar. An old post about the founding of Acquia 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.
Mean-centering reveals what makes each post distinctAnisotropy 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.
All-but-the-top removes the average and the next few strongest directions. Whitening 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.
You 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.
A modern model like bge-base-en-v1.5 already 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.
An example might help. Imagine three posts with only two numbers each instead of 768:
A = (0.90, 0.10) B = (0.85, 0.80) C = (0.80, 0.75)At 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.
The average (mean) of the three vectors is:
mean = (0.85, 0.55)Now subtract that average from each post:
A = ( 0.05, -0.45) B = ( 0.00, 0.25) C = (-0.05, 0.20)Now 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.
Before centering, everything looked similar. After centering, the comparison focuses on what is different from the average.
Normalization reduces comparison to a dot productAfter 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.
I 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.
It 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.
Using the same example, the centered vectors for B and C are:
B = ( 0.00, 0.25) C = (-0.05, 0.20)First, 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:
B ≈ ( 0.00, 1.00) C ≈ (-0.24, 0.97)Then take the dot product:
(0.00 × -0.24) + (1.00 × 0.97) = 0.97That 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.
A, after normalization, points mostly downward. Next to B:
A ≈ ( 0.11, -0.99) B ≈ ( 0.00, 1.00)Multiplying them the same way:
(0.11 × 0.00) + (-0.99 × 1.00) = -0.99That is not a match at all.
The PHP code is shorter than the explanationThe production code does the same arithmetic, just with 768 numbers per post instead of two:
public static function center(array $raw): array { if ($raw === []) { return []; } $mean = array_fill(0, count(reset($raw)), 0.0); foreach ($raw as $vector) { foreach ($vector as $i => $value) { $mean[$i] += $value; } } $count = count($raw); foreach ($mean as $i => $sum) { $mean[$i] = $sum / $count; } $centered = []; foreach ($raw as $nid => $vector) { $norm = 0.0; foreach ($vector as $i => $value) { $vector[$i] = $value - $mean[$i]; $norm += $vector[$i] * $vector[$i]; } // A vector sitting exactly on the mean centers to zero; fall back to 1.0 // so the division below never hits a zero norm. $norm = sqrt($norm) ?: 1.0; foreach ($vector as $i => $value) { $vector[$i] = $value / $norm; } $centered[$nid] = $vector; } return $centered; } public static function topMatches(array $source, array $pool, int $self): array { $scores = []; foreach ($pool as $nid => $vector) { if ($nid === $self) { continue; } $similarity = 0.0; foreach ($source as $i => $value) { $similarity += $value * $vector[$i]; } $scores[$nid] = $similarity; } arsort($scores); return array_keys(array_slice($scores, 0, 3, TRUE)); }While my explanation was long, both PHP methods are relatively short. In center(), each vector has the corpus mean subtracted, then is divided by its own length. In topMatches(), I calculate the cosine similarity between one post and every other post, then keep the three highest.
You 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(), but it would not remove center(). Centering is optional, but it meaningfully improved my results.
A 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.
One-time embeddings, occasional rankingYou might wonder how expensive it is to generate these embeddings and compare all these vectors. It turns out to be fast and cheap.
There 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.
I 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.
Cloudflare 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.
Calculating the related posts never calls the AI model. It all happens in Drupal, 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.
In 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.
Tags 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.