@icjia/pdf-search-index
pdf-search-index — live demo
Full-text search across PDFs at build time. No server, no native deps.
What this is
This is a live deployment of the open-source @icjia/pdf-search-index package.
The 14 PDFs below were fetched and parsed at build time; their full text is
searchable client-side via Fuse.js — no database, no API, no runtime indexing.
How it works
- Extract
At build time, each PDF is fetched, parsed with
pdf.js, and its text is added to a JSON index. - Bundle
The JSON ships with the site as a static asset — no API, no server, no database.
- Search
Your query runs client-side against Fuse.js. The matching span is highlighted in the snippet below each result.
The corpus
14 documents indexed by this build (10 PDF / 3 DOCX / 1 XLSX), with their on-disk size, page count, and the number of characters of plain text extracted from each one.
-
PDF 2022 SFS Process Evaluation Report
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
DOCX Community Based Corrections Task Force 4 22 Agenda
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View DOCX -
PDF Drug Testing Lit Review
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
DOCX DVFRC Agenda 5.12.2026
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View DOCX -
PDF Elderabuse2
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
PDF Female Criminality Needs OCR — title only
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII. This one is image-only — the extractor returns empty text and Fuse can't match it. Run
View PDFocrmypdfon the source PDF first to add a searchable text layer; this package then indexes it normally on the next build. -
XLSX File Example XLSX 1000
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View XLSX -
PDF JJ Statewide Snapshot 2014 Final 09132016
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
PDF Overview Methamphetamine Trends
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
PDF PDF Brief
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
PDF Seniors Needs OCR — title only
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII. This one is image-only — the extractor returns empty text and Fuse can't match it. Run
View PDFocrmypdfon the source PDF first to add a searchable text layer; this package then indexes it normally on the next build. -
PDF Stigma PDF For Posting
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF -
DOCX Uniform Statewide Crime Statistics Task Force Agenda May 12
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View DOCX -
PDF Youth And Alcohol
Randomly-clicked public sample from icjia.illinois.gov ; contains no PII.
View PDF
Try it
Tune Fuse.js, live v7.4.0-beta.6
Match scoring
0.0 = exact match · 1.0 = match almost anything
Search the entire field (recommended for long PDF text).
Search-window radius. Only matters when ignoreLocation is off.
Active only when ignoreLocation is off
Where in the field to anchor the search. Only matters when ignoreLocation is off.
Active only when ignoreLocation is off
Drop matches shorter than this many characters.
Result behavior
Match the exact case of the query.
Strip accents before comparison (“naïve” matches “naive”, “café” matches “cafe”). Useful for multilingual corpora.
Surface Fuse’s 0–1 match score on each result. (0 is best.)
Sort results by relevance. Turn off to see Fuse’s input-order output.
Don’t stop at the first match per field. Slower; broader snippets.
Split multi-word queries into tokens and merge matches per token. Improves recall for short queries like “drug testing” where either word alone is a useful hit. Reference →
Advanced scoring
Don’t penalize matches in long fields. Useful for body-heavy PDFs.
How much field length penalizes the score. 0 = no penalty.
Extended syntax & keys
Enable Fuse extended syntax: ='exact, !not, ^prefix, end$. Reference →
Fuse-native token search with TF-IDF scoring. Splits the query into tokens internally and ranks results by term-frequency × inverse-document-frequency — better relevance than our demo-side tokenSearch wrapper above for multi-word queries. Distinct from the wrapper: this is built into the Fuse runtime.
Disable “text” to see how much weaker the match is when only titles are indexed — that’s the case for any search engine that doesn’t extract PDF text.
Current config
// fuse.js v7.4.0-beta.6
new Fuse(rows, {
keys: ['title', 'text'],
threshold: 0.20,
ignoreLocation: true,
location: 0,
distance: 100,
minMatchCharLength: 2,
isCaseSensitive: false,
ignoreDiacritics: false, // new in 7.4-beta
includeScore: false,
shouldSort: true,
findAllMatches: true,
ignoreFieldNorm: false,
fieldNormWeight: 1.0,
useExtendedSearch: false,
useTokenSearch: false, // new in 7.4-beta — native TF-IDF
includeMatches: true,
});
// Demo-only: tokenSearch wrapper splits multi-word queries (active).
// (Distinct from useTokenSearch above — the wrapper works in any Fuse version;
// useTokenSearch is the 7.4-beta native implementation with TF-IDF scoring.)
// See https://www.fusejs.io/token-search.htmlWhy Fuse.js?
| Engine | Fuzzy | Pros | Cons | Production sweet spot |
|---|---|---|---|---|
| Fuse.js | Yes (Bitap; threshold: 0.0 – 1.0) | Smallest API. Best typo tolerance. Native match-position output drives our snippet highlighting. Built-in FuseWorker in 7.4.0-beta.6+. | In-memory; full index loaded up-front. Build cost grows with corpus. Slows down past ~2,500 documents. | < 2,500 documents |
| FlexSearch | Partial (n-gram via tolerant: true; needs tuning) | Sub-millisecond queries on 10K+ docs. Encoded index format (denser than JSON). Built-in WorkerIndex. | Loses Fuse’s typo tolerance default. No native match positions — we ship snippetHTMLForFlexMatch for substring-based highlight. | 2,500 – 10,000 documents |
| Pagefind | No (substring + word-boundary; no fuzzy mode) | Chunked on-demand index — only engine that scales past five-figure corpora without paying full-index download on first load. Returns pre-highlighted excerpts. | Crawls HTML pages, not JSON — needs a build step that emits one HTML per document. No fuzzy matching at all. | 10,000+ documents |
Why Fuse.js is the default here
Fuse.js is the right default for this package because the whole point — PDFs become search rows alongside your page text — assumes you already have a Fuse setup. The R3 reference site that motivated this package was Fuse-based; ICJIA’s Astro and Nuxt sites are Fuse-based; the spec calls Fuse out as the bullseye consumer.
Beyond that genealogy, Fuse fits the constraints:
- Pure client-side. The whole library is ~12 KB gzipped and runs in the browser. No API, no server, no database — the index ships as static JSON and Fuse loads it from the page. That’s the same shape this package delivers, so they pair without ceremony.
- Fuzzy matching tolerates typos. PDF text from
pdf.jshas its own quirks — extra whitespace, line breaks across columns, the occasional OCR-like artifact. A user typing “applicent” should still find “applicant”; Fuse’s bitap algorithm handles that with the threshold slider above. - Sensible defaults work. Three lines of code produces a usable index. The tuner above covers ~90% of real configuration needs.
- Mature and stable. v7.x has been the API surface for years. No churn, no breaking releases mid-deploy.
How to implement
// Build-time (Node): index your PDFs into IndexedDocument[] rows
import { indexDocuments } from '@icjia/pdf-search-index';
const rows = await indexDocuments([
'https://site.com/report.pdf',
'https://site.com/policy.docx',
]);
// Write rows to public/searchIndex.json (your build step)
// Runtime (browser):
import Fuse from 'fuse.js';
import { snippetHTMLFor } from '@icjia/pdf-search-index/snippet';
const rows = await fetch('/searchIndex.json').then(r => r.json());
const fuse = new Fuse(rows, {
keys: ['title', 'text'],
threshold: 0.2,
includeMatches: true,
});
const results = fuse.search('stigma');
for (const r of results) {
console.log(r.item.title, snippetHTMLFor(r));
}
// Optional v1.2+: build a prebuilt index to skip the in-browser
// Fuse.createIndex() cost (~10s → ~200ms at 2K rows):
import { serializeFuseIndex } from '@icjia/pdf-search-index/fuse';
const indexJson = serializeFuseIndex(rows);
// Write indexJson alongside the rows JSON.
// Then at runtime:
const fuseIndex = Fuse.parseIndex(await fetch('/searchIndex.fuse-index.json').then(r => r.json()));
const fuse = new Fuse(rows, options, fuseIndex);
// Optional v1.2+: off-main-thread search via FuseWorker:
import { FuseWorker } from '@icjia/pdf-search-index/worker';
const fuse = new FuseWorker(rows, { keys: ['title', 'text'], threshold: 0.2 });
const results = await fuse.search('stigma');
fuse.terminate(); // on unmountNot the only option
The output of this package is plain JSON — every client-side search engine consumes it. Below is the honest landscape; pick by team familiarity and corpus size. For the 14-doc demo you’re looking at, all of these are interchangeable.
| Engine | Strengths | Tradeoffs | Best corpus size |
|---|---|---|---|
| Fuse.jsdemo default | Best typo tolerance (Bitap). Native match-position output for snippet highlighting. Built-in FuseWorker in 7.4.0-beta.6+. Smallest API. | In-memory; full index loaded up-front. Build cost grows with corpus. | < 2,500 documents |
| MiniSearch | Lighter index format than Fuse. Slightly better relevance ranking via TF-IDF. Easy API. Good for prefix / autocomplete. | No native fuzzy matching (substring + token-prefix). No built-in worker. | < 5,000 documents |
| Orama | Zero-config; multi-language tokenizers. Fast on medium corpora. Vector / hybrid search support if you need it later. | Newer library (younger ecosystem than Fuse / Lunr / MiniSearch). Heavier bundle. | < 5,000 documents |
| FlexSearch | Sub-millisecond queries on 10K+ docs. Encoded index format (denser than JSON). Built-in WorkerIndex. Phonetic / stemming encoders. | Loses Fuse’s typo tolerance (n-gram mode exists but needs tuning). No native match positions — you do your own substring search for highlight. | 2,500 – 10,000 documents |
| Pagefind | Chunked on-demand index — only engine that scales past five-figure corpora without paying full-index download on first load. Returns pre-highlighted excerpts. | Crawls HTML pages, not JSON — needs a build step that emits one HTML per document. More setup than Fuse / FlexSearch / MiniSearch. | 10,000+ documents |
| Lunr | Battle-tested classic; Solr-like inverted index in pure JS. Predictable behavior. | No fuzzy matching. No web-worker mode out of the box. Less actively developed lately. | < 3,000 documents |
| Typesense / MeiliSearch / Algolia | Managed services; backend indexing; production-grade scaling; rich UI components. | Not client-side; requires a running service (or Algolia’s SaaS). Defeats the "no servers" tradeoff this package is built around. | Any size — server handles it |
Inspect the search index
Curious how the package’s output looks before it hits Fuse? This is the raw IndexedDocument[] array that ships as /searchIndex.pdfs.json. Each row carries the URL, title, full extracted text, page count (when surfaced by the parser), format discriminator ('pdf' | 'docx' | 'pptx' | 'xlsx'), and a stable hash-derived id. Your search engine of choice consumes this same shape — Fuse here, MiniSearch / Orama / Lunr / Algolia / Typesense elsewhere.
Show the document rows (IndexedDocument[])
[]New in 1.2: the build also emits a prebuilt Fuse index at /searchIndex.fuse-index.json. Consumers load both files and pass the index to Fuse.parseIndex(...) at runtime, skipping the in-browser build (cuts first-paint setup from ~10 s to ~200 ms at the 2,000-row scale). At this demo's 14 rows the delta is invisible — the dropdown below is a diagnostic so you can see what the prebuilt-index file actually looks like.
(Prebuilt Fuse index file not loaded — older build, or a fetch failure. Rebuild the demo to emit searchIndex.fuse-index.json.)