Back to Streaming Sketching

Similarity & Deduplication Service

September 28, 2026 Wasil Zafar 21 min read

Create a streaming near-duplicate detection service.

Table of Contents

  1. Define Duplicate, Similar and Acceptably Different
  2. Design the Signature and Candidate Pipeline
  3. Build Indexing, Verification and Feedback Loops
  4. Measure Recall, Cost and Adversarial Failures
  5. Deliver the Deduplication Service

Define Duplicate, Similar and Acceptably Different

A similarity and deduplication service is like a librarian deciding whether a newly delivered book is already in the catalogue. An identical barcode is easy; a revised cover, corrected spelling, reordered paragraph, or resized image requires evidence. The service turns each item into comparable features, retrieves a small candidate set, verifies candidates with a declared similarity rule, and records a decision rather than comparing every new item with everything ever seen.

Take two token sets: A={red, fox, runs, fast} and B={red, fox, runs, today}. Their intersection has 3 elements and their union has 5, so Jaccard similarity is 3/5 = 0.60. If policy says “near duplicate at 0.80 or above,” they are acceptably different even though an approximate signature might place them in the same candidate bucket. Conversely, failing to generate a true candidate is a false negative that exact reranking cannot repair.

A shingle is a contiguous token or character sequence, such as word bigram red fox. A set signature such as MinHash estimates Jaccard similarity; a vector signature such as SimHash approximates angular similarity. Locality-sensitive hashing (LSH) groups likely neighbours into shared buckets. A membership filter can cheaply say “definitely unseen” or “possibly seen” under its contract. Verification computes the authoritative business metric over retained canonical features. The index lifecycle covers insert, tombstone, compaction, rebuild, migration, and rollback.

Stakeholders include content owners, storage engineers, search/recommendation teams, abuse analysts, privacy reviewers, and operators. Acceptance requires versioned normalization and signatures, tenant isolation, bounded candidate counts and memory, deterministic replay, measured recall at the decision threshold, exact verification before destructive action, explainable outcomes, and reversible merges or quarantines.

From librarian analogy to a deduplication pipelineA new document becomes shingles and a signature, retrieves candidates, and is exactly verified before keep, link, or quarantine. New item“red fox runs…” Fingerprintshingles → signatureversioned normalization CandidatesLSH bucketsbounded shortlist Verifyexact scorepolicy ≥ τ Keep distinctLink duplicatereversible recordQuarantinehuman review Approximation narrows search; verification and policy determine the outcome.
TermIntuitive meaningExact exampleNon-exampleAssumptionQuestion answered
NormalizationStable canonical representationUnicode NFC, lowercase, whitespace collapseRemoving all numbers without policyVersion is fixed and auditableWhat differences should be ignored?
ShingleLocal feature from contentWord bigram red foxWhole document IDTokenization and width are stableWhich local fragments overlap?
Jaccard similaritySet overlap divided by union3/5 = 0.60Cosine of term countsPresence/absence features are intendedHow similar are two shingle sets?
MinHashCompact Jaccard estimatorEqual minima in 78 of 100 rows ≈ 0.78Cryptographic content hashHashes behave independently enoughWhich set pairs are likely similar?
SimHashCompact angular signatureHamming-near fingerprintsExact byte equalityFeature weights and bit hash are stableWhich weighted vectors point similarly?
LSH candidatePair worth exact comparisonTwo IDs sharing a MinHash bandConfirmed duplicateBand parameters match index versionWhom should we compare?
Membership filterCheap pre-check“Definitely not in exact-ID set”Proof that content is uniqueFalse-positive/no-false-negative contract holdsCan work be skipped safely?
Exact rerankingAuthoritative score and policyJaccard 0.91 ≥ τ=0.85LSH bucket collisionCanonical features remain availableDoes this pair meet the decision rule?
TombstoneReversible index deletion markerID excluded until compactionImmediate physical erasure everywhereReplicas observe ordered epochsShould an old item remain discoverable?
Warm-up

Define Duplicate, Similar and Acceptably Different

