Skip to content

Retrieve in a browser or from a script

Run a graph function against these docs. Inspect its results, or enable a local model to answer questions about them.

Graph playground Proof of concept

Run a browser graph function on this site's docs. Inspect what came back, then optionally ask a local model about it.

1. Run a graph function

Function options
Scope and budget

Search uses lexical matching. No embedding model is downloaded.

Ready. The first run downloads this site's graph artifact.

Current settings (JavaScript)
2. Ask about the returned values (optional)

WebLLM runs Qwen3 1.7B on your device. Using this model requires about 1 GB of browser storage and 2.6 GB of video memory. Questions and passages stay in your browser.

Local answers are off.

Run a function first. Only a bounded selection of its returned values is sent to the small model.

Answers are experimental and unchecked. Larger models can give better answers. This playground is provided as an example.

A published artifact. manni graph publish writes chunks.json, its rows, the graph and the search index into one directory. Point it at the site’s public folder, and the site serves the artifact of the commit it builds:

Terminal window
manni graph publish --out docs/public/graph && manni graph check -g docs/public/graph/graph.ttl

Publish the graph covers each step.

The package. Install manni where the script runs. The runtime is one file with no dependencies and no Node APIs, so it runs in a browser, a worker or Node:

Terminal window
npm install @hawkeyexl/manni

createRetriever reads chunks.json, and lookup takes the question:

import { createRetriever } from "@hawkeyexl/manni/graph/runtime";
const docs = await createRetriever({ manifest: "/graph/chunks.json" });
const result = await docs.lookup("How does cite check decide a pin moved?");

The graph, the search index and the rows resolve from the manifest’s directory. The rows load once, the first time their language is queried.

The result holds three sections, and nothing is dropped from any of them:

  • matches: every ranked passage, with its lexical, vector and fused scores.
  • traversal: every passage reached by following links outward from the matches, with each edge followed.
  • impact: every passage that depends on the matches, with each edge.

Each passage carries iri, title, page, lines, text and its provenance record. A section has a character budget. A passage past it keeps everything but its text, which is null with truncated: true. The lookup result lists every key.

manni retrieves, and your model answers. This worker takes a question, asks lookup, and hands the passages to the model with the role each one plays:

worker.ts
import { createRetriever, type LookupResult } from "@hawkeyexl/manni/graph/runtime";
interface Passage {
id: string;
role: "answer" | "background" | "dependent";
title: string;
text: string;
unverified: boolean;
}
interface Env {
MODEL: { answer(input: { question: string; context: Passage[] }): Promise<string> };
}
const docs = await createRetriever({ manifest: "https://hawkeyexl.github.io/manni/graph/chunks.json" });
function passages(
result: LookupResult,
section: "matches" | "traversal" | "impact",
role: Passage["role"],
): Passage[] {
return result[section].passages.flatMap((p) =>
p.text === null
? []
: [{ id: p.iri, role, title: p.title, text: p.text, unverified: p.provenance.pins.error > 0 }],
);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const body: unknown = await request.json().catch(() => null);
const question = typeof body === "object" && body !== null && "question" in body ? body.question : null;
if (typeof question !== "string" || question === "") {
return Response.json({ error: 'Send JSON like {"question": "..."}.' }, { status: 400 });
}
const result = await docs.lookup(question);
if (result.refusal) return Response.json({ answer: null, refusal: result.refusal });
const context = [
...passages(result, "matches", "answer"),
...passages(result, "traversal", "background"),
...passages(result, "impact", "dependent"),
];
const answer = await env.MODEL.answer({ question, context }); // your model, your backend
return Response.json({ answer, citations: result.citations });
},
};
  • A bad request is a 400. The worker answers malformed JSON, or a missing or empty question, with a 400 before it retrieves anything.
  • A refusal is an answer. When nothing in the docs matches, refusal says why, and the worker says so rather than letting the model guess.
  • A passage over the budget has no text. The worker skips it. Fetch it by its iri from the rows when you need the words.
  • citations names the source of each passage, so the answer can link to it.

MODEL stands for whatever backend you call. manni never sees the prompt or the answer.

Every passage carries a provenance record. The worker reads one field of it, and these are the others an Ask AI tends to use.

FieldWhat it tells the answer
pins.errorSentences whose cited source changed. Flag them, or leave them out.
pins.current, pins.totalHow much of the passage is pinned to source, and still holds.
dates.lastUpdated.dayThe day the page last changed, safe across a squash merge.
dates.lastVerifiedThe day a check last proved the page, as the page records it.
evals.current, evals.failWhether the eval verdicts describe this text, and how many failed.

pins and dates come from git when the artifact was published. evals is null unless graph.export.chunks.evals names a docevals report. The provenance record defines each field.

The steerable calls, retrieve, traverse and impact, return a bundle instead. Each passage in a bundle carries role: match, context or dependent. In a lookup result, the section is the role.

The rows are self-contained, so a vector DB ingests them as they are. Each row’s id is its graph node IRI:

import { parseChunkRows } from "@hawkeyexl/manni/graph/runtime";
const text = await (await fetch("https://docs.example.com/graph/chunks.en.jsonl")).text();
for (const row of parseChunkRows(text)) await db.upsert(row.id, row.vector, row);

Then pass a store that queries your DB instead of the rows’ vectors. Every other line of the worker stays the same:

import { createRetriever, createHttpVectorStore } from "@hawkeyexl/manni/graph/runtime";
const docs = await createRetriever({
manifest: "https://docs.example.com/graph/chunks.json",
store: createHttpVectorStore({ url: "https://docs.example.com/api/vectors" }),
});

The store POSTs one JSON object per query, and your server answers with the matches.

{ "text": "How does cite check decide a pin moved?",
"vector": [0.0132, -0.0871],
"k": 3,
"language": "en",
"where": [] }
{ "matches": [ { "id": "https://docs.example.com/cite/#how-a-pin-is-classified", "score": 0.81 } ] }
Request keyMeaning
textThe question.
vectorThe question’s embedding, or null when the script did not embed. Embed on the server then.
kHow many matches to return.
languageThe localization to search, or null for every language.
whereMetadata conditions, { field, value }, that every match must meet.

A match may carry its row, which is then used as it is. Without one, the id is read from the rows. An id no row holds is dropped and traced. The library API reference has both seams.

chunks.json names a model per language, under languages.<tag>.model. A question is embedded with its language’s model, so the vectors compare. The first query in a language with a model downloads that model’s weights. The small models the configuration reference lists run from about 23 MB.

Two ways to skip the download:

  • embed: false ranks by lexical match alone.
  • A hosted store receives vector: null and embeds on the server.

When the model cannot load, entry falls back to lexical, and trace.entry.fallback reads "lexical". A language with no model is lexical from the start. That is this site’s case, since it configures no graph.embed.

graph.retrieve.withhold leaves drifted or failing passages out of the published rows, so no host holds their text. retrieve, traverse and impact also take withhold, which tightens that floor for one call:

const bundle = await docs.retrieve("How do I rotate the key?", { withhold: ["drifted"] });

It never lowers the floor, because rows left out at export are not in the artifact. lookup takes only the question and serves the published floor. Each passage left out is named in trace.withheld.

A refusal comes back in the result. Two errors throw instead:

  • StaleArtifactError: the graph or a row was built from another graph than chunks.json names. Publish again.
  • ModelMismatchError: the embedder you passed is not the model the queried language was embedded with.