In May I had 5,892 blog posts spread over seven hosts, the oldest dated November 7, 2011, and not one field anywhere that said which post was the translation of which. Same company, same blog: French on fr.goodbarber.com, English on www, then es, it, pt, de and nl on their own subdomains. In our CMS each language blog is a separate site, and for fourteen years a translation was published as a new, unrelated article. Google's hreflang wants, for every article, the full list of its language versions, and it wants the list on every one of them. We had the articles. We did not have the list.
One line of context so you know where I stand: I run engineering at GoodBarber, a no-code app platform headquartered in Ajaccio, Corsica. I have no religion about where a model runs. This post is about one job where a two-year-old open-weight model on a laptop was the right tool, with the numbers to show it, and about why we are hosting a Hacktoberfest Fest on exactly this subject on October 21.
Every obvious key fails. The number at the end of each URL (-a1332 in French, -a1486 in English for the same article) is a per-blog counter. Publication dates are days apart for a translation, sometimes months. Titles are translated freely, and the monthly "What's new at GoodBarber" post carries the same title every month in every language. Slug overlap works when the translator kept the English words and fails precisely when they did their job.
A person would read the summary of the French article, read the summaries of the English candidates, and decide. That is a language model's job. The question was which one, and where.
Hardware: a MacBook Pro with an M3 Max and 48 GB of memory. Runtime: Ollama. Two models, both open weights: gemma2:27b, Google's June 2024 release, 15 GB on disk at the default 4-bit quantization, under the Gemma terms; and bge-m3, BAAI's multilingual embedding model, MIT licensed, 1.2 GB, 1,024 dimensions. Around 600 lines of Python with httpx, no framework, no vector database. Crawl, deterministically
One script dumps every article of every language blog into a JSONL file, one line per article, from the CMS listing API. It flushes each page as it lands and skips ids already on disk, so it survives being interrupted.
| blog | articles | first post | last post | |---|---:|---|---| | fr | 1,085 | 2011-11-07 | 2026-04-24 | | en | 1,083 | 2011-11-07 | 2026-04-24 | | es | 893 | 2013-01-24 | 2026-04-23 | | it | 892 | 2013-01-24 | 2026-04-23 | | pt | 778 | 2013-01-24 | 2026-04-23 | | de | 683 | 2013-11-25 | 2026-04-23 | | nl | 478 | 2013-11-25 | 2026-04-23 |
French is the pivot: it is the source language of most of our posts and the largest blog. For each French article and each of the six other languages, the candidates are the articles of that language published within 90 days of the French one, closest first, twenty at most. The model never sees the 1,083 English posts. It sees at most twenty summaries and picks one, or none.
This is where most of the accuracy comes from, and it costs nothing. A retrieval step does not need to be clever; it needs to make the question small. Ask one small question, in JSON, at temperature zero
Three rules around it. An answer under 0.7 confidence is discarded. An article can belong to one row only, so a matched URL leaves the candidate pool for everyone else. And after every pivot the CSV is rewritten atomically, with a sidecar file recording which (pivot, language) pairs were already attempted, so a Ctrl-C or a rerun never pays for a prompt twice.
That sidecar exists because I reran the thing several times while changing the prompt and the thresholds. With a hosted API, each rerun is a line on a bill and a rate limit to negotiate. Here it was a keystroke.
I measured the cost of one prompt again today, September 9, on the same laptop, with the same code and the same model: the French "GoodBarber vs Glide" article against its thirteen English candidates within the window, 1,342 prompt tokens, 16 tokens out. Cold, 24.7 seconds, of which 11.2 to load the model. Warm, 2.3 seconds. Both runs answered {"index": 2, "confidence": 0.95}, and index 2 is the right article. About 6,500 (pivot, language) pairs at that speed is roughly four hours of GPU time. The crawl ran on May 4; the CSV was last written on May 6 at 20:49. Sent to a hosted model, those eleven million or so prompt tokens would have cost somewhere between a couple of dollars and a couple of hundred depending on the model. Money was never the argument. The meter was. Verify with a different model
The generator is the judge. It should not also be the reviewer. Three layers, cheapest first: Deterministic checks. Host matches the column, slug has the expected shape, each URL appears once in its column, and the publication dates within a row span 90 days at most. Slug overlap. A cell whose slug shares almost no words with the French slug, in a row where the other cells share plenty, gets flagged. It is a heuristic with known false positives on well-translated slugs; it only surfaces candidates. A second model. bge-m3 embeds the title plus the first 300 characters of the summary for all 5,892 articles (an 82 MB cache on disk, once). For each row, pairwise cosine similarity between the cells. A cell whose mean similarity to its row-mates drops 0.15 below the others is an outlier; a row whose mean is under 0.55 is weak. In fix mode the script proposes, for each outlier, the article of that language closest to the centroid of the other cells, and only if it scores at least 0.70 against the centroid, at least 0.70 against the French pivot, and beats the current cell by at least 0.10.
Two models disagreeing is the review queue. A human reads the queue, not 5,325 cells.
| | rows | en | es | it | pt | de | nl | |---|---:|---:|---:|---:|---:|---:|---:| | French pivots | 1,085 | | | | | | | | cells filled | | 959 | 766 | 760 | 688 | 616 | 451 |
5,325 of the 5,892 articles landed in a row, 90.4 percent. 347 rows are complete in seven languages, 151 more in six.
The gaps are older than the model. The Spanish, Italian and Portuguese blogs opened in January 2013 and the German and Dutch ones in November 2013, and the early years published a lot of local-only content that was never translated. So the 193 French posts of 2013 yielded 227 matched cells and no complete row, which is the right answer, not a miss. From 2019 on, more than half of the French posts have all six translations.
| year of the French post | French posts | matched cells | complete rows (6 of 6) | |---|---:|---:|---:| | 2013 | 193 | 227 | 0 | | 2015 | 142 | 542 | 0 | | 2018 | 48 | 226 | 20 | | 2021 | 64 | 354 | 51 | | 2023 | 87 | 473 | 65 | | 2025 | 26 | 155 | 25 |
