cd /news/artificial-intelligence/my-ai-content-got-flagged-as-templat… · home topics artificial-intelligence article
[ARTICLE · art-122375] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

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

A developer whose AI-generated comparison articles were silently penalized by Google discovered the cause was structural fingerprinting, not plagiarism. By analyzing n-gram Jaccard similarity across their corpus, they found that templated paragraph slots created near-duplicate sentence structures. The fix involved splitting paragraphs into short, independently varied slots selected via hashing, yielding thousands of distinct combinations and measurably distinct articles.

read5 min views2 publishedSep 7, 2026

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.

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|

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

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.

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:

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

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 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)]

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.

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

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.

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.

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.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @google 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/my-ai-content-got-fl…] indexed:0 read:5min 2026-09-07 ·