End to end
A folder of PDFs to an answer with citations, in two scripts.
Everything on this page is one worked example: point it at a folder of contracts, index them once, then ask questions against the lot. Two scripts, because indexing and asking have completely different cost profiles and you want to run them on different schedules.
Before you start
bun add vectorless-js @llamaindex/liteparse-wasm
ask needs somewhere to send LLM calls. Anything speaking OpenAI chat completions works. This runs as a Bun script, so it can hold a key and talk to a provider directly. From a browser you would drop the key and point at a proxy instead, because CORS blocks the direct call and a key in the page is a key anyone can read.
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o
Script one: index the folder
This runs once per document. Parsing and tree building are free. Summaries and descriptions cost LLM calls, which is exactly why the result gets written to disk.
// index.ts
import { readdir } from "node:fs/promises";
import { initParser, pdfToMarkdown } from "vectorless-js/parse";
import {
buildIndexAuto,
addNodeSummaries,
generateDocDescription,
complete,
type LLMConfig,
} from "vectorless-js";
const llm: LLMConfig = {
baseURL: process.env.OPENAI_BASE_URL!,
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL,
};
const think = (p: string) => complete(p, llm); // supplies `Complete`
// Hand the parser its wasm. It will not guess, which is what lets the same
// code run in a browser, in Bun, or on an edge runtime.
await initParser(
await Bun.file("node_modules/@llamaindex/liteparse-wasm/pkg/liteparse_wasm_bg.wasm").arrayBuffer(),
);
for (const file of await readdir("./contracts")) {
const bytes = new Uint8Array(await Bun.file(`./contracts/${file}`).arrayBuffer());
const markdown = await pdfToMarkdown(bytes);
// Deterministic when the document has headings. Falls back to asking an LLM
// for the outline when it does not, which contracts usually do not.
const doc = await buildIndexAuto(markdown, file, think);
await addNodeSummaries(doc, think); // so the agent can navigate by summary
await generateDocDescription(doc, think); // so the router can pick this document
await Bun.write(`./trees/${file}.json`, JSON.stringify(doc));
console.log(`${file}: ${doc.structure.length} top-level sections`);
}
Run it:
bun run index.ts
lease-acme.pdf: 9 top-level sections
msa-globex.pdf: 14 top-level sections
nda-initech.pdf: 6 top-level sections
The trees are plain JSON files on disk, which is as much of a storage layer as this needs.
Script two: ask
This runs once per question. It reads the trees back and never touches the PDFs again.
// ask.ts
import { readdir } from "node:fs/promises";
import { routeDocuments, ask, complete, type Corpus, type LLMConfig } from "vectorless-js";
const llm: LLMConfig = {
baseURL: process.env.OPENAI_BASE_URL!,
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL,
};
const think = (p: string) => complete(p, llm);
// Rehydrate. JSON.parse gives you back exactly what you saved.
const corpus: Corpus = {};
for (const f of await readdir("./trees")) {
const doc = JSON.parse(await Bun.file(`./trees/${f}`).text());
corpus[doc.doc_id] = doc;
}
const question = "What is the notice period for termination?";
// Narrow first. The router reads the descriptions, not the documents.
const ids = await routeDocuments(corpus, question, think);
const shortlist = Object.fromEntries(ids.map((id) => [id, corpus[id]]));
console.log("searching:", ids);
const { answer, citations } = await ask(shortlist, question, llm, (e) => {
if (e.type === "tool") console.log(` ${e.name}(${JSON.stringify(e.input)})`);
});
console.log("\n" + answer);
console.log("\nsources:", citations);
What you see when it runs
searching: [ "lease-acme.pdf", "msa-globex.pdf" ]
list_documents({})
get_document_structure({"doc_id":"lease-acme.pdf"})
get_page_content({"doc_id":"lease-acme.pdf","pages":"241-268"})
Either party may terminate on 30 days written notice.
sources: [ { doc_id: "lease-acme.pdf", pages: "241-268" } ]
Read that trace from the top. The router dropped the NDA before anything was opened. The agent listed what was left, read one table of contents, decided the answer lived around line 241, and pulled 27 lines. It never saw the rest of the contract, and it never saw Globex’s body text at all.
The citation is the part to keep. It says which document and which lines produced the answer, so you can show a reader the source instead of asking them to trust the model.
What this costs
Indexing three contracts costs one reconstruction plus one summary pass plus one description per document. You pay it once and it lands on disk.
Each question costs one routing call plus three to six calls for the retrieval loop. That number follows how many times the agent has to look, not how long the contracts are, so a 300 page agreement costs about what a 30 page one does.
If you re-run index.ts, only the LLM steps are worth skipping. buildIndex is deterministic and free, so rebuilding a heading-rich document is cheaper than caching it. Reconstruction, summaries, and descriptions are the parts you paid for.
The same thing in a browser
The only difference is where the bytes come from and how the parser gets its wasm:
import wasmUrl from "@llamaindex/liteparse-wasm/liteparse_wasm_bg.wasm?url";
await initParser(wasmUrl);
const llm = "/api/llm"; // no apiKey: the proxy holds it
const think = (p: string) => complete(p, llm);
const bytes = new Uint8Array(await file.arrayBuffer()); // from an <input type="file">
const doc = await buildIndexAuto(await pdfToMarkdown(bytes), file.name, think);
const { answer, citations } = await ask(doc, question, llm);
Parsing and tree building happen on the user’s machine. The only thing that crosses the network is the slice of text the agent decided to read.