August 30, 2026

Searching a huge file in milliseconds

Searching a huge file in milliseconds

How We Made Instant Search Work on Multi-Gigabyte JSON Files

Imagine you have a multi-gigabyte JSON export sitting on disk, and you urgently need to locate every record containing a specific customer ID.

In a standard text editor, your go-to move is Ctrl+F. On a small file, that is the correct solution: scan the text buffer, report the matching hits, and you're done. On a file measured in gigabytes, however, standard search falls completely apart. The editor has to walk the entire file to report anything at all—and it must repeat that whole grueling process the moment you type another character.

NARIKJSON treats search as a data problem rather than a text problem. In this post, we’ll explore how we made search instantaneous on giant datasets:

  • What a trigram index is and why it can answer "contains" queries rather than just "starts with."
  • Why stepping through search results doesn't slow down as you navigate deeper into a file.
  • Why we deliberately defer building that search index until you explicitly ask for it.

The Cost of Naive Substring Search

A plain substring search has no shortcuts. To prove that "cust_88213" does not appear in a file, a tool must inspect every single byte. There is simply nowhere else the answer could be hiding.

Consequently, the computational cost of a naive query is equal to $O(N)$, where $N$ is the total file size. The query runtime scales with the overall size of the input, not with the actual number of matching results. A query that finds zero hits costs exactly as much processing time as one that finds millions.

Why Editors Freeze as You Type: Live search issues a fresh query for every single character you enter. Type ten characters, and your text editor reads the entire multi-gigabyte file ten times. This is why standard search performance degrades sharply on large files: there is no reusable work.

The solution relies on classic database design: pay a one-time upfront cost to build an index that allows every subsequent search query to touch only a fraction of the raw data.


What a Trigram Index Is (and Why "Contains" Is Hard)

Most full-text search systems rely on word or prefix indexing: they break text into individual words and maintain a posting list for each term. This design makes whole-word and prefix queries extremely fast—a search for invoi* is simply a quick range lookup over sorted terms. However, it makes substring queries impossible. There is no path from a sorted list of whole words to "every value containing voic" because the target substring does not start at a word boundary.

A trigram index solves this by indexing every overlapping three-character window in the text. For example, the string invoice produces five trigrams:

"invoice" ──> ["inv", "nvo", "voi", "oic", "ice"]

Each individual trigram maintains its own posting list pointing back to its source row.

Consider a search query for voic. It decomposes into two trigrams: voi and oic. Any row containing voic must contain both trigrams. Therefore, the candidate set is simply the intersection of those two posting lists—a small, highly targeted set fetched directly without reading the rest of the file.

The Execution Workflow

  1. Trigram Lookup: Break the query into trigrams and intersect their posting lists to isolate candidate rows.
  2. Candidate Verification: Because trigram containment is necessary but not sufficient (a row could hold voi and oic in the wrong order or across different fields), each candidate is verified against the actual substring predicate.

Crucially, verification runs strictly over candidate rows rather than the entire document. A search for a rare string verifies only a handful of rows, completely eliminating the full file scan.

NARIKJSON implements this via SQLite's native FTS5 trigram tokenizer. Hierarchical files stream into SQLite when using Database Mode. The index resides alongside the data being served, allowing the same long-lived engine process to handle search requests and UI paging requests concurrently.

An Intentional Design Limit: Hierarchical search interprets terms strictly as either a substring or—if enclosed in "double quotes"—as an exact match. Regular expressions are deliberately excluded on tree structures because an arbitrary regex pattern cannot be decomposed into a fixed set of trigrams.


Keyset Pagination: Why Stepping Doesn't Slow Down

Locating matches is only half the problem; navigating through them is the other half. This is where many otherwise fast search tools suffer from performance degradation.

The standard way to page through search results is using LIMIT ... OFFSET ... queries. The hidden issue lies in how OFFSET operates behind the scenes: it does not skip ahead. Instead, it generates all preceding rows and discards them. To serve a deep offset, the database computes every single prior match and throws them away before emitting the requested page.

