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.
Why this is a judgment call, not a join
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.
The recipe: four scripts, one afternoon of GPU time
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.
1. 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 |
5,892 articles, crawled on the morning of May 4, 2026.
2. Narrow before you ask
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.
DATE_WINDOW_DAYS = 90
TOP_K_CANDIDATES = 20
MIN_CONFIDENCE = 0.7
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.
3. Ask one small question, in JSON, at temperature zero
The prompt, verbatim:
Match translations of blog articles between languages.
RULES:
- A match means the SAME article translated - same specific content, same arguments,
same examples - not just the same broad topic or shared keywords.
- Two articles can share a keyword (e.g. 'no-code', 'GoodBarber', 'app') and still be
DIFFERENT articles. Reject those.
- Compare the FULL summary, not just the title. Look for the same specific subject,
same angle, same takeaways.
- If you are not sure it's the same exact article, set index=null.
- Return high confidence (>0.8) only when the summary clearly describes the same content.
SOURCE (fr):
title: ...
summary: ...
date: ...
CANDIDATES (en):
[0] title: ...
summary: ...
date: ...
[1] ...
Reply JSON only: {"index": <int>|null, "confidence": <0..1>}. Default to index=null when unsure.
The call:
r = await client.post(
"http://localhost:11434/api/generate",
json={"model": "gemma2:27b", "prompt": prompt, "format": "json",
"stream": False, "options": {"temperature": 0.0}},
timeout=600,
)
answer = json.loads(r.json()["response"])
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.
4. 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.
What came out
| 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 |
Two implementations, one list
We had no ground truth. Nobody was going to grade 5,325 pairs by hand, and a validator written by the same person with the same assumptions grades itself. So over the same two weeks in May, a colleague, Marc, built a second implementation with nothing in common with mine: a different method, an embedding model and a nearest-neighbour index (pgvector) instead of a generator and a prompt, the same French pivot, a similarity threshold at 0.60, and a preference for the French article that shares the same thumbnail image, by hash, when one exists. No date window, no candidate list, no shared code, and no import of my CSV. Two lists, built blind. The rule was simple: when they said the same thing, we would stop.
I pulled the export of the second implementation today and diffed it against my May file.
- Its export lists 1,137 rows today; 1,051 of my 1,085 French pivots are in it.
- Of the 4,122 cells both pipelines filled, 4,037 are identical: 97.9 percent.
- 85 differ. I read the first 40 by hand: in 38 the second implementation is right and mine is wrong, the other two are a toss-up. The errors cluster in three families: serial posts (the monthly "What's new", the engine revision announcements, where a 240-character summary does not carry the edition), topic pairs published in the same window (two reseller posts, two native-ads posts, which my 90-day filter served up together), and a handful where the summary simply was too short.
- 284 cells the second implementation filled that mine had left empty, and 110 that mine filled and it leaves empty.
Two implementations that share nothing but the corpus, landing on the same answer 98 times out of 100: that was the stop criterion, and we stopped. Since June our blog sitemaps carry the alternates, rebuilt from that export on every regeneration, best-effort: if the export is unavailable, the sitemap is generated without alternates rather than not at all. As of today, on our seven hosts:
| host | blog URLs in sitemaps | with hreflang alternates |
|---|---|---|
| www (en) | 1,107 | 983 |
| fr | 1,084 | 1,022 |
| es | 945 | 844 |
| it | 943 | 854 |
| pt | 888 | 801 |
| de | 768 | 701 |
| nl | 561 | 536 |
5,741 of 6,296 URLs, 91.2 percent. The alternates live in the sitemaps only, not in the pages' <head>, which Google accepts as one of the three supported ways to declare them. One entry, as served today, shortened:
<url>
<loc>https://www.goodbarber.com/blog/design-trends-2026-...-a1608/</loc>
<xhtml:link rel="alternate" hreflang="fr" href="https://fr.goodbarber.com/blog/tendances-design-2026-...-a1439/"/>
<xhtml:link rel="alternate" hreflang="en" href="https://www.goodbarber.com/blog/design-trends-2026-...-a1608/"/>
<xhtml:link rel="alternate" hreflang="es" href="https://es.goodbarber.com/blog/tendencias-de-diseno-2026-...-a1137/"/>
<xhtml:link rel="alternate" hreflang="it" href="https://it.goodbarber.com/blog/tendenze-design-2026-...-a1102/"/>
<xhtml:link rel="alternate" hreflang="pt" href="https://pt.goodbarber.com/blog/tendencias-de-design-2026-...-a1341/"/>
<xhtml:link rel="alternate" hreflang="de" href="https://de.goodbarber.com/blog/designtrends-2026-...-a1441/"/>
<xhtml:link rel="alternate" hreflang="nl" href="https://nl.goodbarber.com/blog/designtrends-2026-...-a1435/"/>
</url>
The same seven lines appear under the French, Spanish, Italian, Portuguese, German and Dutch URLs of that article in their own sitemaps, which is what makes the set reciprocal.
What I would tell you about open weights, after this
Task design beats model size. The 90-day window and the twenty-candidate cap did more for accuracy than any model choice would have. A June 2024 model at 4-bit was enough because it was never asked to search; it was asked to compare twenty summaries.
Repeatability is a feature. Temperature zero and JSON mode gave me the same answer on the same prompt today as in May. Debugging a matcher that answers differently on each run is not debugging.
The meter changes what you build. The attempts sidecar, the reruns, the fix loop: none of it would exist at a price per prompt. Zero marginal cost is not about saving money on the final run. It is about how many times you are willing to be wrong on the way there.
Verify with a second, cheaper model. bge-m3 is 1.2 GB and MIT licensed. It never decides; it reviews. Two models with different failure modes are worth more than one bigger model.
Build it twice. When there is no ground truth and no budget to make one, two implementations that share nothing, not the model, not the method, not the code, not the author, are a test suite you can afford. Where they agree, you are done. Where they disagree, you have a review queue, and 85 cells is a queue a person can read. Gemma 4 was already on the same disk in May, by the way; I ran the two-year-old one because it felt faster.
Privacy was not the argument here. Blog posts are public. But the crawl hit an internal API on a 192.168 address and nothing left the LAN, so if your corpus is not public, the same recipe runs unchanged.
Hacktoberfest 2026 comes to Ajaccio, and we are hosting
Hacktoberfest changed shape this year. It is now run by Major League Hacking and DEV, in partnership with DigitalOcean, and it stopped counting pull requests. The 2026 edition is 300-plus in-person Fests plus a global online event, all about building with open source AI: write your first skills.md, build an open-source agent, fine-tune an open-weight model. The tagline is "AI belongs to everyone", and after the afternoon described above I have no argument with it.
GoodBarber is hosting the Ajaccio Fest:
- When: Wednesday, October 21, 2026, 18:00 to 21:00.
- Where: CampusPlex, 95 cours Napoléon, Ajaccio, in the middle of town.
- Program: doors at 18:00; at 18:30 a talk on building with open source and open-weight AI, why it matters and concrete ways to start; Q&A and open discussion at 19:15; drinks and time to talk to the people around you from 20:00; end at 21:00.
- Language: the talk is in French; slides and shared resources are in English so you can keep going with the wider Hacktoberfest community afterwards.
- Price: free. Registration on the MLH event page is recommended. Open to working professionals and university students.
No conference format, no pitch. We are one of the few tech companies headquartered on the island, and most developers here work alone; the point of the evening is to get them in one room on a weekday. If you have contributed to open source for years, come. If you have only been curious about where to start, come with a laptop, install Ollama and pull one model beforehand, and the recipe above fits in an evening.
Fourteen years of posts got their hreflang from a 15 GB file and an afternoon. The interesting part was never the model. It was that nothing stood between me and trying.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.