Compare A={red, fox, runs, fast}, B={red, fox, runs, today}, and C={database, backup}. Checkpoint 1: A∩B has 3 elements and A∪B has 5, so J(A,B)=0.60. Checkpoint 2: A∩C=∅, hence J(A,C)=0. With candidate threshold 0.50 and duplicate threshold 0.80, B should be retrieved for A but must remain distinct after verification; C need not be a candidate. Expected outcome: no duplicate links. Reflection: why would treating candidate threshold 0.50 as the final decision cause destructive false positives?

Design the Signature and Candidate Pipeline

The service accepts up to 20,000 documents per second across isolated tenants, with 10 million active items per large tenant, candidate lookup below 50 ms p99, final decision below 200 ms p99, and a fixed per-item index budget. Producers send {tenant_id, item_id, content_uri, media_type, source_time, normalization_version, idempotency_key}. The gateway authenticates, validates size/type, resolves governed content, and writes a replayable command. Content is normalized, shingled, signed, optionally screened by compact filters, inserted into versioned LSH bands, and verified against a capped candidate set.

For text, word or character shingles feed 128-row MinHash; 32 bands of 4 rows give candidate probability 1-(1-s^4)^32 at true Jaccard similarity s. Parameters are calibrated from a labelled corpus rather than selected by folklore. SimHash is available for weighted bag-of-feature cosine-like workloads, but not mixed silently with MinHash. Exact Jaccard verification uses retained canonical shingle sets or a governed feature store. Every result reports candidate method/version, exact score, threshold/policy version, explanation, and decision state.

Privacy limits prohibit cross-tenant candidate generation, restrict raw-content access, encrypt features and snapshots, minimize retained shingles, and audit reviews. The threat model includes adversarial token stuffing, hash flooding, oversized content, crafted bucket explosions, duplicate command replay, malicious feedback, poisoned thresholds, snapshot tampering, and operator credential misuse. Resource guards cap shingles, candidates, buckets per item, verification bytes, and per-tenant index growth before allocation.

Similarity & Deduplication Service: Design the Signature and Candidate Pipeline
flowchart TD
    P[Content producers] --> G[Auth validate quota]
    G --> N[Normalize and shingle]
    N --> S[MinHash or SimHash]
    S --> F[Compact membership screen]
    F --> L[Versioned LSH index]
    L --> C[Bounded candidates]
    C --> V[Exact verifier]
    V --> D[Keep link or review]
    D --> X[Index and decision log]
    O[Operators] --> R[Policies rollout feedback]
    R --> V
    X --> M[Metrics traces audit]
    classDef navy fill:#17365D,color:#fff,stroke:#102844;
    classDef teal fill:#147D84,color:#fff,stroke:#0c5c61;
    classDef crimson fill:#C6284D,color:#fff,stroke:#8e1935;
    classDef purple fill:#6C3BAA,color:#fff,stroke:#482675;
    class P,O navy;
    class G,N,S,F teal;
    class L,C,X purple;
    class V,D,R,M crimson;
Deduplication system context and trust boundariesTenant producers cross an authenticated boundary into isolated signature partitions; feature storage, source content, and operator control occupy separate trust and failure domains. Authenticated tenant data plane Producerscontent references Gatewayauth • size • policy Partition Asignatures + LSHtenant range 1 Partition Bsignatures + LSHtenant range 2 Candidate + verification APIscoped evidence Feature storeencrypted failure domain Content storegoverned raw bytes Operatorscontrol boundary Dashed enclosure is the service trust boundary; partitions, stores, and control plane fail independently.
CandidateGuaranteeScale / latencyMemoryOperational burdenPrivacyRecoveryDecision
All-pairs exact JaccardExact under canonical featuresO(N) comparisons per insertStores all featuresSimple logic, impossible scaleBroad feature accessRebuild from featuresKeep as small-corpus oracle only
Exact content hashExact byte/canonical equalityO(1) lookupOne digest/itemLowDigest can reveal known contentReplayable mapSelect as first exact-duplicate tier
MinHash + banded LSHProbabilistic candidate recall for JaccardSublinear retrieval; tunable bucket workSignatures + postingsCalibrate bands and versionsSignatures may leak resemblanceRebuild versioned indexSelect for set-based near duplicates
SimHash + Hamming searchAngular-near weighted featuresFast for short fingerprintsFingerprint + multi-indexRadius explosion at high distanceFingerprint leakageRebuild by versionSelect only for vector-like workloads
Bloom/Cuckoo filterCheap membership screen; configured errorsConstant expected workFixed/boundedCapacity and deletion semanticsMembership inference riskRebuild from authoritative IDsSelect as optimization, never final evidence
LSH-only decisionBucket collision onlyFastestSmallHidden false positivesMinimal verification accessEasyReject: candidates are not decisions
LSH + exact rerankingMeasured retrieval recall plus exact policy scoreBounded candidate verificationIndex + canonical featuresMore components, auditableLeast raw access compatible with proofReplay index; preserve decisionsSelect as production architecture
Learned embedding ANNSemantic proximity, model-dependentHigh recall at tuned costDense vectors + ANN graphModel drift and GPU/index lifecycleEmbeddings may encode sensitive dataVersioned full rebuildRoadmap after labelled-policy evidence

