A corpus-hygiene utility for RAG data pipelines that identifies duplicate content risk, quantifies duplication with actionable statistics, and supports controlled remediation before indexing. It enables staged audit-then-cull workflows that improve retrieval quality, reduce embedding/indexing cost, and strengthen governance in knowledge curation operations.
Topic tags: rag, retrieval-augmented-generation, data-curation, data-governance, corpus-hygiene, document-deduplication, file-deduplication, knowledge-management, data-quality, bash, dotnet
README.md: quick-start reference and command/flag summaryUSERGUIDE.md: step-by-step tutorial with practical workflowsARCHITECTURE.md: internal design and implementation architecture (Bash + .NET)
- Sorts files alphabetically.
- Aligns the hash column based on the longest displayed file name.
- Supports multiple hash algorithms.
- Defaults to BLAKE3.
- Can recurse into subdirectories.
- Supports built-in exclusions plus user-defined exclusion patterns.
- Ignores
.dups/directories by default. - In recursive mode, processes and prints results directory-by-directory as traversal encounters them.
- Continues processing on per-file access errors and emits warnings instead of halting.
- Highlights adjacent matching hashes in green.
- Optional dedupe mode to keep one file and move duplicates into hidden
.dups/directories. - Prints a completion summary with duplicate counts and percentages.
- Supports macOS Catalina-compatible traversal behavior (no GNU
find -printf/sort -zdependency). - Built-in exclusions include common VCS/editor/temp artifacts and
*.lshash.jsonsidecars.
This tool was developed as a corpus-hygiene control for RAG pipelines.
In production RAG systems, duplicate files can create duplicate chunks, increase embedding/indexing spend, and over-weight repeated content during retrieval. That can reduce answer quality and make retrieval behavior less predictable.
The intended workflow is a staged curation process:
- Phase 1 (audit, no mutation): run without
-dto profile duplication as part of pre-ingestion assessment. Use the completion statistics to quantify duplicate-file rate before chunking and embedding. - Phase 2 (remediation, optional): run with
-d(and optionally--directoryfor full-directory grouping) to quarantine duplicates into.dups/, reducing corpus redundancy before indexing. - Phase 3 (post-curation validation): re-run audit and compare summary metrics to confirm that curation improved corpus quality.
This separation of discovery and action supports safer change control, clearer governance, and repeatable RAG data-preparation practice.
lshash.sh
- Bash implementation:
- Script:
lshash.sh - Supports contiguous dedupe and
--directorydedupe (with--all-directoryas a compatibility alias)
- Script:
- .NET implementation:
- Project:
dotnet/ - Supports the same runtime options and dedupe variants as Bash
- Project:
- Bash 3.2+
- Standard Unix tools:
find,sort,awk,stat,mv - Hash command for selected algorithm:
b3sumforblake3sha256sumforsha256sha512sumforsha512sha1sumforsha1md5sumformd5b2sumforblake2
- The script now runs on macOS Catalina or later shell/tooling for traversal and sorting behavior.
- Hash command requirements still apply by algorithm choice. On macOS,
blake3is typically the easiest path becauseb3sumcan be auto-installed when package tooling is available. - For non-BLAKE3 algorithms on macOS, the script prefers GNU
*sumtools when installed, but automatically falls back to native commands where possible (shasumforsha256/sha512/sha1, andmd5formd5). - On legacy Bash (for example macOS system Bash 3.2), the script relaxes
nounset(set +u) internally to avoid known empty-array expansion failures while preserving other strict-mode protections.
If blake3 is selected and b3sum is missing, the script attempts an automatic install using a detected package manager.
- Uses non-interactive elevation (
sudo -n) when needed. - Uses a timeout for install attempts.
- Timeout defaults to 20 seconds and can be overridden:
LSHASH_INSTALL_TIMEOUT=10 ./lshash.shIf installation cannot be done automatically, the script exits with guidance.
This repository also includes a .NET 10 C# implementation with behavior parity to the Bash script.
cd dotnet
./build.shOptional runtime identifier argument:
cd dotnet
./build.sh linux-x64Output executable:
dotnet/dist/linux-x64/lshash
The publish configuration is self-contained and single-file, so no .NET runtime is required on the target host.
The .NET build also enables invariant globalization, so libicu is not required on minimal Linux containers.
cd dotnet
./build-macos.shBy default, build-macos.sh publishes net6.0 binaries for better macOS Catalina compatibility.
Optional target selection:
cd dotnet
./build-macos.sh osx-arm64
./build-macos.sh osx-x64
./build-macos.sh --framework net10.0 osx-arm64Output executables:
dotnet/dist/osx-arm64/lshashdotnet/dist/osx-x64/lshash
If you prefer a containerized execution path, use the Docker deployment bundle:
cd dotnet/deploy/macos
./deploy.sh build
./deploy.sh audit /path/to/scan
./deploy.sh cull /path/to/scanThe deployment wrapper is documented in dotnet/deploy/macos/README.md.
cd dotnet
dotnet run -c Release -- --helpThe .NET implementation supports the same options as Bash (--algorithm, -r/--recursive, -e/--exclude, -d/--dedupe, --directory (alias --all-directory), --global, --prompt-delete, --move-dups, -q/--quiet, optional DIRECTORY):
--directory(alias:--all-directory)- With
-d/--dedupe, dedupe by hash across all files in each directory, ignoring filename adjacency - Without
-d/--dedupe, this flag is a no-op
- With
--global- With
-d/--dedupeand-r/--recursive, dedupe by hash across the entire recursive tree - With
-d/--dedupewithout-r/--recursive, behaves like--directoryon the selected directory - Sidecar metadata files
<moved-file>.lshash.jsonare created only in recursive global mode (-r -d --global) - In dedupe mode, any directory containing
.lshash-excludeis skipped with descendants - Without
-d/--dedupe, this flag is a no-op
- With
--prompt-delete- With
-d/--dedupe, after listing.dupsdirectories, promptsy/Nto delete them - Used alone (or with only
DIRECTORY), recursively gathers existing.dupsdirectories, lists them, and promptsy/Nto delete them - When combined with other non-dedupe options, this flag is a no-op
- With
--move-dups PATH/--move-dups=PATH- Standalone mode (optionally scoped by
DIRECTORY) that recursively finds existing.dupsdirectories and moves files from them underPATHusing original relative paths - Copying the resulting archive tree back onto the source tree restores duplicates (plus sidecars, when present)
- Standalone mode (optionally scoped by
- Default backend is CPU.
- Override backend with environment variable
LSHASH_BLAKE3_BACKEND:cpu(default)gpu
- If GPU backend initialization or hashing fails at runtime, the process falls back to CPU BLAKE3 for the remainder of that run.
- Optional GPU chunk budget override:
LSHASH_BLAKE3_GPU_MAX_CHUNKS(positive integer)- Default:
1048576(1 << 20)
LSHASH_DIAGNOSTICS=1enables tuning diagnostics output.- Network filesystems (for example
cifs,smb3,nfs) auto-enable diagnostics even withoutLSHASH_DIAGNOSTICS. LSHASH_HASH_WORKERS=<N>pins a fixed worker count (disables adaptive worker tuning).LSHASH_READ_BUFFER_KB=<N>sets read buffer size for sequential hashing.
dotnet/dist/linux-x64/lshash -q
dotnet/dist/linux-x64/lshash -rq /path/to/scan
dotnet/dist/linux-x64/lshash -r -d shorter -q
dotnet/dist/linux-x64/lshash --directory # no-op without -d
dotnet/dist/linux-x64/lshash -d shorter --directory
dotnet/dist/linux-x64/lshash -d shorter --global
dotnet/dist/linux-x64/lshash -r -d shorter --global
dotnet/dist/linux-x64/lshash -d shorter --prompt-delete
dotnet/dist/linux-x64/lshash --prompt-delete
dotnet/dist/linux-x64/lshash --prompt-delete /path/to/scan
dotnet/dist/linux-x64/lshash --move-dups /path/to/archive
dotnet/dist/linux-x64/lshash --move-dups=/path/to/archive /path/to/scan./lshash.sh [--algorithm NAME] [-r|--recursive] [-e PATTERN] [--exclude PATTERN] [-d [MODE]] [--directory] [--global] [--prompt-delete] [--move-dups PATH] [-q|--quiet] [DIRECTORY]cd /path/to/lshash
chmod +x ./lshash.sh
./lshash.sh --algorithm sha256 -r /path/to/scancd dotnet
./build-macos.sh
./dist/osx-arm64/lshash --help # Apple Silicon
./dist/osx-x64/lshash --help # Intelcd dotnet/deploy/macos
./deploy.sh build
./deploy.sh audit /path/to/scan
./deploy.sh cull /path/to/scan--algorithm NAME- Hash algorithm:
blake3,sha256,sha512,sha1,md5,blake2
- Hash algorithm:
-r,--recursive- Include files in subdirectories
- Hidden
.dups/directories are skipped by default - Output is emitted progressively per directory encountered during traversal
-e PATTERN--exclude PATTERN--exclude=PATTERN- Exclude files matching glob pattern (repeatable)
- Built-in exclusions are always active (for example
.dupstraversal skip,.lshash-exclude,.git/.hg/.svn,.gitignore,.mdexplore-*.json,*.lshash.json, and common temp/editor files)
-d [MODE],--dedupe [MODE],--dedup [MODE]-d=MODE,--dedupe=MODE,--dedup=MODE- Dedupe files with identical hash in the same directory
- Valid
MODEvalues:newer,older,shorter,longer - Default mode when omitted:
shorter shorter/longercompare full root-relative path length (directory path + basename), not basename-only length
--directory(alias:--all-directory)- With
-d/--dedupe, uses full-directory hash grouping instead of contiguous-neighbor grouping - Without
-d/--dedupe, no-op
- With
--global- With
-d/--dedupeand-r/--recursive, dedupes by hash across all scanned files in the recursive tree (not per-directory) - With
-d/--dedupewithout-r/--recursive, behaves like--directoryfor the selected directory - In recursive global mode (
-r -d --global), each moved duplicate gets a sidecar metadata JSON file<moved-file>.lshash.jsonin.dups/describing duplicate peers (full paths) and statuses (kept/moved) - In dedupe mode, any directory containing
.lshash-excludeis skipped with descendants - Without
-d/--dedupe, no-op
- With
--prompt-delete- With
-d/--dedupe, after printing.dupsdirectory paths, promptsy/Nto delete them - Used alone (or with only
DIRECTORY), recursively gathers existing.dupsdirectories, lists them, and promptsy/Nto delete them - When combined with other non-dedupe options, no-op
- With
--move-dups PATH/--move-dups=PATH- Standalone mode (optionally scoped by
DIRECTORY) that recursively finds existing.dupsdirectories and moves files from them underPATHusing original relative paths - Copying that archive tree back over the source tree restores duplicates (plus sidecars, when present)
- Standalone mode (optionally scoped by
-q,--quiet- Only print duplicate lines (the lines that would be highlighted green in normal output)
- Works with and without dedupe, and with and without recursive mode
DIRECTORY(optional positional argument)- Scan this directory instead of the current working directory
- Output paths remain relative to the selected directory root
- One-letter short switches are stackable in any order (for example
-rd,-dr,-rq,-re '*.log').
- Hash values are left-justified in a single aligned column.
- If the previous listed file has the same hash, the current hash is shown in green.
- When dedupe moves a file, the file name is italicized and annotated:
(moved to .dups/)
- Completion summary reports duplicate count and percentage of scanned files.
- With
-r/--recursive, summary also reports directories traversed. - With
-d/--dedupe, summary wording changes to duplicates "found and moved".
When dedupe is enabled:
- Primary use case: remove copy/restore/merge artifacts where duplicate files usually sort next to each other (for example names containing
(copy), version suffixes, or sync-conflict tags). - Duplicate groups are determined by contiguous same-hash blocks in alphabetical listing order within each directory.
- Files that cannot be hashed are skipped for block matching, so they do not break a contiguous duplicate block among hashable neighbors.
- Genuine executable program files are excluded from dedupe matching and never moved (requires execute permission plus program/script detection, for example MIME types such as
application/x-pie-executableortext/x-shellscript; shebang scripts are also treated as executable programs even if MIME resolves totext/plain; many file managers show these asProgram). - One file is kept in place based on selected mode.
- All other duplicates in that directory are moved to that directory's
.dups/subdirectory. - In recursive mode, dedupe is still per directory encountered during traversal.
- Tie-breaking rule: first file in sorted listing order is kept.
- If a destination name already exists in
.dups/, a.dupNsuffix is added. --directoryprovides a more thorough filename-blind mode that checks duplicates across the full directory. It only takes effect when used with-d/--dedupe.--globalextends dedupe scope across the full recursive tree when combined with-dand-r, and writes provenance JSON sidecars (<moved-file>.lshash.json) for moved files.
| Flags | Duplicate scope | Grouping method | Moved file destination | Sidecar metadata |
|---|---|---|---|---|
-d |
Per directory | Contiguous same-hash runs in sorted filename order | Same directory .dups/ |
No |
-d --directory |
Per directory | Full-directory hash grouping (filename adjacency ignored) | Same directory .dups/ |
No |
-d --global |
Selected directory only | Full-directory hash grouping (same as --directory) |
Same directory .dups/ |
No |
-d -r --global |
Full recursive tree | Whole-tree hash grouping across directories | Each file's own source directory .dups/ |
Yes (.lshash.json) |
In recursive --global mode (-r -d --global), every moved duplicate gets a sidecar metadata file next to it in .dups/:
- Name:
<moved-file>.lshash.json - Location: same
.dups/directory as the moved file - Purpose: explain the duplicate set peers and which file was kept vs moved
JSON structure:
{
"hash": "<hash>",
"dedupeMode": "shorter",
"subject": {
"path": "/abs/path/to/dir/.dups/file.ext",
"status": "moved"
},
"others": [
{
"path": "/abs/path/to/kept/file.ext",
"status": "kept"
},
{
"path": "/abs/path/to/another/dir/.dups/file2.ext",
"status": "moved"
}
]
}Technical flow diagrams are maintained in ARCHITECTURE.md.
- Default (
-d): optimized for copy/restore/merge artifacts where duplicate names are often alphabetically adjacent. --directorywith-d: more thorough and filename-blind dedupe across the entire directory.--directorywithout-d: no-op (normal non-dedupe listing behavior).--globalwith-d -r: cross-directory, whole-tree hash dedupe with per-moved-file metadata JSON.--globalwith-d(no-r): equivalent dedupe scope to--directoryon the selected directory and does not emit sidecar JSON.
./lshash.sh./lshash.sh --algorithm sha256./lshash.sh -r./lshash.sh -r -e '*.log' --exclude '*.tmp' --exclude='build/*'./lshash.sh -d./lshash.sh -r --dedupe newer./lshash.sh --dedupe=longer./lshash.sh -d shorter --global /path/to/scanThis uses full-directory hash grouping for that single directory (same scope behavior as --directory) and does not write sidecar metadata.
./lshash.sh -r -d shorter --global /path/to/scanThis compares hashable files across all directories in the tree, moves losers to each file's local .dups/, and writes <moved-file>.lshash.json sidecars.
./lshash.sh -r --dedupe newer --global /path/to/scanIn each duplicate set, the newest file is kept in place and all others are moved to their source-directory .dups/ folders.
find /path/to/scan -path '*/.dups/*.lshash.json' -maxdepth 6 -print
cat /path/to/scan/some/dir/.dups/example.txt.lshash.jsonIf jq is available:
jq . /path/to/scan/some/dir/.dups/example.txt.lshash.json./lshash.sh -q
./lshash.sh -rq /path/to/scan./lshash.sh --prompt-delete
./lshash.sh --prompt-delete /path/to/scan./lshash.sh --move-dups /path/to/archive
./lshash.sh --move-dups=/path/to/archive /path/to/scanThese examples use made-up file sets to show how the completion summary text changes by mode.
Hypothetical files in one directory:
a.txt (content: same)
b.txt (content: same)
c.txt (content: different)
Command:
./lshash.sh --algorithm sha256Expected output shape:
a.txt <hash-A>
b.txt <hash-A>
c.txt <hash-C>
Summary: scanned 3 file(s); 1 duplicate file(s) were found (33.33% of scanned files).
Hypothetical tree:
./a.txt (content: same)
./b.txt (content: same)
./sub/c.txt (content: unique)
Command:
./lshash.sh --algorithm sha256 -rExpected output shape:
a.txt <hash-A>
b.txt <hash-A>
sub/c.txt <hash-C>
Summary: scanned 3 file(s); 1 duplicate file(s) were found (33.33% of scanned files); 2 directories were traversed.
Hypothetical files in one directory:
a.txt (content: same)
aa.txt (content: same)
aaa.txt (content: same)
Command:
./lshash.sh --algorithm sha256 -d shorterExpected output shape:
a.txt <hash-A>
aa.txt (moved to .dups/) <hash-A>
aaa.txt (moved to .dups/) <hash-A>
Summary: scanned 3 file(s); 2 duplicate file(s) were found and moved (66.66% of scanned files).
Expected result on disk:
.dups/aa.txt
.dups/aaa.txt
Hypothetical files in one directory:
a.txt (content: alpha)
b.txt (content: bravo)
c.txt (content: charlie)
Command:
./lshash.sh --algorithm sha256Expected output shape:
a.txt <hash-A>
b.txt <hash-B>
c.txt <hash-C>
Summary: scanned 3 file(s); 0 duplicate file(s) were found (0.00% of scanned files).
Hypothetical tree:
./a.txt (content: same)
./aa.txt (content: same)
./sub/p.txt (content: same)
./sub/pp.txt (content: same)
Command:
./lshash.sh --algorithm sha256 -r -d shorterExpected output shape:
a.txt <hash-A>
aa.txt (moved to .dups/) <hash-A>
sub/p.txt <hash-P>
sub/pp.txt (moved to .dups/) <hash-P>
Summary: scanned 4 file(s); 2 duplicate file(s) were found and moved (50.00% of scanned files); 2 directories were traversed.
Hypothetical files in one directory (non-adjacent duplicate content):
a-copy.txt (content: same)
m-middle.txt (content: unique)
z-sync.txt (content: same)
Command:
./lshash.sh --algorithm sha256 --directoryExpected output shape:
a-copy.txt <hash-S>
m-middle.txt <hash-M>
z-sync.txt <hash-S>
Summary: scanned 3 file(s); 0 duplicate file(s) were found (0.00% of scanned files).
Use the same hypothetical files as example 6.
Command:
./lshash.sh --algorithm sha256 -d shorter --directoryExpected output shape:
a-copy.txt <hash-S>
m-middle.txt <hash-M>
z-sync.txt (moved to .dups/) <hash-S>
Summary: scanned 3 file(s); 1 duplicate file(s) were found and moved (33.33% of scanned files).
Hypothetical files in one directory:
a.txt (content: same)
b.txt (content: same)
c.txt (content: unique)
Command:
./lshash.sh --algorithm sha256 -qExpected output shape:
b.txt <hash-A>
Summary: scanned 3 file(s); 1 duplicate file(s) were found (33.33% of scanned files).
- Dedupe moves files; it does not delete them.
- Review output carefully before running dedupe on important directories.
- First run with
blake3may try to auto-installb3sumif missing. - Use another algorithm immediately:
./lshash.sh --algorithm sha256- Reduce install wait time:
LSHASH_INSTALL_TIMEOUT=5 ./lshash.sh- Install it manually, or use another algorithm.
- Example fallback:
./lshash.sh --algorithm sha512- If a file cannot be read (for hash or metadata), the tool prints a warning and continues.
- Output for those files shows
<hash unavailable>. - In dedupe mode, inaccessible files are ignored for contiguous block matching; hashable neighbors can still form a duplicate block across them.
- Auto-install uses non-interactive sudo (
sudo -n) and will fail fast if credentials are not already available. - Fix by installing
b3summanually or run with a different algorithm.
- Dedupe only groups contiguous same-hash neighbors (in alphabetical listing order) within the same directory.
- With
-r, grouping is still per directory, not across the entire tree. - For cross-directory dedupe across the full tree, use
-r -d --global. - Confirm mode selection:
newerkeeps newestolderkeeps oldestshorterkeeps shortest full root-relative path (default)longerkeeps longest full root-relative path
-q/--quietonly prints duplicate lines (green lines in normal mode).- If no adjacent duplicate hashes are encountered in listing order, quiet output will be empty.
- If your shell says it cannot access the current directory (
getcwdwarnings), your working directory may have been deleted. - Change into a valid directory before running again:
cd /home/npepin/Projects/lshash- Recent versions of the script avoid high-frequency process substitutions in recursive traversal/sorting paths to prevent file-descriptor exhaustion on legacy macOS Bash.
- If you still see this, ensure you are running the latest script revision from this repository.
./lshash.sh./lshash.sh /path/to/scan
./lshash.sh -rq /path/to/scan./lshash.sh -r -e '.git/*' -e '.dups/*' -e 'node_modules/*' -e '*.log' -e '*.tmp'./lshash.sh --algorithm sha256./lshash.sh -r --dedupe newer./lshash.sh -r -d shorter --global /path/to/scanFor each moved duplicate in recursive global mode (-r -d --global), a sidecar file <moved-file>.lshash.json is created in .dups/ with peer paths and kept/moved status.
./lshash.sh -dThis uses shorter, which compares full root-relative path length.
./lshash.sh --move-dups /path/to/archive
./lshash.sh --move-dups=/path/to/archive /path/to/scan--move-dups writes files under the archive using original relative paths, so copying that archive tree back over the source tree restores duplicates (plus sidecars, when present).
- Duplicates are moved into a hidden
.dups/subdirectory under the same directory where the duplicate was found.
- All of these are accepted:
--dedupe--dedup-d
Run the parity/regression checks (Bash + .NET):
chmod +x tests/regression.sh
./tests/regression.shFor hash-algorithm rationale and comparison notes, see the BLAKE3 appendix in ARCHITECTURE.md.