DEV Community

pickuma
pickuma

Posted on Originally published at pickuma.com

701 Bad Internal Links Before 49 Good Ones: Why Deterministic Anchor Matching Fails

We maintain a 269-article editorial corpus and wanted internal links without hand-placing every one. The first script was the obvious design: build a phrase list from every article's title, its tools frontmatter, and its keywords; walk each MDX body; wrap the first occurrence of each phrase in a link to the matching slug. 812 phrases, 269 files, one pass, 750 insertions.

We kept 49 of them.

This is where the other 701 went, why adding matching rules does not rescue the design, and what the second version changed — which turned out not to be precision.

Where the 701 went

Every insertion was reviewed by hand against the rendered page. The rejections sorted into five buckets:

Rejection reason Count
Anchor landed inside a code fence or inline code 214
Phrase matched a different sense of the word 168
Target article never discussed the matched claim 141
Page already linked to that slug 96
Anchor landed in a heading or the first two paragraphs 82

The cleanest example is the phrase Cursor. It is an editor we write about, and it is also cursor: pointer in every CSS snippet we ship, cursor in three paragraphs about Postgres keyset pagination, and a substring of cursor in DOM API prose. Case sensitivity does not save you: our sentences capitalize at the start, and type names in code are capitalized too. A tokenizer that respects word boundaries and skips fenced blocks removes most of the 214 and some of the 168. It removes none of the 141.

That third bucket is the one that matters, because it is the one that looks correct in the diff. The phrase was real prose, the link went to a real article, and the article had nothing to say about the sentence it was attached to. Of those 141, 118 came from phrases that appear in more than 20 of our 269 articles — the generic middle of a title, the part chosen for search rather than for meaning.

Two insertions in the first run wrote a link inside an existing markdown link. MDX compiled both files without error and rendered literal brackets around a working link. A third wrote into a JSX attribute string and silently changed a component prop. If you do this, compile every touched file and diff the rendered text, not the source. A green build is not evidence the insertion was structurally valid.

Why more rules do not fix it

Each rule you add is a filter, not a signal. Excluding code, excluding headings, requiring word boundaries, capping links per page — all of these subtract bad candidates. None of them add information about whether the link is useful.

The defect is in what a phrase match proves. It proves a term occurs on both pages. It does not prove that the claim being made in this sentence is one the target page supports. Those are different questions, and only the second one describes what a reader gets from clicking.

Titles are the worst possible source for anchors, for a reason specific to how titles are written. A title is a noun phrase optimized to be searched for, which means it is composed of the vocabulary most common in its topic. Feeding those phrases back into a matcher is close to matching on the corpus's own stopwords.

Link from claims, not from terms

We already generate three to five one-sentence checkable claims per article into a committed JSON file — roughly 1,000 claim strings across the corpus. The second version used those as the link targets instead of titles.

The pipeline: embed every body sentence and every claim; propose a link where cosine similarity clears 0.62 and the two articles do not share both category and tool list (that exclusion exists to stop four hub articles absorbing most of the links); then send every survivor through a single yes/no gate — does the target claim substantiate this sentence?

1,043 candidates cleared the threshold. 128 survived the gate. 61 shipped after human review.

Read those numbers carefully, because the obvious reading is wrong. Candidate-level precision barely moved: 49 of 750 is 6.5 percent, 61 of 1,043 is 5.8 percent. The win is entirely in the review pile. 128 diffs is an afternoon. 750 diffs is not something you will do twice, which means version one was going to be abandoned rather than corrected.

The embedding step is not doing semantic work you could not get from the gate alone. It exists as a cost filter: about 32,000 body sentences against 1,000 claims is roughly 32 million pairs, and the gate cannot run on that. If your corpus is small enough that the full cross product is affordable, skip the embeddings.

What we did not measure, and what we would do instead

We have not measured whether the 61 links changed anything in Search Console. Our indexed-URL count was 37 on 2026-08-17, our snapshot series is weekly, and 434 articles were deleted in the same window. Two data points cannot separate a linking change from a prune of that size. If someone tells you internal links moved their index coverage inside a month with a concurrent deletion, they are reading noise.

What we would tell you, given the corpus size you probably have:

Under about 50 articles, do not automate this. Hand-maintain a phrase-to-slug map. Ours started at 30 entries and covered most of what version one found correctly, at a fraction of the review cost.

The condition that flips it is map maintenance exceeding roughly one new entry per published article, with real topical clusters underneath — in practice somewhere past 150 articles. Below that line the script's review cost is larger than the map's maintenance cost, and you are automating the cheaper half of the job.

Whatever you build, emit candidates and never edits. The commit stays human. Version one wrote files directly, which is why the first thing we did after reviewing it was git checkout .

Anchor on the sentence that asserts something, and link to the page that verifies it. A useful side effect: if a target page has no checkable claim to link to, that is a signal about the target page, not about the linker.


Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Top comments (0)