The central invariant is that a candidate generator may only broaden or narrow the verification workload; it can never authorize deletion or merging. Launch requires tenant-isolated, version-compatible indexes, capped candidate work, measured recall, exact policy verification, idempotent decisions, and reversible outcomes.

Build Indexing, Verification and Feedback Loops

Build vertical slices. Slice 1 normalizes text, calculates an exact content hash, and compares every small-corpus item with exact Jaccard. Slice 2 adds deterministic MinHash and an LSH candidate index while retaining the oracle. Slice 3 introduces compact membership filters, partitioning, snapshots, tombstones, and rebuilds. Slice 4 adds policy decisions, explanations, reviewer feedback, and a shadow index for migrations. Each slice demonstrates ingest, query, verification, deletion, restore, and rollback independently.

Stable interfaces are upsert(item), candidates(item, limit), verify(left,right,policy), decide(item), tombstone(id,epoch), snapshot(), and restore(bytes). Configuration validates shingle size, signature length, band divisibility, threshold range, maximum shingles/candidates/bucket size, seeds, and format version before allocating. Serialized state records normalization, hash, signature, LSH, and policy versions plus tenant, epoch, checksum, and encryption metadata. Unsupported migrations rebuild from canonical features rather than coercing state.

Feedback is evidence, not truth by fiat. Reviewer labels are immutable events with actor, rationale, policy, and source versions. They feed threshold evaluation and labelled regression sets only after quality checks. Safe degradation can run exact-hash-only mode, quarantine a result, or reject over-budget content. It must not silently lower the threshold, cross tenant boundaries, or convert “no candidate found” into proof of uniqueness.

from dataclasses import dataclass
from hashlib import blake2b
from collections import defaultdict
import re

def stable_hash(seed: int, value: str) -> int:
    return int.from_bytes(blake2b(f"{seed}:{value}".encode(), digest_size=8).digest(), "big")

def shingles(text: str, width: int) -> frozenset[str]:
    tokens = re.findall(r"[a-z0-9]+", text.lower())
    return frozenset(" ".join(tokens[i:i+width]) for i in range(max(0, len(tokens)-width+1)))

def jaccard(a: frozenset[str], b: frozenset[str]) -> float:
    return len(a & b) / len(a | b) if a or b else 1.0

@dataclass(frozen=True)
class Config:
    shingle_width: int = 2
    rows: int = 32
    bands: int = 8
    threshold: float = 0.75
    max_shingles: int = 10_000
    max_candidates: int = 100
    seed: int = 17
    format_version: int = 1

    def validate(self) -> None:
        if not 1 <= self.shingle_width <= 8:
            raise ValueError("invalid shingle width")
        if self.rows <= 0 or self.bands <= 0 or self.rows % self.bands:
            raise ValueError("rows must divide evenly into bands")
        if not 0.0 <= self.threshold <= 1.0:
            raise ValueError("invalid threshold")
        if not 1 <= self.max_candidates <= 10_000 or not 1 <= self.max_shingles <= 1_000_000:
            raise ValueError("unsafe resource limit")
        if self.format_version != 1:
            raise ValueError("unsupported format version")

class Deduplicator:
    def __init__(self, config: Config):
        config.validate()
        self.config = config
        self.features: dict[str, frozenset[str]] = {}
        self.signatures: dict[str, tuple[int, ...]] = {}
        self.index: dict[tuple[int, tuple[int, ...]], set[str]] = defaultdict(set)

    def signature(self, features: frozenset[str]) -> tuple[int, ...]:
        if not features:
            return tuple(0 for _ in range(self.config.rows))
        return tuple(min(stable_hash(self.config.seed + row, token) for token in features)
                     for row in range(self.config.rows))

    def _band_keys(self, signature: tuple[int, ...]):
        size = self.config.rows // self.config.bands
        for band in range(self.config.bands):
            yield band, signature[band*size:(band+1)*size]

    def upsert(self, item_id: str, text: str) -> list[dict]:
        if not item_id or item_id in self.features:
            raise ValueError("item id must be new and non-empty")
        features = shingles(text, self.config.shingle_width)
        if len(features) > self.config.max_shingles:
            raise MemoryError("shingle budget exceeded")
        sig = self.signature(features)
        candidate_ids = set()
        for key in self._band_keys(sig):
            candidate_ids.update(self.index[key])
        if len(candidate_ids) > self.config.max_candidates:
            raise RuntimeError("candidate budget exceeded; quarantine item")
        results = [{"candidate": cid, "score": jaccard(features, self.features[cid])}
                   for cid in sorted(candidate_ids)]
        results.sort(key=lambda x: (-x["score"], x["candidate"]))
        self.features[item_id], self.signatures[item_id] = features, sig
        for key in self._band_keys(sig):
            self.index[key].add(item_id)
        return [{**r, "duplicate": r["score"] >= self.config.threshold,
                 "policy_threshold": self.config.threshold,
                 "algorithm": "minhash_lsh_then_exact_jaccard"} for r in results]

def self_test() -> None:
    cfg = Config(threshold=0.75, rows=32, bands=8, seed=19)
    service = Deduplicator(cfg)
    assert service.upsert("A", "red fox runs very fast") == []
    result = service.upsert("B", "red fox runs very fast today")
    assert result and result[0]["candidate"] == "A"
    assert result[0]["score"] == 4/5 and result[0]["duplicate"] is True
    assert service.upsert("C", "database backup completed nightly") == []
    assert service.signature(service.features["A"]) == Deduplicator(cfg).signature(service.features["A"])
    try:
        Deduplicator(Config(rows=31, bands=8))
    except ValueError:
        pass
    else:
        raise AssertionError("incompatible band layout accepted")
    try:
        service.upsert("A", "replacement")
    except ValueError:
        pass
    else:
        raise AssertionError("non-idempotent overwrite accepted")
    print("integration checks passed", result)

if __name__ == "__main__":
    self_test()
Similarity & Deduplication Service: Build Indexing, Verification and Feedback Loops
flowchart TD
    C[Contracts and exact hash] --> O[All-pairs Jaccard oracle]
    O --> S[Deterministic signatures]
    S --> L[LSH candidate index]
    L --> V[Exact policy verifier]
    V --> I[Lifecycle and snapshots]
    I --> F[Reviewed feedback loop]
    F --> K[Shadow index canary]
    K -->|Recall and SLO pass| P[Promote version]
    K -->|Regression or drift| R[Rollback index]
    R --> O
    classDef navy fill:#17365D,color:#fff,stroke:#102844;
    classDef teal fill:#147D84,color:#fff,stroke:#0c5c61;
    classDef crimson fill:#C6284D,color:#fff,stroke:#8e1935;
    classDef purple fill:#6C3BAA,color:#fff,stroke:#482675;
    class C,O navy;
    class S,L,V,I teal;
    class F,K purple;
    class P,R crimson;
Build

Build Indexing, Verification and Feedback Loops: Similarity & Deduplication Service

