The Duplicate Pages Your Crawler Cannot See

Every site audit tool will tell you about duplicate content. What it means is pages whose words overlap: the same paragraph on two URLs, a location page templated across forty towns, a product description pasted from the manufacturer.

That is one kind of duplicate. It is not the kind that costs a client money.

The expensive kind is two pages written months apart, by different people, that answer the same question in completely different words. Nothing about their text overlaps. They compete with each other in the index, split their own links, and no word-comparison tool will ever put them side by side.

MDDB ships three detectors, and the interesting part is where they disagree.

A client site, nine pages

Here is a corpus small enough to check by hand. A running shop's blog:

Page
how-to-choose-running-shoes buying guide: gait, cushioning, drop, sizing
picking-the-right-trainers the same advice, rewritten from scratch
running-shoes-guide a longer guide
running-shoes-guide-copy byte-identical to the one above
store-krakow location page
store-warsaw the same location page, city name swapped
clean-running-shoes care guide
marathon-training-plan training
trail-vs-road comparison

Three planted problems, each a different shape. Load it and ask:

curl -s -X POST localhost:11023/v1/find-duplicates \
  -H 'Content-Type: application/json' \
  -d '{"collection":"client-site","mode":"both","threshold":0.9}'
{
  "totalDocuments": 9,
  "totalEmbedded": 9,
  "exactGroups": [
    {"groupId": 1, "type": "exact", "score": 1,
     "documents": [
       {"key": "running-shoes-guide-copy", "contentHash": "064fe82d79d08ae4"},
       {"key": "running-shoes-guide",      "contentHash": "064fe82d79d08ae4"}
     ]}
  ],
  "similarGroups": [
    {"groupId": 1, "score": 1,
     "documents": [{"key": "running-shoes-guide-copy"}, {"key": "running-shoes-guide"}]},
    {"groupId": 2, "score": 0.91372794,
     "documents": [{"key": "store-krakow"}, {"key": "store-warsaw"}]}
  ],
  "exactDuplicates": 2,
  "similarPairs": 2,
  "searchStats": {"durationMs": 0.71, "indexSize": 9}
}

Two of the three planted problems, in 0.71 ms. The identical guide is caught by its content hash — free, exact, no embeddings involved. The two location pages come in at 0.9137 similarity.

The rewritten buying guide is not there. It is the one that matters, and at the default threshold it does not appear.

The threshold is the whole story

Drop it and watch:

Threshold What comes back
0.90 the identical pair (1.0000), the two location pages (0.9137)
0.85 the above, plus how-to-choose-running-shoes + picking-the-right-trainers at 0.8611
0.80 everything collapses: the guide and both how-to pages become one group of four at 0.8504 — 7 pairs instead of 3

At 0.85 the detector names the pair a crawler cannot: two pages, no shared sentences, one intent. At 0.80 it stops distinguishing "the same page twice" from "two pages about running shoes", and you get a blob you have to re-read by hand.

The useful window on this corpus is 0.85 to 0.90. On yours it will be somewhere else, which is the point of being able to move it: run it at three thresholds on a client site you already know well, see where the results stop being obvious and start being wrong, and use that number for the rest of the estate.

What word overlap can and cannot do

The third detector, minhash, compares the words themselves rather than the meaning. Same corpus:

Threshold Found
0.7 the byte-identical pair
0.6 the identical pair and the two location pages
0.4 the same two. Nothing more.

It never finds the rewritten guide. Not at 0.4, not at any threshold, because those two pages genuinely do not share their words — that is what "rewritten" means. Meanwhile it finds the templated location pages instantly and cheaply, without needing a single embedding.

So the two detectors are not ranked. They see different things:

  • minhash — copy-paste, templating, syndicated manufacturer text, a page forked per city or per environment. No embeddings needed at all.
  • similar — the same intent in different words. Needs embeddings, needs a threshold you have calibrated, finds the thing nothing else finds.
  • exact — free, instant, and worth running on every collection you own.

Run minhash first: it costs nothing to configure and it clears out the mechanical duplication. What is left after that is the editorial problem.

Does it scale to a real estate?

An all-pairs comparison is quadratic, so the honest question is where that starts to hurt. Measured on the same instance, local Ollama embeddings (nomic-embed-text, 768 dimensions):

Collection Documents Mode Time
one client blog 300 exact 0.56 ms
one client blog 300 similar 5.2 ms
an estate 4,000 similar ~900 ms
an estate 4,000 minhash ~750 ms

Four thousand pages compared against each other, under a second. For agency work that means the scan is not the constraint — reading the output is.

One caveat on those larger numbers: the 4,000-page corpus was generated, and generated pages resemble each other far more than real ones do, so the pair counts from that run say nothing about a real site. The timings are real; the duplicate counts are an artefact of the generator, and I would rather say so than quote a number that flatters the tool.

Getting a client site in

For WordPress, the path already exists:

WordPress → wpexportjson → MDDB → find_duplicates

The WordPress Website Analyzer guide covers the export end to end.

Check your embeddings before you trust the similar results. A large import can outrun the embedding queue, and today the ingest API will tell you "failed": 0 while a portion of the collection has no vectors at all (issue #232 — a bulk load of 4,000 documents left 1,002 embedded and reported success). One request tells you where you stand:

curl -s localhost:11023/v1/vector-stats | jq '.collections'
# {"client-site": {"embedded_documents": 9, "total_documents": 9}}

If those two numbers disagree, POST /v1/vector-reindex with {"force": true} fills the gaps. A similar scan over a half-embedded collection is not wrong so much as quiet — it compares the documents it has.

Asking it from an agent

find_duplicates is also one of the 81 tools on MDDB's built-in MCP server, so the whole thing is available to Claude, Cursor or your own agent without an integration to write:

Scan the client-site collection for duplicates at 0.85 and group them by what kind of problem each one is.

Since 2.14 the MCP server speaks the stateless 2026-07-28 revision alongside 2025-11-25, so an agent can ask this without a handshake and without a session — useful when the thing asking is a scheduled job rather than a chat window.

What this does not do

It does not tell you which page to keep. That is the part of the job that is still yours: consolidate, redirect, canonicalise or leave alone, depending on links, traffic and what the client actually sells.

What it does is turn "we think there is some duplication on this site" into a list of page pairs with a number against each one — in under a second, for a site large enough that nobody was going to read it by hand.