dedup

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT, MIT Imports: 10 Imported by: 0

README

dedup

Go Reference Go Report Card

Generic, dependency-free duplicate and near-duplicate detection for Go.

Point it at any slice, tell it how to read the text out of each item, and it groups the duplicates for you. Exact matches via content hashing; fuzzy matches via SimHash and Hamming distance. No database, no external services — just the standard library and generics.

d := dedup.NewDetector[Doc](dedup.NormalizedHash, func(x Doc) string { return x.Body })
d.Index(docs)
groups := d.FindDuplicates() // [][]Doc, each inner slice is one set of duplicates

Install

go get github.com/dotcommander/reliquary/dedup

Requires Go 1.26+. The only non-stdlib dependency is testify, used by tests.

Quick start

Find exact duplicates in a slice of your own type:

package main

import (
	"fmt"

	"github.com/dotcommander/reliquary/dedup"
)

type Doc struct {
	ID, Body string
}

func main() {
	docs := []Doc{
		{ID: "a", Body: "the quick brown fox"},
		{ID: "b", Body: "the quick brown fox"},
		{ID: "c", Body: "something else"},
	}

	d := dedup.NewDetector[Doc](dedup.SimpleHash, func(x Doc) string { return x.Body })
	d.Index(docs)

	for _, group := range d.FindDuplicates() {
		fmt.Printf("%d copies: %s\n", len(group), group[0].Body)
	}
	// 2 copies: the quick brown fox
}

Near-duplicates

SimHash is the only strategy that supports fuzzy matching. Index with it, then call FindNearDuplicates with a Hamming-distance threshold — items whose fingerprints differ by at most that many bits are grouped together.

sim := dedup.NewDetector[Doc](dedup.SimHash, func(x Doc) string { return x.Body })
sim.Index([]Doc{
	{ID: "a", Body: "the quick brown fox jumps over the lazy dog"},
	{ID: "b", Body: "the quick brown fox jumps over the lazy dog today"},
})

for _, group := range sim.FindNearDuplicates(5) {
	fmt.Printf("near-duplicate cluster of %d\n", len(group))
}

A threshold of 5 is a sensible default for short text. Lower it to demand closer matches; raise it to cast a wider net.

Hashing strategies

Pass one of these to NewDetector (or NewContentHasher):

Strategy Matches when… Near-dup?
SimpleHash content is byte-for-byte identical
NormalizedHash content is equal ignoring case and whitespace
SemanticHash the meaningful lines match after structural tagging
SimHash content is similar — within a Hamming threshold

Rule of thumb: start with NormalizedHash for "are these the same document?", reach for SimHash when you need "are these roughly the same?".

Stats, metadata, and ordering

Stats returns a typed summary of the last index — total items, unique hashes, duplicate groups, and a deduplication rate. FindDuplicateGroups returns the same duplicate item groups as FindDuplicates, plus the shared hash for each group. WithOrdering controls how items are sorted inside each group (chainable):

d := dedup.NewDetector[Doc](dedup.NormalizedHash, func(x Doc) string { return x.Body }).
	WithOrdering(func(a, b Doc) bool { return a.ID < b.ID })
d.Index(docs)

stats := d.Stats()
fmt.Printf("%d of %d items were duplicates\n", stats.DuplicateFiles, stats.TotalFiles)

for _, group := range d.FindDuplicateGroups() {
	fmt.Printf("%s has %d copies\n", group.Hash, len(group.Items))
}

GetStats remains available for callers that need the legacy map[string]any shape.

Choosing a canonical per group

Canonicalize collapses each duplicate group to a single survivor. Give it a better(a, b) predicate and it keeps the preferred element per group (ties keep the earlier one). CanonicalizeWith adds a merge step to fold losers into the winner first.

groups := d.FindDuplicates() // [][]Doc

// Keep the shortest body in each group.
keep := dedup.Canonicalize(groups, func(a, b Doc) bool {
	return len(a.Body) < len(b.Body)
})
fmt.Printf("%d unique survivors\n", len(keep))

Lifecycle and concurrency

  • Call order is NewDetector → optional WithOrderingIndex → query.
  • Index replaces the index every call; it never appends.
  • A Detector holds no locks. Index once on a single goroutine, then run as many concurrent reads (FindDuplicates, FindNearDuplicates, GetStats) as you like. Do not call Index while reads are in flight.

See docs/api-reference.md for the full API, edge cases, and the precise behavior of each strategy.

License

MIT © DotCommander contributors

Documentation

Overview

Package dedup provides generic duplicate and near-duplicate detection for text-like values using string hash strategies.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Canonicalize

func Canonicalize[T any](groups [][]T, better func(a, b T) bool) []T

Canonicalize reduces each duplicate group to a single representative. For each group it keeps the element for which better(candidate, current) reports the candidate as preferable, returning one survivor per group in input order. Empty groups are skipped; a singleton group yields that element unchanged. Pair this with Detector.FindDuplicateGroups / FindNearDuplicates.

func CanonicalizeWith

func CanonicalizeWith[T any](groups [][]T, better func(a, b T) bool, merge func(winner, loser T)) []T

CanonicalizeWith is Canonicalize plus a merge step: for every non-winner in a group, merge(winner, loser) is invoked before the loser is discarded — e.g. to fold a duplicate's metadata into the survivor. With a pointer element type, merge can mutate the winner in place. merge may be nil.

Types

type ContentHasher

type ContentHasher struct {
	// contains filtered or unexported fields
}

ContentHasher provides fast duplicate detection capabilities

func NewContentHasher

func NewContentHasher(strategy HashingStrategy) *ContentHasher

