My AI Content Got Flagged as Templated. The Fix Was N-gram Math.

dev.to

Google doesn't read your articles. It fingerprints them.

Earlier this year, a batch of comparison articles across my B2B sites got hit with the quietest penalty in SEO: nothing at all. Indexed fine, rendered fine, ranked nowhere. Average position 76 across five domains, three months, ~50,000 impressions, seven clicks.

When I finally stopped looking at rankings and started looking at my own content, I found something embarrassing. My AI-generated articles shared sentence-level fingerprints with each other. Not plagiarism — every article passed every plagiarism checker as "unique." But structurally, hundreds of them were the same article wearing different words.

This is how I found the fingerprints, reverse-engineered the math that detects them, and rebuilt the generation pipeline until every pair of articles was measurably distinct. Working code included.

What "templated" means to an algorithm

Duplicate detection at scale doesn't compare whole documents. It compares n-grams — sliding windows of n consecutive words — usually as a "shingle" set. Two documents are "near-duplicates" when their shingle sets overlap heavily, measured by Jaccard similarity:

Jaccard(A, B) = |A ∩ B| / |A ∪ B|
Enter fullscreen mode Exit fullscreen mode

An 8-gram is a good window for sentence-level work: long enough that a shared 8-word sequence is almost never coincidence, short enough that it catches partial rewrites. If two paragraphs share most of their 8-grams, they're the same paragraph for ranking purposes — no matter that the words around them differ.

Here's the entire detector, which is the point — the math is simple enough that there's no excuse for not running it on your own content:

import re
from pathlib import Path

def ngrams(text: str, n: int = 8) -> set[str]:
    words = re.sub(r"[^\w ]", "", text.lower()).split()
    return {"".join(words[i : i + n]) for i in range(len(words) - n + 1)}

def jaccard(a: set, b: set) -> float:
    return len(a & b) / len(a | b) if (a | b) else 0.0
Enter fullscreen mode Exit fullscreen mode

Run it across every pair of same-position paragraphs in a content batch, and you get a heatmap of how templated your corpus actually is.

What the detector found in my content

I write "X vs Y" comparison articles. The generation pipeline had a FAQ block, a verdict block, and a few recurring analysis blocks — each with a handful of pre-written variants the pipeline rotated through.

The scan results, across ~300 articles on three sites:

  • Cross-article paragraphs with Jaccard = 1.00: several hundred. Identical sentences, verbatim, across dozens of articles.
  • After my first fix (more variants): still dozens of pairs at Jaccard ≥ 0.5 — the threshold where near-duplicate detection gets interested.
  • The structural skeleton (H2/H3 sequence) of my "vs" articles was 100% identical across every article in the category.

Every article was "unique." The corpus was a Xerox machine.

Fix attempt #1: more variants (and why it failed)

The obvious fix: write more variants per block, rotate them. I went from 3 variants to 6.

The scan barely moved. Two lessons fell out of the data:

Long slots dominate the fingerprint. One block had a single 20-word sentence slot with 6 variants. But a 20-word slot contributes 13 of its own 8-grams — roughly 60–70% of the paragraph's entire shingle set. With 6 variants across 70+ articles, dozens of pairs shared a variant and collided on most of their fingerprints. Six options, seventy articles, one shared long slot: the math was doomed before I wrote a single new variant.

Rotation creates only k distinct skeletons. article_index % k means with k variants, you have exactly k distinct articles, repeated forever. Jaccard between two articles sharing a variant: high. Guaranteed.

The fix that worked: short slots, combinatorial space

The winning design flips the unit of variation. Instead of varying whole paragraphs, split each paragraph into 3–4 short slots (each ≤ 12 words), give each slot 3–6 independent phrasings, and select per-article with a hash:

import hashlib

def pick(options: list[str], article_slug: str, slot_name: str, reseed: int = 0) -> str:
    key = f"{article_slug}:{slot_name}:{reseed}".encode()
    h = int(hashlib.md5(key).hexdigest(), 16)
    return options[h % len(options)]
Enter fullscreen mode Exit fullscreen mode

The combinatorics do the rest. A 4-slot paragraph with 3–6 options per slot yields 216–1,296 distinct combinations. For 70 articles, the space is 3–18x larger than the demand — plenty of room for every article to be structurally different from every other.

Rule of thumb I'd now tattoo on the inside of every content pipeline: the slot-combination space must exceed the article count with margin, or you've built a rotation, not a generator.

The constraint solver that almost broke me

Per-slot independence isn't quite enough. Two articles can still collide by unlucky draws — same slot options in the same order. So generation became a constraint problem: assign every article a combination such that every pair stays under a Jaccard threshold (I targeted < 0.5, with < 0.3 as the comfort zone).

My first approach was greedy: generate all articles, find conflicting pairs, re-seed the loser, repeat. It oscillated forever — the conflict count bounced between 5 and 10 for over a thousand reseed iterations. Classic local-search thrash: fixing one pair broke another.

What converged was embarrassingly simple: sequential construction.

def resolve(articles: list, slots: dict, max_j: float = 0.5) -> dict:
    chosen = {}          # slug -> (fingerprint set, combo)
    for slug in articles:
        for reseed in range(200):          # per-article attempts
            combo = [pick(slots[s], slug, s, reseed) for s in slot_names]
            fp = ngrams("".join(combo))
            if all(jaccard(fp, chosen[o][0]) < max_j for o in chosen):
                chosen[slug] = (fp, combo)
                break
        else:
            raise RuntimeError(f"no valid combo for {slug}")
    return chosen
Enter fullscreen mode Exit fullscreen mode

Place articles one at a time. Each new article checks itself against the already-placed set only, and once placed, never moves. No oscillation is possible because nothing ever gets un-fixed. Deterministic, fast, and it converged on the first pass for every site.

Greedy restart kept thrashing because it re-opened solved articles. Sequential construction works because it never does.

The results

After rebuilding all three sites' recurring blocks on short-slot combinations:

Metric Before After
Paragraph pairs at J ≥ 0.5 dozens 0
Global max pairwise Jaccard 1.00 0.48
Verbatim-duplicate paragraphs hundreds 0

Zero pairs above the near-duplicate threshold. The remaining sub-0.5 pairs are legitimately different sentences sharing an 8-gram or two — exactly what honest topically-similar content looks like.

What I take from this

  1. AI batch generation is a template bomb by default. Every article is unique; the corpus is a photocopy. Plagiarism checkers will never catch it; n-gram math catches it in an afternoon.
  2. Run the detector before Google does. The scan is 15 lines of Python. If you generate content at any volume and haven't run it on your own corpus, you're guessing.
  3. Vary short slots, not long blocks. The unit of variation determines the fingerprint surface. A 20-word slot is a fingerprint; a 10-word slot is a choice.
  4. Sequential construction beats greedy restart for any "assign without conflicts" problem. Fix what's placed; never reopen it.

The deeper lesson cuts against how most AI content tools work: a template with slots isn't a feature, it's a liability. The pipeline that replaced all this doesn't have fixed blocks at all — it reads the live SERP for each keyword and drafts from that pattern, so every article's structure is derived from what actually ranks for that query, not from a shared skeleton. That tool is SerpCraft — free tier, no card, and the SERP analysis page works even if you never let it write a word.

If you've run n-gram analysis on your own AI content, I'd like to hear how bad it was. It was worse than I expected, every time I looked.

Source: dev.to

arrow_back Back to News