Retrieval without embeddings. Instead of chunking a document and searching for text that looks similar, this builds a table of contents, hands it to an LLM, and lets the model pick which section to read, the way a person uses an index.
It runs wherever JS does: the browser through WASM, plus Bun, Node, and Cloudflare Workers. It is a JavaScript port of PageIndex, verified byte-for-byte against its Python tree builder.
Docs: vectorless.js.org
bun add vectorless-js
bun add @llamaindex/liteparse-wasm # optional, only if you feed it PDFsimport { buildIndexAuto, ask, complete, type LLMConfig } from "vectorless-js";
const llm: LLMConfig = {
baseURL: process.env.OPENAI_BASE_URL!, // https://api.openai.com/v1
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL, // defaults to gpt-4o
};
const doc = await buildIndexAuto(markdown, "lease.pdf", (p) => complete(p, llm));
const { answer, citations } = await ask(doc, "What is the notice period?", llm);The config mirrors the OpenAI client, so the standard environment variables drop straight in, and anything speaking OpenAI chat completions works. Pass a bare URL instead of a config when you point at a proxy that holds the key.
The PDF reader is a separate import. Keeping it out of the main entry point means readers who feed it Markdown skip the WASM download entirely:
import { initParser, pdfToMarkdown } from "vectorless-js/parse";
await initParser(wasmBytesOrUrl); // you supply the wasm; the library never guesses
const markdown = await pdfToMarkdown(bytes);PDF
| liteparse, compiled to WASM. Runs on your machine.
v
markdown
| buildIndex: headings become nodes.
v Deterministic, no LLM, free.
tree
| The only smart part. The model reads the table of
v contents and fetches the one section it picks.
answer + citations
buildIndex turns Markdown headings into a tree without calling an LLM or touching the network, so it costs nothing and gives you the same tree every run. The agent then navigates that tree through three tools that execute locally: get_document, get_document_structure, and get_page_content.
Running the tools locally is what keeps the document at home. On a 28-page filing the model sees about 1.6 KB of section titles and line numbers, picks one, and pulls only those lines. The other 60-odd KB stays where it is, because nothing asked for it. In a browser that extends to the file itself, which you never upload; the only thing crossing the network is text the model chose to read.
Some documents come out flat, and buildIndexAuto repairs them. A PDF that gives every heading the same rank has correct titles and no nesting, so an LLM assigns the depths and nothing but the headings leaves the machine. A contract with no headings at all has nothing to nest, so an LLM infers the outline from the text. Both feed the same deterministic builder.
Beyond one document, ask also takes a corpus keyed by doc id, and routeDocuments reads per-document descriptions to decide which are worth opening. That routing reads descriptions rather than vectors, same as the rest.
OCR is off, so born-digital PDFs read fine and scanned pages come back empty. Wiring a JS OCR engine through pdfToMarkdown's config is the fix.
A document with no headings at all goes through reconstructTree, which sends the whole thing to the model. That is fine for a contract and a problem for a large filing: 448 KB of text is a prompt around 133k tokens, which will not fit a 128k context window. Documents that do have headings avoid this, however uniform those headings are, because buildIndexAuto sends only the headings. Check isFlatTree before reaching for reconstruction on a large heading-less document.
The tree holds the document text, so memory tracks document size. A 53 KB contract becomes a 59 KB tree.
bun install
bun test # deterministic tree and tool logic
bun run typecheck
bun run build:lib # library → dist-lib/
bun run dev # demo app on :5173
bun run docs:dev # docs siteMIT. This is a port of PageIndex by Vectify AI, also MIT. Both notices are in LICENSE.