Create normalize.py, signatures.py, index.py, verify.py, and test_replay.py. Insert A=red fox runs very fast; checkpoint 1 records four word-bigram shingles. Insert B=red fox runs very fast today; checkpoint 2 expects exact Jaccard 4/5=0.80 and a duplicate decision at threshold 0.75. Insert unrelated C=database backup completed nightly; expect no verified match. Re-run with the same seed and require byte-identical signatures. Compare LSH candidates with all-pairs exact results over a fixed labelled corpus, then rebuild a shadow index from the event log and compare postings. Try 31 rows with 8 bands, reuse an item ID, exceed the shingle cap, corrupt a snapshot, and mix normalization versions; all must fail before visible mutation. Done means deterministic replay, measured candidate recall, exact decisions, bounded work, lifecycle recovery, and auditable feedback pass in one command.

Measure Recall, Cost and Adversarial Failures

Verification treats candidate recall and decision precision as separate contracts. Unit tests cover normalization, shingle boundaries, deterministic hashing, band slicing, exact scoring, idempotency, tombstones, and serialization. Property tests assert Jaccard symmetry and bounds, identical inputs score 1, signature equality estimates set similarity across seeds, compatible rebuilds reproduce postings, and tenant IDs never share buckets. Differential replay compares LSH candidates and final decisions with all-pairs exact oracles on manageable corpora.

The approachable case uses A, B, and C from the build exercise. The professional workload contains 10 million items per large tenant, 20,000 inserts/s, multilingual text, templated pages, OCR noise, boilerplate, adversarial token padding, and labelled duplicate clusters near the threshold. Targets are candidate recall ≥99.5% for exact Jaccard ≥0.85, verified-decision precision ≥99.9%, candidate p99 <50 ms, end-to-end p99 <200 ms, availability ≥99.9%, candidate cap ≤100, and a published cost per million items.

Fault injection kills an indexer during checkpoint, delays a partition, corrupts postings, rotates a seed without a version bump, fills one hot bucket, removes the feature store, replays commands, and poisons feedback labels. Metrics expose ingest lag, bucket size, candidates/query, cap rejections, verification score distribution, labelled recall/precision, shadow-index disagreement, tombstone lag, snapshot age, and cross-version attempts. Traces connect normalization, signature, bands, candidate reads, exact verification, and decision. Audit evidence preserves policy/version, reviewer action, explanation, rollback, and content-access purpose.

Verification and observability surfaceExact tests, labelled replay, load and faults feed metrics, traces, alerts, and audit evidence for a promote, hold, or rollback decision. Exact + property testsscores • versions • isolation Labelled replay + loadrecall • precision • cost Faults + shadow indexloss • corruption • drift Metrics + tracescandidate and system error Audit evidencepolicy • review • access Alertsrecall • caps • versions Evidence bundledossier + timeline Operatorpromote • holdrollback Evidence distinguishes retrieval misses, policy errors, and infrastructure failures before action.
Requirement / guaranteeOracleWorkloadMetric and thresholdFailure injectionEvidenceOwner
Normalization is deterministicGolden canonical stringsUnicode, punctuation, OCR casesByte-identical output for a versionLocale/library changeGolden diff and dependency lockText pipeline owner
Candidate recall is adequateAll-pairs exact JaccardLabelled threshold-near corpusRecall ≥99.5% for Jaccard ≥0.85Seed/band mismatchRecall curve by cohort/versionAlgorithms reviewer
Final decisions are preciseHuman-reviewed/exact policy labelsDuplicate and hard-negative pairsPrecision ≥99.9%; no LSH-only decisionVerifier unavailableDecision audit and confusion matrixProduct owner
Work remains boundedCandidate and allocator countersHot templates, token stuffingCandidate cap ≤100; reject before memory breachBucket explosionCap metrics and heap profilePlatform lead
Latency SLOLoad-generator clock20k inserts/s, 10M-item tenantCandidate p99 <50 ms; total p99 <200 msSlow shard and feature storeDashboard and tracesSRE
Version isolationCompatibility oracleDual-write migrationZero mixed signatures/policiesSeed rotation without version bumpRejection logs and shadow diffIndex owner
Lifecycle correctnessEvent-log replayUpsert, tombstone, compact, rebuildRebuilt active set and postings match oracleCrash during compactionReplay hashes and tombstone auditStorage owner
Recovery is safeSnapshot checksum + replayIndexer loss during checkpointRTO <30 min; corrupt state quarantinedKill process, flip posting byteRecovery timelineIncident commander
Tenant/privacy isolationAuthorization oracleCross-tenant and sensitive-content probesZero cross-tenant candidates; all raw access auditedForged tenant tokenSecurity report and audit exportSecurity owner
Feedback resists poisoningCurated reviewer consensusConflicting and burst labelsUntrusted labels never auto-change policyCompromised reviewer accountLabel lineage and approval recordGovernance owner
Production