NewContentHasher creates a new content hasher with the specified strategy. SimHash shingle size and bit width default to 3-grams and 64 bits; override with WithSimHashOptions.

func (*ContentHasher) HashContent

func (ch *ContentHasher) HashContent(content string) string

HashContent generates a hash for the given content using the configured strategy

func (*ContentHasher) SupportsNearDuplicate

func (ch *ContentHasher) SupportsNearDuplicate() bool

SupportsNearDuplicate reports whether the strategy produces locality-sensitive hashes usable by FindNearDuplicates.

func (*ContentHasher) WithSimHashOptions

func (ch *ContentHasher) WithSimHashOptions(shingleSize, bits int) *ContentHasher

WithSimHashOptions configures the SimHash shingle (n-gram) size and bit width, returning the receiver for chaining (matching Detector.WithOrdering). Invalid values are clamped to the supported range rather than erroring, matching the package's tolerant input handling: shingleSize is clamped to a minimum of 1; bits is clamped to the range 1..64 (the internal FNV-64 / uint64 pipeline and Detector.hammingDistance cannot represent wider hashes).

type Detector

type Detector[T any] struct {
	// contains filtered or unexported fields
}

Detector groups items by content hash to find exact and near duplicates.

func NewDetector

func NewDetector[T any](strategy HashingStrategy, content func(T) string) *Detector[T]

NewDetector creates a new duplicate detector for items of type T.

func (*Detector[T]) FindDuplicateGroups

func (d *Detector[T]) FindDuplicateGroups() []DuplicateGroup[T]

FindDuplicateGroups returns groups of duplicate items with their shared hash.

func (*Detector[T]) FindDuplicates

func (d *Detector[T]) FindDuplicates() [][]T

FindDuplicates returns groups of duplicate items.

Example
package main

import (
	"fmt"

	"github.com/dotcommander/reliquary/dedup"
)

type Doc struct {
	ID   string
	Body string
}

func main() {
	docs := []Doc{
		{ID: "doc1", Body: "The quick brown fox"},
		{ID: "doc2", Body: "the quick brown fox"},
		{ID: "doc3", Body: "Something completely different"},
	}

	// Index using NormalizedHash (ignores case and whitespace differences)
	d := dedup.NewDetector(dedup.NormalizedHash, func(doc Doc) string {
		return doc.Body
	})
	d.Index(docs)

	for _, group := range d.FindDuplicates() {
		fmt.Printf("Duplicate group of %d items:\n", len(group))
		for _, doc := range group {
			fmt.Printf("  - %s: %s\n", doc.ID, doc.Body)
		}
	}
}
Output:
Duplicate group of 2 items:
  - doc1: The quick brown fox
  - doc2: the quick brown fox

func (*Detector[T]) FindNearDuplicates

func (d *Detector[T]) FindNearDuplicates(threshold int) [][]T

FindNearDuplicates finds items with similar hashes (for SimHash).

Example
package main

import (
	"fmt"

	"github.com/dotcommander/reliquary/dedup"
)

type Doc struct {
	ID   string
	Body string
}

func main() {
	docs := []Doc{
		{ID: "doc1", Body: "The quick brown fox jumps over the lazy dog"},
		{ID: "doc2", Body: "The quick brown fox jumps over the lazy dog today"},
		{ID: "doc3", Body: "Different text altogether"},
	}

	// Index using SimHash for near-duplicate (fuzzy similarity) matching
	d := dedup.NewDetector(dedup.SimHash, func(doc Doc) string {
		return doc.Body
	})
	d.Index(docs)

	// Group items within a Hamming distance threshold of 5
	for _, group := range d.FindNearDuplicates(5) {
		fmt.Printf("Near-duplicate cluster of %d items:\n", len(group))
		for _, doc := range group {
			fmt.Printf("  - %s: %s\n", doc.ID, doc.Body)
		}
	}
}
Output:
Near-duplicate cluster of 2 items:
  - doc1: The quick brown fox jumps over the lazy dog
  - doc2: The quick brown fox jumps over the lazy dog today

func (*Detector[T]) GetStats

func (d *Detector[T]) GetStats() map[string]any

GetStats returns statistics about the hashing results.

func (*Detector[T]) Index

func (d *Detector[T]) Index(items []T)

Index creates a hash index for all items.

func (*Detector[T]) Stats

func (d *Detector[T]) Stats() Stats

Stats returns statistics about the hashing results.

func (*Detector[T]) WithOrdering

func (d *Detector[T]) WithOrdering(less func(a, b T) bool) *Detector[T]

WithOrdering sets the ordering used to sort items within duplicate groups.

type DuplicateGroup

type DuplicateGroup[T any] struct {
	Hash  string
	Items []T
}

DuplicateGroup is a group of items that share the same content hash.

type HashingStrategy

type HashingStrategy string

HashingStrategy defines different approaches to content hashing

const (
	// SimpleHash - Basic content hash
	SimpleHash HashingStrategy = "simple"
	// NormalizedHash - Normalized text hash (whitespace, case insensitive)
	NormalizedHash HashingStrategy = "normalized"
	// SemanticHash - Content-aware hash (structure preserved)
	SemanticHash HashingStrategy = "semantic"
	// SimHash - Locality-sensitive hash (Charikar 2002) for similarity detection
	SimHash HashingStrategy = "simhash"
)

type Stats

type Stats struct {
	TotalFiles        int
	UniqueHashes      int
	DuplicateGroups   int
	DuplicateFiles    int
	DeduplicationRate float64
}

Stats is a typed snapshot of a detector index.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL