---
title: "A free embedding API for related posts, with a cache that survives failure"
date: 2026-10-29
description: "A runnable build-time pipeline for short public summaries, with model-aware caching, token checks and last-good JSON. Hosted inference remains untested."
tags:
  - "free-tier"
  - "embeddings"
  - "personal-sites"
---

A free embedding API can supply vectors for a static site's related-post links without becoming a service every visitor depends on. Generate the vectors when content changes, rank the posts locally, and give the site build a plain `related.json` file.

The [complete Python example](related-links-example.zip) does that for a small corpus, with a cache that tracks both text and model settings. If inference fails halfway through, it leaves the previous JSON untouched. Its local checks use synthetic vectors; the Cloudflare request path follows the documentation and has mocked HTTP tests, but no hosted inference was run for this article. The example establishes the plumbing, not the usefulness of its recommendations.

## Start with the links you actually need

For a small site, a few manually chosen links may do the job better. Tags can automate obvious connections without an API. If the useful connections mostly share distinctive words, [TF-IDF is another practical option](https://osher.com.au/blog/automatically-relate-blogs-tf-idf-cosine-similarity/): it compares vocabulary using local code, with no embedding service.

Embeddings are worth investigating when those methods miss connections you want readers to find. They still need checking against real examples. Pick a few posts, write down the useful next reads, and compare the generated candidates with those choices before changing the site.

The architecture already has good precedents. [Dries Buytaert uses Cloudflare BGE for related posts without a vector database](https://dri.es/finding-related-posts-with-embeddings). [Tom Dickson generates static related-post JSON at build time with a local model](https://tom-dickson.com/blog/semantic-blog-clustering/). The example here concentrates on cache invalidation and what happens when regeneration fails.

## Run the example without calling an API

Download and unpack the example, then run these commands inside its directory:

```sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python fetch_tokenizer.py
.venv/bin/python -m unittest -v
.venv/bin/python related.py
.venv/bin/python related.py
```

Setup downloads the Tokenizers library and a pinned BGE tokenizer file. It downloads no model weights and performs no inference. After setup, the tests and default example run offline.

The first build generates four synthetic vectors; the second reports four cache hits and zero generated vectors. The fixture coordinates were chosen by hand, including which articles should sit near one another. A convincing-looking pair in that output says nothing about BGE's recommendations.

For your own corpus, `records.json` contains public article records in this shape:

```json
{
  "path": "/posts/a-walking-map/",
  "title": "A walking map",
  "summary": "Drawing a walking route on a map."
}
```

The file itself is an array of records. The script accepts 2–200 records, prepares each title followed by its summary, and keeps the vector cache separate from the JSON published with the site. It does not read private drafts or scrape full posts.

[Cloudflare's BGE base English v1.5 page](https://developers.cloudflare.com/workers-ai/models/bge-base-en-v1.5/) specifies 768 output dimensions and a maximum of 512 input tokens. The script counts actual tokens with the pinned tokenizer, including special tokens, and refuses records over 480. That margin is a deliberate input policy. Shorten the summary if it fails; there is no hidden truncation or assumption that a character count is a token count.

## Add the documented Cloudflare route

Cloudflare Workers AI is one concrete free embedding API route for this job. Its [pricing documentation](https://developers.cloudflare.com/workers-ai/platform/pricing/), checked on 27 October 2026, lists a recurring allocation of **10,000 Neurons a day**, resetting at 00:00 UTC. Workers Free stops at its allocation; Workers Paid can charge for additional usage. Neurons are a usage unit, not a fixed number of articles.

Use the [REST setup guide](https://developers.cloudflare.com/workers-ai/get-started/rest-api/) for the account ID and Workers AI API token. Keep the token in your build environment or CI secret store. It belongs neither in the public site nor in `records.json`.

With those values already available as `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, the optional command is:

```sh
.venv/bin/python related.py --mode cloudflare --allow-inference \
  --records records.json --cache .related-cache --output related.json
```

Check the account's plan and remaining allowance before opting in. The script cannot inspect billing or guarantee a free run on a Paid account. It makes one request per cache miss, with a timeout and no automatic retries or model fallback.

The adapter posts `text` and an explicit `pooling: cls` setting to `@cf/baai/bge-base-en-v1.5`. Cloudflare documents `cls` and `mean` pooling as incompatible, even though both produce 768 numbers. That setting has to travel with the cache identity.

Only send text you intend to submit to the provider. Cloudflare's [data-use policy](https://developers.cloudflare.com/workers-ai/platform/data-usage/) distinguishes standard inference from stateful features, logging and caching; a free allowance is not a substitute for checking those conditions. The [maintained free-compute guide](/free-compute/) covers the wider choice of where to run a project.

## A cache hit must mean the same calculation

A cache keyed only by article URL survives the wrong changes. Edit the summary and it returns the old vector. Change the model or pooling and it can mix incompatible vectors in the same ranking.

This example hashes the prepared text together with the model ID, dimensions, pooling, text-preparation version, tokenizer hash and an operator-controlled model epoch. Fixture and Cloudflare modes have separate identities. With an unchanged model contract, editing one record regenerates that record alone. Changing the contract rebuilds the corpus.

The epoch handles a remaining limit: the hosted model name does not pin an immutable serving revision. After a known model change, increment `--model-epoch` and regenerate all vectors. The example cannot automatically detect a silent provider update.

Every returned or cached vector must contain 768 finite numbers and have a nonzero norm. The script normalises valid vectors, compares them by cosine similarity, removes self-matches and orders ties by path. It writes only link paths to `related.json`.

Keep `.related-cache` between CI runs. A fresh build machine without that cache has to regenerate the vectors, even when the articles have not changed.

## Keep the last good output, and notice when it is old

The executed checks cover the failure cases that would otherwise be easy to miss:

| Check | Observed local result |
| --- | --- |
| Repeat an unchanged build | Four cache hits, no new vectors |
| Edit one prepared record | One vector regenerated |
| Change model, pooling, preparation, tokenizer or epoch | All four vectors regenerated |
| Return a zero, nonfinite or wrongly sized vector | Build rejected; previous output retained |
| Fail on the second new vector, or fail during file replacement | Previous output retained |

All 15 tests passed using synthetic vectors, the real local tokenizer and mocked HTTP. They also check the 480/481-token boundary, self-match removal and deterministic ties. The [test receipt](proof/test-results.txt) records the run.

The script calculates the complete result before replacing `related.json` through a temporary file in the same directory. A partially completed request sequence cannot leave half a recommendations file behind. Successful cache entries from before a later failure remain available for the next attempt.

Let a regeneration failure fail the CI job and keep the previous deployed site. Continuing a new site build with old JSON is a separate decision: a deleted article may still appear in that old file, so filter its links against the current route list if you take that route. On a fresh checkout, restore a last-good JSON file or commit it; the script cannot preserve a file that was never there.

Finally, top-two means the two closest candidates, however weak they are. A cosine score is not a percentage chance that someone will find a link useful, and the [BGE model card](https://huggingface.co/BAAI/bge-base-en-v1.5) cautions against treating a fixed similarity value as a universal threshold. Review actual recommendations before shipping them. This pipeline prepares related links for known articles; arbitrary visitor search queries would need their own compatible embedding path.