As a result, query latency grows linearly with scroll depth. Early pages load instantly, deep pages feel broken, and users assume the engine itself is slow.

NARIKJSON replaces offset pagination with keyset pagination (also known as seek pagination). Instead of counting records from the beginning, each request includes the key of the last seen row and queries directly for records following it:

SELECT * FROM records 
WHERE rowid > :last_seen_id 
AND rowid IN candidate_matches 
LIMIT 50;

Because rowid is indexed and strictly ordered, the engine seeks directly to that offset and reads forward. Zero work is discarded, keeping page load times identical at any depth. Pressing Find Next executes an instant seek past the current match, not a re-scan from the top.

Matches are highlighted in amber and navigated using F3 / Shift+F3 (or Ctrl+G / Cmd+G), yielding identical performance on the 3rd match as on the 300th match.


Why the Index Is Opt-In, Not Automatic

While an index provides massive speedups, building one automatically on every single file open is inefficient.

Building an index consumes CPU time and storage up front—at the exact moment you are waiting to inspect your data. Furthermore, most file opens do not require search: developers frequently open a file just to check payload structures or confirm field names. Forcing every session to pay an indexing penalty for a feature that only pays off during repetitive querying is the wrong default.

NARIKJSON defers indexing until it has sufficient context. When a file is large enough that an unindexed scan would introduce noticeable latency, the match bar displays an optional prompt:

Slow? Build a one-time index for instant search — with a ⚡ Build Index button.

The choice belongs entirely to you. Choosing to index does not block productivity: browsing, paging, and tree navigation remain fully functional in the UI while the index processes in the background.

Managing Disk Usage

Index storage remains transparent and simple to manage:

  • Select Delete Search Indexes… in the Cache Manager menu to reclaim disk space instantly.
  • Set global cache limits (5, 10, 20, 50 GB, or Unlimited) managed by automatic LRU pruning at launch.
  • Indexes created for temporary investigations are automatically reclaimed rather than persisting indefinitely on your drive.

Scope on a Tree, Modes on a Table

Performance is one dimension of search; precision is the other. On hierarchical datasets, precision is achieved by scoping where the engine looks.

  • Scope (Hierarchical Tabs): For JSON, NDJSON, XML, and YAML, searches can be scoped to All, Keys, or Values. Searching for status across an API payload returns every field named status and every value containing that word. Scoping to Keys answers "where is this field used?", while scoping to Values answers "which records are in this state?"
  • Modes (Table Tabs): Tabular views offer Contains, Exact, or Regex modes operating across all columns simultaneously. This eliminates the need to know which specific column holds a value when working with unfamiliar CSV files.

Table search acts as a direct view filter powered by the integrated DuckDB / Parquet cache, narrowing the rendered grid instantly without requiring a separate trigram index build.


Exporting the Match Set

Once a query completes, navigating to Export → Search Matches as JSON / CSV extracts the result set (up to 5,000 matches) into a clean, structured schema containing three core fields:

{ "path": "orders[402].customer.id", "name": "id", "value": "cust_88213" }

This export acts as a lightweight structural map of match locations rather than a giant data dump. It allows exact path locations for a specific order ID to be extracted, shared, attached to tickets, or piped directly into command-line tools like jq. Because processing occurs entirely on your local machine, search extractions never expose sensitive data to remote servers.


Hands-On Workflow

To test this indexing pipeline on a large JSON or NDJSON file:

  1. If prompted with "Slow? Build a one-time index for instant search", click ⚡ Build Index and continue navigating your tree while it builds.
  2. Set Scope to Values, then step through results instantly using F3 / Shift+F3.
  3. Wrap terms in "double quotes" to narrow down exact matches.
  4. Select Export → Search Matches as CSV to extract structural path locations.
  5. Reclaim disk space anytime via Cache Manager → Delete Search Indexes.