danalec/rgx has 25 GitHub stars and 1 forks. GitHot measured +1 stars in 24 hours and +6 in 7 days. Last checked 2026-11-10 19:23 UTC. Last push 2026-11-04.
High-performance full-text search across PDFs, Office documents, ZIP archives, email, media metadata and GGUF/LLM model files. ripgrep-compatible CLI, zero external dependencies.
Measured star growth
- in 24h
- +1
- in 7 days
- +6
- in 15 days
- Update delayed
- in 1 month
- Update delayed
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
Originalrgx
rg that reads inside files — PDFs, Office documents, archives, 3D models,
LLM checkpoints, databases, e-mail, media metadata. Every container format
ripgrep treats as opaque bytes.
Same flags, same path:line:text output, same exit codes (0/1/2), so it drops
into a pipeline that already calls rg unchanged.
Every adapter runs in-process — bytes in, searchable text out.
That is the whole difference from rga, which reaches the same formats by shelling out to a helper per file: here there is nothing to spawn
See it work
rgy or rgx?
If you just want to find things, use rgy. It ships rgx's matching engine inside an indexed search app: your disks are indexed once, a name query answers from that index, and a content query narrows with the index before reading the file. Nothing to configure, nothing to rebuild by hand. Windows, Linux, macOS and FreeBSD.
rgx is what sits underneath: a search engine and an rg-compatible command
line. Take it when you are scripting, dropping it into a pipeline that already
calls rg, or choosing the engine and the store yourself.
| rgy | rgx (this repo) |
|
|---|---|---|
| Shape | an app — CLI, TUI (search-ui) and daemon (searchd) |
one binary: rgx PATTERN [PATHS] |
| Find a file by name | yes — fuzzy, ranked, typo-tolerant (rep, ^src, dat$, !tmp) |
no — --files lists paths and -g filters by glob; rgx searches contents |
| Find text inside a file | yes — grep: / regex: / sym:, confirmed on rgx's regex engine |
yes — the whole rg flag surface |
| Read inside a PDF / Office doc / archive | not yet — the adapter families are vendored, the search path does not run them | yes — every family in What it reads |
| Between queries | keeps an index across runs | walks the tree again, every query |
| Indexed when | by default, on first run | opt-in and manual — --index-build, or --tv-build for the pack store |
| Freshness | --max-age / --reindex say when the index is rebuilt |
always current — it reads the disk as it searches |
| Exit codes | 0 matches · 1 none · 2 error | the same |
rgy's confirmation step is rgx's regex engine — rgx_core::regex::ByteRegex,
imported verbatim — so plain-text matching runs the same code in both. What is
not in rgy's search path yet is the adapter layer: grep: confirms against a
file's own bytes, so a hit inside a PDF, an Office document or an archive is
rgx-only today.
Install
irm https://raw.githubusercontent.com/danalec/rgx/main/install.ps1 | iex
From crates.io, as a Rust tool:
cargo install rgx-search # installs the `rgx` binary
Linux tarballs (glibc, static musl, znver4), a FreeBSD tarball, and the
.deb / .rpm / .apk / .pkg.tar.zst packages are on the
release page. Winget
manifests are in .winget/. On FreeBSD, install.sh picks its channel from
uname -s by itself:
curl -fsSL https://raw.githubusercontent.com/danalec/rgx/main/install.sh | bash
On macOS, from Homebrew — the formula is in Formula/rgx.rb, and
scripts/publish-homebrew.sh opens the pull request to homebrew-core:
brew install rgx
Build only what you need
Every format sits behind a Cargo feature, so a build can carry only the ones you name. The default build carries all of them; a narrow one does not.
# the engine alone: no adapter crate at all
cargo build --release --no-default-features
# only the formats you asked for
cargo build --release --no-default-features \
--features format-pdf,format-zip,format-office
# a whole adapter crate at once
cargo build --release --no-default-features --features adapters-archive
The feature is named after the string the adapter itself reports — the one
--json prints in the format field — so format-zip is the zip adapter,
format-grib is GRIB, format-stl is the binary STL and format-stl-ascii
is the ASCII renderer.
The engine is no_std. rgx-core is #![no_std] + alloc, and the
std feature turns on the typed error chain, the profiler map and the
f64 JSON parser — nothing else that matters. So the engine above is the
crate an embedder links: a router or a NAS that wants to search its own
files can take it without the standard library, and still reach the same
AVX2 kernels the full build uses.
rgx --version prints the formats the build actually carries, read from the
features themselves, so the list cannot drift away from the binary.
The full binary is 9 340 416 bytes. A build with three formats (zip, pdf, grib) is 4 014 592 and the engine alone is 3 516 416 — 57 % and 62 % smaller.
Try it
The first three are searches ripgrep reports as binary:
rgx "invoice total" ~/Documents # inside PDFs and .docx
rgx "password" secrets.tar.gz .env.bak # inside archives
rgx "hidden_dim" model.gguf # inside an LLM checkpoint
rgx "fn main" -t rust --sort path . # plain source: the flags you already know
On a mixed corpus of source plus RAR / 7z / tar / brotli / zstd / lz4 / xz / lzma fixtures, from one command line:
| paths returned | |
|---|---|
| rgx | 20 |
| ripgrep | 9 |
+122% more hits. ripgrep calls those archives binary and moves on; rgx unpacks
them in-process. Full table and the gate that produced it:
benchmark/status.md.
What it reads
The families, the formats of each one, what each of them gives and what each of them refuses are in FORMATS.md -- eighteen families, from the archive to the e-mail, and the gaps of each.
Searching by what a file is
A pattern asks a question about the text. A filter asks a question about the
file, and the file has facts the pattern cannot see: a PDF has an author, a
message has a sender, a photo has coordinates, a model has tensors, an archive
has entries. --filter is where they become searchable.
$ rgx 'contrato' --filter 'mime:pdf AND author:"ACME"'
$ rgx 'senha|password' --filter 'mail:from:"[email protected]"'
$ rgx 'layer.*weight' --filter 'tensor:name AND mime:safetensors'
$ rgx 'GPS' --filter 'exif:gps'
$ rgx 'api_key' --filter 'container:zip AND lang:python'
The grammar is predicates over named facts, combined with AND, OR and
NOT, with parentheses:
expr := or
or := and ( "OR" and )*
and := unary ( "AND" unary )*
unary := "NOT" unary | primary
primary := "(" expr ")" | predicate
predicate := path [ ":" [ op ] value ]
path := ident ( (":" | ".") ident )*
op := "=" | "!=" | "<" | "<=" | ">" | ">="
value := '"' text '"' | "'" text '"' | bare
Where the path ends. At the last colon outside quotes, so
mail:from:"[email protected]" is the path mail:from and the value
[email protected], and mime:pdf is the path mime and the value pdf.
: and . are the same separator, so mail:from and mail.from are one
path.
What a predicate matches. A fact has a name and a value. A predicate whose
path is the fact's name compares the fact's value; a predicate whose path
is the fact's namespace compares the fact's name. So author:"Jane Doe"
asks who wrote it, and exif:gps asks whether the file has an EXIF fact
called gps at all. That second shape is why a fact nobody reports is
false rather than an error: a file that is not a photo does not have GPS.
The facts each format gives are in FORMATS.md.
The unit is what is filtered. A file that no adapter claims is searched
raw, and the raw path is filtered on the facts the name gives. A member of a
container is filtered on its own facts plus the container's: a Python file
inside a zip is a Python file (lang:python) and it is inside a zip
(container:zip), and a filter that asks for both keeps it. A container
itself produces no unit of its own — its members do — so --filter ext:zip
finds nothing, because the thing that gets searched is bundle.zip!script.py
and that is a .py.
What it costs. Nothing when there is no filter: the facts are not
assembled, and the adapters are not asked. With a filter, an adapter that has
to parse the file again to answer pays for a second parse — the PDF's
/Info dictionary is the case that shows it.
What it is not. An index, and a query language over an index. The filter is applied to what the walk finds, file by file, the same way the pattern is.
The facts index
A filter asks every file the same question on every run: what do you know about this file? The answers are assembled, tested against the filter, and thrown away. That is a fine bargain for one search over a corpus that is still moving, and a bad one for a question asked twice about a corpus that changes once.
--facts-build asks the question once and keeps the answers:
$ rgx --facts-build ~/corpus # writes ~/corpus/.rgx-facts.jsonl
$ rgx --facts-index ~/corpus --filter 'mime:pdf'
The index is JSON Lines: one object per path, one line per object.
$ head -1 ~/corpus/.rgx-facts.jsonl
{"path":"/home/me/corpus/a.txt","size":15,"mtime":1700000000,"facts":{"path":"/home/me/corpus/a.txt","format":"raw","ext":"txt","mime":"text/plain"}}
It is JSON and not a binary blob for the same reason the token index is
not the only thing rgx ships: an index nobody can read is an index nobody
will inspect. rg reads this file. So does jq, and so does a person
who wants to know what rgx thinks a file is.
The query language is the filter's. --facts-index takes the same
--filter expression the walk takes, and applies it to the facts that were
kept. There is one query language over the facts, not two: a second one
would be a second answer to the same question, and the two would drift.
What a stale record does. It does not answer. A record carries the size and the modification time the file had when the facts were taken, and a file that no longer has them is a file the index does not know any more. The record is reported as stale on stderr rather than silently answered: a filter that matched a file that has since changed is a filter that answered about a file that is not there.
What the index is not. It is not a copy of the corpus. It holds facts and stamps, not bytes — the bytes are read from the file when a search runs, and an index that held them would be a second copy of the corpus, and a stale one.
Where it lives. <root>/.rgx-facts.jsonl by default.
--facts-store <PATH> puts it somewhere else, which is the flag to reach
when the corpus itself is read-only, or when the index is meant to describe
a tree other than the one it is asked about.
Getting it out. --facts-export <FILE> copies the index to a file, in
the same JSON Lines form it was written in:
$ rgx --facts-index ~/corpus --facts-export ~/facts.jsonl
The export is the whole index, not a filtered subset: choosing which
records to keep is the reader's job, and the reader already has a way to
choose — --facts-index with a --filter. What the export does not do is
invent a second format. An export in a format only rgx reads would be a way
to move facts that only rgx could then use, and the index is already a file
that rg, jq and a person can read.
The MCP server
--mcp puts rgx behind a Model Context Protocol server: JSON-RPC 2.0
over stdin and stdout, one message per line, the way an MCP client already
speaks to a tool.
$ rgx --mcp
Two tools are exposed:
| tool | what it answers |
|---|---|
search |
a literal, case-sensitive search over the corpus. The pattern is matched byte for byte, and the path narrows where the search happens. |
facts |
what is known about one file: the adapter that claimed it, and, with a filter argument, whether that file satisfies a --filter expression. |
The protocol version announced at initialize is 2025-03-26, and the
older 2024-11-05 is accepted and echoed back when the client asks for it.
initialize, tools/list, tools/call and ping are the methods; a
notification such as notifications/initialized is answered with nothing,
because a notification is not a question.
--mcp and --serve are two doors into the same house: --serve speaks
rgx's own line protocol to one client, --mcp speaks the protocol an MCP
client already speaks. They cannot be combined — both want to own stdin —
and --mcp refuses a pattern or a path of its own, because the requests
arrive on the wire and a second way in would be a second answer to the
same question.
Speed
Every row below sits behind a match-count gate: a row whose counts disagree
against ripgrep is withheld, not printed with a ratio. Per-row timings, corpus
digests and the ±3% spawn floor are in
benchmark/status.md.
- One large file, the matcher alone — 1.33×–2.14× faster than ripgrep,
9 of 9 patterns won (246 MB file,
-j1). - A directory walk, 20,028 files — five of nine patterns ahead, one tie, three overlapping, none a ripgrep win. The isolated walker is the clearest margin: 14.3 ms against 23.0 ms for 20,000 paths.
- Pack store, same tree built once — 2.4×–3.1× over rgx's own direct path, 2.8×–3.4× over ripgrep, paying for itself after ~24 queries.
And the other half of it: those are one machine's numbers. The same
byte-identical corpus on a second host (Linux, Ryzen 7 255) puts rgx behind
ripgrep on six of the nine patterns
(benchmark/status-linux-ryzen7-255.md).
Quote a ratio with its host, or don't quote it.
The extraction cache
--cache DIR memoises what the adapters made of a file, so a second search
over the same corpus does not extract it again: a copy of a file is
extracted once, a file that moved is not extracted again, and a touched file
is re-read rather than re-extracted. How it is keyed, and what it does not
survive, is in the manual.
The storage tier
The architecture is diodic: work is asymmetric in one direction and
scoped to one generation of the corpus, so a mutation re-pays the build and
the asymmetry is amortised between one ingestion and many queries. The
--tv flags select it, and the tier itself is in the
manual.
Still ripgrep
The rg flag surface works as-is — -i -S -F -w -v -m -o -r -l -c -q -U --files --json -g --no-ignore --max-depth -M/--max-filesize -I -A/-B/-C --files-without-match -a, plus -t/--type and -T/--type-not.
One default differs, on purpose. rgx reads the hidden files — a name
that starts with a dot, and on Windows the hidden attribute — because a
format that keeps its own metadata in a dotfile is invisible otherwise: a
Zarr array's .zarray, a .zattrs, a .gitignore. --no-hidden puts
ripgrep's default back. The one thing never walked, whatever the flag
says, is a version-control system's own state (.git, .hg, .svn,
.jj): it is metadata about the corpus rather than the corpus, and it
is routinely larger than the tree it describes. The rules in it are still
applied — a .gitignore and a .git/info/exclude are read and honoured —
they are just not listed.
Four additions are worth knowing:
--chunk[=BYTES]— each match as a paragraph-aware window, built for LLM context. Bare--chunkis 2048 bytes.--filter <expr>— a structured query over what is known about a file: its type, its language, the author of a PDF, the sender of a message, the tensors of a model, the columns of a table. See Searching by what a file is.--serve— a persistent query daemon; the adapter registry and page cache survive between queries.--json— machine-readable, naming the adapter that claimed each file and the exact submatch spans..rgxrc— read before argv, so a CLI flag always wins. If rgx seems to ignore a flag you passed, look here first.
Full reference: MANUAL.md.
Scripts
Three jobs exist in both languages. The .sh is the Linux counterpart and
runs the same cases, so a parity failure names the same case on either host.
| job | Windows | Linux / macOS |
|---|---|---|
| parity against ripgrep | scripts/compare-rg.ps1 |
scripts/compare-rg.sh |
| linker A/B (system vs lld vs lld-znver4) | scripts/bench-linkers.ps1 |
scripts/bench-linkers.sh |
| CPU topology, for the thread sweeps | scripts/detect-ccd.ps1 |
scripts/detect-ccd.sh |
PowerShell — scripts/build-release.ps1 (both shippable binaries,
gate included) · scripts/check-vendoring.ps1 (the guard) ·
scripts/check-drift.ps1 (vendored code behind upstream) ·
scripts/apply-vendored-edits.ps1 · scripts/harvest-golden.ps1 ·
scripts/publish-release.ps1 · scripts/smoke-serve.ps1, plus
benchmark/extent-ab.ps1.
bash — scripts/release-all.sh (the whole release, one command) ·
scripts/build-linux.sh and scripts/package-linux.sh (every Linux artifact,
each install-verified in a container of its own distro) ·
scripts/release-linux.sh (Linux artifacts onto this machine) ·
scripts/push-github.sh (mirror the release to GitHub) ·
scripts/bench-status.sh (regenerates the tables above) ·
scripts/adapter-subset.sh (superset or subset, per adapter) ·
scripts/diodicity.sh · scripts/engine-sweep.sh · scripts/mmap-ab.sh, and
the pack-store harnesses tv-vs-rg.sh, tv-matrix.sh, tv-threads.sh,
tv-cold.sh, tv-ablate.sh, tv-ugrep.sh and tv-usecase.sh.
Reading further
FORMATS.md |
what each format gives, and what it refuses |
MANUAL.md |
every flag, environment variable and exit code |
benchmark/status.md |
per-row timings, corpus digests, the gate, both hosts |
benchmark/findings.md |
the ledger — what was tried, and what it was worth |
COPYING |
the licence the whole tree is under, and what it does not cover |
License
Apache-2.0, and nothing else — per file: every file in this repository
carries an SPDX header, and a test reads the file list out of git and fails
on the first file that says otherwise. A distributed rgx binary is
Apache-2.0 code plus the Rust dependencies it links, each of which carries
its own permissive licence.
The full text is LICENSE-APACHE. What is not rgx's code is named in
NOTICE, and the reasoning — why the tree has one licence and
what it does not cover — is in COPYING.
Star history
Explore related projects
codecrafters-io/build-your-own-x
Master programming by recreating your favorite technologies from scratch.
561,867 starspublic-apis/public-apis
A collective list of free APIs
498,228 starsmorluto/rea
Reverse engineer anything with agents, from app behavior down to native binaries.
452,608 starsopenclaw/openclaw
The AI that really does things. Any OS. Any Platform. The lobster way. 🦞
394,365 stars