@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

  1. Extract

    At build time, each PDF is fetched, parsed with pdf.js, and its text is added to a JSON index.

  2. Bundle

    The JSON ships with the site as a static asset — no API, no server, no database.

  3. 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.

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.

Search in:

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.html

Why Fuse.js?

EngineFuzzyProsConsProduction sweet spot
Fuse.jsYes (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
FlexSearchPartial (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
PagefindNo (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.js has 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 unmount

Not 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.

EngineStrengthsTradeoffsBest 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.)