HypGrep is a serverless grep over Parquet files in S3. A client (browser, Node, etc.) reads a small precomputed index over HTTP range requests, then reads only the source byte ranges that could plausibly match. The dominant cost being optimized is per-query transfer bytes — every byte the client pulls is wall-clock latency and dollar cost. File size on disk is a secondary metric.
A HypGrep index is itself a Parquet file with two columns:
| column | type | encoding | meaning |
|---|---|---|---|
ngram |
STRING | DELTA_BYTE_ARRAY | a lowercase n-character substring |
blockId |
INT32 | DELTA_BINARY_PACKED | logical block this n-gram appears in |
Rows are sorted by (ngram, blockId). The set of blockIds for a given n-gram is its posting list. The source file is divided into logical blocks of block_size consecutive rows (independent of Parquet row groups). The index records, for each block, the set of distinct n-grams present across every indexed string column.
Key-value metadata on the index file:
hypgrep.version # format version
hypgrep.block_size # rows per block
hypgrep.ngram_length # n-gram size (default 5)
hypgrep.text_columns # which columns were indexed
hypgrep.source_rows # total rows in source
hypgrep.source_bytelength # source file size (lets the client pre-size buffers)
Storing source size in the metadata lets the client construct an AsyncBuffer for the source without a separate HEAD request.
For a query string Q:
- Derive n-grams from the query.
- String query: lowercase; split on
/[^a-z0-9]+/; emit every n-character window of each alphanumeric run. This is a superset filter; the row matcher applies the final search semantics. - RegExp query: walk the regex source to extract mandatory literal substrings (handling escapes, character classes, groups, quantifiers, and top-level alternation safely), then run the string n-gram extractor over each literal.
- No extractable n-grams (very short string, or regex like
/./or/foo|bar/): skip pruning and return all blocks. The substring/regex filter still runs per row — correct grep semantics, just no index acceleration.
- String query: lowercase; split on
- Push-down filter on the index with
{ ngram: { $in: queryNgrams } }. Hyparquet only reads the row-group(s) covering those n-grams. - Intersect. Group returned rows by
blockId; keep blocks that hit every query n-gram. - For each candidate block, in order, read the row range from the source with
parquetReadObjects({ rowStart, rowEnd })anduseOffsetIndex: true. Both files are wrapped incachedAsyncBufferfor the duration of the query so adjacent blocks within a Parquet row group don't re-fetch its pages. - Per-row match.
parquetFindverifies the full string query as a contiguous case-insensitive substring;parquetSearchrequires every whitespace-separated query word and scores by occurrence count. For RegExp queries, the regex runs across indexed columns. limitshort-circuits as soon as enough matches have been yielded — subsequent blocks are never read.
Shorter n-grams fail to prune prose at any reasonable block size — every block of natural-language text contains every common 3- or 4-character window, so trigram intersection returns every block as a candidate and we end up scanning the whole source. n = 5 has a large enough universe (~285K alnum 5-grams vs ~47K trigrams) that distinguishing windows like rverl (serverless) or ichor (petrichor) prune effectively. Per-query source transfer on a 420 MB Wikipedia dump:
| query | n=3 | n=4 | n=5 |
|---|---|---|---|
| eigenvalue (67 hits) | 839 MB | 205 MB | 189 MB |
| petrichor (0) | 839 MB | 839 MB | 316 MB |
| serverless (0) | 839 MB | 839 MB | 29 MB |
Tradeoff: index size grows with n. n=3 → 1.2 MB, n=5 → 15 MB on Wikipedia. The goal weighs query bytes far more than index size, so the larger index pays for itself many times over after the first few queries.
Smaller blocks improve pruning — when a candidate block is selected, fewer "wasted" non-matching rows ride along. Going from 500 to 100 cuts source transfer roughly in half on absent/rare-string queries:
| query | b=500 | b=100 |
|---|---|---|
| petrichor | 301 MB | 157 MB |
| serverless | 28 MB | 0 MB |
Tradeoff: smaller blocks mean more (ngram, blockId) postings. Index grows 15 → 25 MB. Again favorable under the goal.
Both source and index files are wrapped with cachedAsyncBuffer for the duration of a query. Parquet readers commonly re-fetch the same byte range across calls (page footer + page data, or overlapping row-group pages when adjacent blocks fall in the same row group). The cache memoizes slices by (start, end), so duplicate fetches are free.
An earlier version coalesced contiguous candidate blocks into single parquetReadObjects calls to avoid the re-fetch. That worked for transfer bytes but defeated limit: a limit: 10 query against a string that matched every block still pulled the entire coalesced run. Switching back to per-block reads (relying on the cache to dedupe pages) costs identical bytes on full scans while letting limit short-circuit on the next block boundary. Measured impact:
| query | without cache | with cache |
|---|---|---|
| eigenvalue | 195 MB | 150 MB |
| petrichor | 106 MB | 59 MB |
| quantum entanglement | 159 MB | 107 MB |
No limit (every match):
| query | matches | total | index | source |
|---|---|---|---|---|
| eigenvalue | 67 | 150.4 MB | 724 KB | 150 MB |
| petrichor | 0 | 59.6 MB | 646 KB | 59 MB |
| serverless | 0 | 0.7 MB | 689 KB | 0 MB |
| quantum entanglement | 12 | 107.7 MB | 870 KB | 107 MB |
| wikipedia | 156289 | 401.3 MB | 708 KB | 401 MB |
limit: 10 (typical client usage):
| query | matches | total | index | source |
|---|---|---|---|---|
| eigenvalue | 10 | 42.5 MB | 724 KB | 42 MB |
| petrichor | 0 | 59.6 MB | 646 KB | 59 MB |
| serverless | 0 | 0.7 MB | 689 KB | 0 MB |
| quantum entanglement | 10 | 76.3 MB | 870 KB | 75 MB |
| wikipedia | 10 | 14.1 MB | 708 KB | 13 MB |
Per-query index transfer is bounded at ~700 KB regardless of selectivity — that's the property that makes the design serverless-friendly. The source transfer scales with how many blocks legitimately match, and limit lets selective UIs (first 10 hits) clip it further.
createIndex({ sourceFile, indexFile, blockSize?, ngramLength?, ... })— build the index Parquet.blockSize,indexRowGroupSize, andngramLengthmust be positive safe integers.queryIndex({ query, indexFile, indexMetadata? })— return candidate blocks, orundefinedfor an empty query.queryaccepts a string orRegExp.parquetFind({ query, url, limit?, rowFilter?, ... })— async generator of matching rows in natural order.queryaccepts a string orRegExp.rowFilteroverrides the default row predicate.parquetSearch({ query, url, limit?, ... })— async generator of matching rows ranked by occurrence count.
All reader functions accept either a url (with optional asyncBufferFactory) or pre-loaded sourceFile / indexFile AsyncBuffers.
hypgrep <file.parquet> [<file.index.parquet>] build an index
hypgrep search <file.parquet> <pattern> [options] grep the file
Search options: --limit N, --index <path>, -c/--count, -i/--ignore-case. Patterns wrapped as /regex/flags are treated as regex; otherwise literal.
-
Queries shorter than
ngramLength(default 5) chars fall back to a full scan of every block. Correct, just unaccelerated. -
Common-everywhere words (e.g.
wikipediain a Wikipedia dump) match every block; the source transfer is unavoidable because every row really does match. No n-gram strategy fixes this. -
Per-row precision is missing. A candidate block is scanned in full even if only one row matches. Storing per-
(ngram, block)row bitmaps would let queries narrow the source read to specific rows, at the cost of a ~3× larger index. Probably the highest-value next step for sparse queries that match a small number of rows spread across many blocks. -
No-limit queries on dense matches do more work than they need to. When every block matches (e.g.
wikipediaagainst a Wikipedia dump), exhausting all matches takes ~24 s of CPU because we process blocks one at a time. The bytes are minimal; the time is CPU/parsing overhead. Real clients should pass alimit. -
Regex literal extractor caps DNF expansion at 32 branches. Groups with alternation are recursed into and cross-producted, so
/abc(foo|bar)def/yields the tight{abcfoodef} ∨ {abcbardef}. Two flavors of fold keep adjacent literals contiguous so the n-gram extractor can prune them:- Char class fold: small literal-only classes (no negation, no ranges, no
\w/\d, single occurrence) concat into the run —/serv[ei]rless/becomes{serverless} ∨ {servirless}(333 MB → 0 MB on Wikipedia). - Group fold: when every inner branch is a single contiguous literal, the group also concats —
/(eigen|petri)(value|chor)/becomes{eigenvalue} ∨ {eigenchor} ∨ {petrivalue} ∨ {petrichor}(615 MB full scan → 51 MB).
Negated classes, ranges,
\d/\w, multi-char quantifiers like[ab]{3}(which could matchaab), and groups whose inner branches contain wildcards or multiple literals fall back to wildcard or independent-literal treatment to stay correct. Deeply nested expansions that would exceed 32 branches drop the offending construct conservatively. - Char class fold: small literal-only classes (no negation, no ranges, no
-
Index files are not back-compatible.
hypgrep.versionexists but we don't ship a multi-version reader. Reasonable for a 0.x package.
hyparquet— Parquet reader with push-down filtering, offset index, andcachedAsyncBuffer.hyparquet-writer— Parquet writer withDELTA_BYTE_ARRAY/DELTA_BINARY_PACKEDencodings that keep the index small.hyparquet-compressors— compression codecs.