Measure Recall, Cost and Adversarial Failures: Similarity & Deduplication Service

Run a 40-minute trial over a labelled multilingual corpus with exact duplicates, paraphrase-like non-duplicates, OCR noise, boilerplate, and pairs concentrated around Jaccard 0.85. Ramp from 5k to 20k then 30k inserts/s while querying a 10M-item tenant. Require candidate recall ≥99.5% for true score ≥0.85, decision precision ≥99.9%, candidate p99 <50 ms, total p99 <200 ms, candidates ≤100, and zero tenant/version violations. At minute 12 create a hot template bucket; expect bounded quarantine. At minute 20 kill an indexer during checkpoint; expect replay recovery. At minute 28 corrupt postings and rotate a seed without a version bump; expect checksum/compatibility rejection and shadow disagreement alerts. Roll back on any destructive false positive, isolation failure, recall below target, sustained p99 >300 ms, or unsafe cap bypass. Record GO, CONDITIONAL GO, or NO-GO with curves, traces, replay hashes, cost per million items, named owners, and expiry dates.

Deliver the Deduplication Service

The release bundle contains API and event schemas, normalization and feature specifications, signature/LSH parameter calculators, policy examples, a pinned environment, labelled datasets and lineage, benchmark commands and raw results, the correctness dossier, migration matrix, privacy/security review, dashboards, alerts, and an operational runbook. Every artifact states version, owner, generation command, review date, and retention rule.

The runbook begins with symptoms—candidate explosion, recall drift, latency, index disagreement, tombstone lag, feature-store outage, mixed versions—and maps each to diagnostics, thresholds, safe degradation, escalation, rollback, and evidence preservation. The demonstration normalizes A/B/C, shows exact hashes and shingles, retrieves B as A's candidate, verifies 4/5, keeps C distinct, rejects an incompatible layout, tombstones and rebuilds an item, and finishes with labelled recall/cost dashboards.

Peer reviewers ask: Which differences does normalization erase? Can candidate recall be reproduced by cohort? Is every destructive outcome exactly verified and reversible? What happens when canonical features are unavailable? Can a hot bucket cross the resource budget? Are feedback labels governed? Could signatures reveal sensitive content? Handoff requires a tagged build, checksums, one-command verification, benchmark provenance, signed correctness/privacy dossiers, rehearsed rebuild/rollback, on-call ownership, and no undocumented bypass.

Deliverable

Deliver the Deduplication Service: Similarity & Deduplication Service

Final checklist: (1) build from a clean checkout; (2) run unit, property, labelled replay, load, security, and fault suites; (3) publish schemas, normalization, thresholds, and explanations; (4) attach banding calculations and recall/precision curves; (5) archive corpora lineage, seeds, environment lock, commands, and raw benchmarks; (6) sign correctness, privacy, and feedback-governance dossiers; (7) rehearse rebuild, tombstone recovery, quarantine, and rollback; (8) confirm dashboard, alert, and on-call ownership. Demo A/B/C, candidate-versus-decision separation, exact score, cap rejection, incompatible-version rejection, checkpoint recovery, and shadow-index comparison. Score 0–2 for correctness, retrieval quality, contract clarity, reproducibility, bounded resources, observability/recovery, privacy/governance, and documentation; require ≥14/16 with no zero. Stretch goals: multimodal perceptual hashes, learned embeddings, active-learning review queues, and privacy-preserving signatures. Milestones: cohort calibration, dual-index migration automation, then governed multimodal matching.

Launch only with measured candidate recall, exact and reversible decisions, bounded work, version isolation, privacy controls, and rehearsed rebuild/rollback. Known risks include normalization bias, adversarial collisions, signature leakage, reviewer poisoning, and unequal error rates across languages; ask who bears the cost of a false merge, then make cohort-level calibration and appeal evidence the next milestone.