Quick start
One script, one PDF, one cited answer.
One file, start to finish. Copy it, point it at a PDF, run it.
Install
bun add vectorless-js @llamaindex/liteparse-wasm
The core has one dependency, a tokenizer that only loads if you ask for summaries. The WASM PDF parser is an optional peer dependency, so it is a separate install and anyone feeding it Markdown never downloads it.
Point at a provider
The config matches the OpenAI client, so the usual environment variables work as they are:
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o
Anything speaking OpenAI chat completions works, including a local server. In a browser you drop the key and point at a proxy instead, which the Browser page covers.
The whole thing
// ask.ts
import { initParser, pdfToMarkdown } from "vectorless-js/parse";
import { buildIndexAuto, ask, 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);
// The parser will not guess where the wasm lives, which is what lets the same
// code run in a browser, in Bun, or on an edge runtime. So hand it over.
await initParser(
await Bun.file("node_modules/@llamaindex/liteparse-wasm/pkg/liteparse_wasm_bg.wasm").arrayBuffer(),
);
const [path, question] = process.argv.slice(2);
if (!path || !question) {
console.error('usage: bun run ask.ts <file.pdf> "<question>"');
process.exit(1);
}
// PDF to Markdown, on this machine. The file stays put.
const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
const markdown = await pdfToMarkdown(bytes);
// Headings become a tree, for free. If the document has none, which contracts
// usually do not, an LLM infers the outline instead.
const doc = await buildIndexAuto(markdown, path, think);
console.log(`indexed ${path}: ${doc.structure.length} top-level sections\n`);
// The model reads the table of contents, picks a section, and fetches only that.
const { answer, citations } = await ask(doc, question, llm, (e) => {
if (e.type === "tool") console.log(` ${e.name}(${JSON.stringify(e.input)})`);
});
console.log(`\n${answer}`);
console.log(`\nsources: ${JSON.stringify(citations)}`);
Run it:
bun run ask.ts ./lease.pdf "What is the notice period for termination?"
indexed ./lease.pdf: 9 top-level sections
list_documents({})
get_document_structure({"doc_id":"./lease.pdf"})
get_page_content({"doc_id":"./lease.pdf","pages":"241-268"})
Either party may terminate on 30 days written notice.
sources: [{"doc_id":"./lease.pdf","pages":"241-268"}]
The trace is worth reading closely, because it shows the model working. It read the table of contents, decided the answer was around line 241, and pulled 27 lines. The rest of the lease never reached it. The citation says where the answer came from, so a reader can check it instead of trusting you.
Where to go next
The script above re-indexes on every run, which is fine for one PDF and wasteful for a folder. Indexing costs LLM calls whenever it has to repair a flat tree, so you want that on disk. End to end splits it into an index script and an ask script, and adds routing across many documents.
Two other things worth knowing early. Summaries and descriptions make retrieval sharper and routing possible, and they belong at index time. OCR is off, so born-digital PDFs read fine and scanned pages come back empty.