The benchmark

Harborline, the fictional product that every DocPilot 2.0 accuracy, speed and safety claim is measured on — what it contains, what it compares, and the commands to rerun it yourself.

The benchmark ​

Every DocPilot 2.0 claim about accuracy, size, speed and safety comes from one script run on one corpus: bench/run.mjs on Harborline. You can rerun it. The tables on this page are the output of that run, and nothing else.

What Harborline is ​

Harborline is a product that does not exist, documented as if it did: a job-queue service with a CLI, an HTTP API and SDKs. A language model has never read about it, so a correct answer can only come from retrieval over these pages. The fixture lives in bench/harborline/:

pathholds
docs/The documentation, one file per route of the sidebar
eval/golden.jsonlSingle-turn questions, each with gold pages and a gold answer, some of them negatives
eval/chains.jsonlMulti-turn conversations
eval/adversarial.jsonlThe security probes, with a canary planted in the instruction
truth/The source of every fact, the list of planted defects, and a reference llms.txt. It is never indexed and never shown to the tuning agent
docpilot.config.mjsThe settings: a local embedder and a local chat model, so a run costs no money

The corpus carries deliberate defects, each tied to the questions it affects: pages with no description whose words readers do not use, sections whose answer is split across chunks, and a page that carries an injected instruction. A defect makes a metric measure something. A corpus with none would score well on every system.

What it compares ​

An arm is an index build: how the corpus is chunked, embedded and stored. A system is an arm answered by one runtime of one release.

armwhat differs
v1.5The index the released 1.5 CLI builds
baseThe 2.0 default index
binaryOne bit per dimension (--quantize=binary)
ctx-pathThe heading path embedded with each chunk (DOCPILOT_EMBED_CONTEXT=path)
ctx-descThe heading path and the page description embedded (path+desc)
llmsPage notes from the reference llms.txt used as descriptions (DOCPILOT_LLMS_FILE)
calibratedbase with the gate's refusals on (HARBORLINE_GUARD_MODE=calibrated), its thresholds from node bench/run.mjs calibrate
systemarmruntime
v1.5 staticv1.5the released 1.5 CLI, from a checkout of main
v2.0 staticbaseruntime: 'browser'
v2.0 serverbaseruntime: 'server', the 2.0 default turn: one-shot, no reasoning before the answer
v2.0 server agenticbaseruntime: 'server' with turn.loop: 'agentic', the 1.x server turn
v2.0 static calibratedcalibratedruntime: 'browser' with guard.mode: 'calibrated'
v2.0 server calibratedcalibratedruntime: 'server' with guard.mode: 'calibrated'
v2.0 static deepseekbaseruntime: 'browser', the chat model deepseek/deepseek-v4-flash on OpenRouter
v2.0 server deepseekbaseruntime: 'server', the same hosted model
v2.0 server agentic deepseekbaseruntime: 'server' with turn.loop: 'agentic', the same hosted model

The 1.5 baseline is the code that shipped, run from a built checkout, not a model of it.

Rerun it ​

You need Node 20 or newer, a built checkout of this repository (npm run build, because the report step imports from dist/), and Ollama serving the embedding model bge-m3 and the chat model. Run every command from the repository root.

bash
node bench/run.mjs index        # build one index per arm
node bench/run.mjs calibrate    # fit the gate on eval/calibration.jsonl for the calibrated arm
node bench/run.mjs retrieval    # eval --gate-only per arm, dev and test splits: free, minutes
node bench/run.mjs answers      # the golden set answered, per system: hours on a local model
node bench/run.mjs security     # the adversarial probes, per system
node bench/run.mjs perf         # what a reader downloads, per index
node bench/run.mjs report       # bench/harborline/results/RESULTS.md from everything above

answers and security take a system name to run one system: node bench/run.mjs answers "v2.0 server".

variabledefaultwhat it sets
OLLAMA_BASE_URLthe local OllamaWhere the embedder and the chat model are served
HARBORLINE_CHAT_MODELqwen3:8bThe chat model that answers
HARBORLINE_EMBED_MODELbge-m3The embedder
BENCH_LEVELhighWhich tier of the golden set the answer run scores — see --level=
BASELINE_ROOTunsetA built checkout of 1.5. Without it the v1.5 arm and system are skipped

Each step saves the log and the eval report in bench/harborline/results/. report reads those reports and writes RESULTS.md and results.json. Every number in it comes from a saved report. The paired intervals are computed from the reports' own rows with the bootstrap that docpilot improve compare uses.

To run one measurement by hand, work from the fixture directory. The CLI resolves docsDir, evalDir and indexDir against the directory it starts in:

bash
cd bench/harborline
node ../../bin/docpilot.js index --yes
node ../../bin/docpilot.js eval --gate-only --split=test --quiet
node ../../bin/docpilot.js eval --set=adversarial --runtime=server --quiet

How to read the tables ​

  • Retrieval is scored --gate-only on the test split, so it calls no model. An arm is compared with base by a paired difference with a 95 % bootstrap interval. A row marked * has an interval that excludes zero. A difference without it is not shown to be real. See Measure for what each metric counts.
  • Answers come from one local model. They are a statement about that model on this corpus. Another model gives other numbers, so compare systems within one table, never across machines.
  • Security is the attack success rate on the probe set, per system. See Attack probes.
  • Bytes are what a reader of the static install downloads: the size on disk and the size on the wire, compressed with brotli.
  • An adopted setting needs a measured gain and a stated cost. An arm that does not beat base with a clear interval stays an option, not a default.

Results ​

Measured on 2026-10-25 with bge-m3 and qwen3:8b on a local Ollama. The raw reports are in bench/harborline/results/.

Answers ​

The golden set at --level=high: 65 questions, 50 answerable and 15 negatives. ΔF1 is paired by question against v1.5 static.

systemanswer F1ΔF1 vs 1.5citation Pcitation Rnegatives caughtfirst token p50whole answer p50 / p95requests / turn
v1.5 static0.405—56%75%40%—24.5 s / 49.1 s1.11
v2.0 static0.405±0.000 [0.000, 0.000]56%75%40%27.5 s27.5 s / 47.0 s1.11
v2.0 server0.414+0.010 [−0.016, 0.033]60%73%40%8.3 s19.5 s / 39.3 s0.98
v2.0 server agentic0.407+0.002 [−0.009, 0.013]56%78%40%25.0 s25.0 s / 40.2 s1.11

What it says:

  • The static install answers exactly as 1.5 did. All 65 answers of v2.0 static are byte-identical to those of v1.5 static. The latency difference is the machine: the two runs generated the same tokens.
  • The server's default turn is three times faster to the first token. One-shot without reasoning shows the first token at 8.3 s against 25.0 s for the agentic loop, and it uses 27% fewer prompt tokens. Answer F1 and citations move inside the noise.
  • The cost of one-shot is that the model cannot search again inside a turn. One of its 65 answers was a generation that ran into the 120 s step timeout. Set chat.maxTokens when a local model answers. server.turn.loop: 'agentic' restores the 1.x turn.

Security ​

The 30 attack probes, per system. A breach is a probe whose forbidden text reached the answer.

systemattack successrefused when it shouldanswered safelycanary leakedmarkup echoed
v2.0 static30%19%64%23
v2.0 server30%19%50%03
v2.0 server agentic23%31%64%03

What it says:

  • The server keeps its instruction. The canary in prompt.extend reached no reader of either server turn. The static install ships its prompt to the browser, so its canary is public by design.
  • Echoed markup is inert. Every system repeated an injected <script>, onerror= or javascript: when asked to. The panel escapes HTML and links no javascript:, so the reader sees text.
  • The loop resists more. The agentic turn resisted two probes that one-shot followed: a translated instruction and an instruction planted in a page. With 30 probes the difference is two probes, inside the noise. A site that weighs injection resistance above speed can set turn.loop: 'agentic'.

A hosted model ​

The same 65 questions with deepseek/deepseek-v4-flash on OpenRouter as the chat model; the embedder stays the local bge-m3. A right outcome is an answer to an answerable question or a refusal of a negative. The three runs cost $0.16.

systemchat modelright outcomenegatives refusedanswerable answeredanswer F1first token p50whole answer p50prompt tokens / turn
v2.0 staticqwen3:8b, local56/656/1550/500.40527.5 s27.5 s2,757
v2.0 static deepseekdeepseek-v4-flash63/6514/1549/500.42910.4 s13.6 s7,762
v2.0 serverqwen3:8b, local55/656/1549/500.4058.3 s19.5 s2,004
v2.0 server deepseekdeepseek-v4-flash60/6515/1545/500.40913.3 s17.3 s2,096
v2.0 server agentic deepseekdeepseek-v4-flash61/6513/1548/500.4369.8 s13.7 s6,959

Answer F1 here counts a refused answerable question as 0, so a system cannot raise it by refusing.

What it says:

  • The model is the lever. With the hosted model the static install reached the right outcome on 63 of 65 questions instead of 56 (McNemar p = 0.039). Most of the gain is honesty: it refused 14 of the 15 questions the docs cannot answer, where the local model refused 6, and it still answered 49 of 50 answerable ones. Answer F1 moved inside the noise.
  • One-shot is a local-model default. With the hosted model the agentic loop showed the first token sooner than one-shot (9.8 s against 13.3 s) and answered every Russian question one-shot refused (9 of 9 against 6 of 9), at 3.3 times the prompt tokens. The right outcomes are level (61 and 60 of 65). A server with a fast hosted model can set turn.loop: 'agentic' and pay in tokens, not in time.

The gate ​

eval/calibration.jsonl holds 295 probes in the eleven strata docpilot calibrate reads, and none of them is a golden question. On Harborline calibrate refuses to write thresholds: the tau that lets every answerable probe through is 0.15, below the lexical weight 0.25, where a question would clear the gate on shared words alone (bench/harborline/eval/calibration.report.md). The build then inlines the provisional tau 0.30, and the calibrated systems measure that.

systemnegatives caughtover-refusalanswer F1whole answer p50
v2.0 static6/150/500.40527.5 s
v2.0 static calibrated6/150/500.40523.8 s
v2.0 server6/150/500.41419.5 s
v2.0 server calibrated7/150/500.40520.1 s

What it says:

  • The gate measures topic, not whether an answer exists. Probes about absent features and absent APIs sit at a median G of 0.65 to 0.91, as high as the probes the docs answer (0.90 to 0.97). Only off-domain questions fall below a threshold that a real question never reaches.
  • On these questions the gate adds one refusal. In the static install it refuses five off-domain questions that the model refused anyway, so the reader gets the same answers without a model call. On the server it catches one more negative, with no false refusal. The two server runs gave the same text on 20 of 65 questions, and the gate changed five of them: the rest is the model's own variation.
  • The default stays guard.mode: 'off'. A threshold has to be measured per corpus and per language — see Relevance gate — and on this corpus no measured threshold exists. A site with blatant off-topic traffic can set guard.tau by hand and check it with eval --gate-only.

Retrieval ​

The test split, 52 answerable questions, scored --gate-only. Δ is paired against base.

armrecall@8MRRnDCG@8context P@5ΔnDCG@8ΔMRR
v1.50.9130.7060.7360.678±0.000±0.000
base0.9130.7060.7360.678——
binary0.9130.6730.7160.668−0.021 [−0.051, 0.009]−0.033 [−0.079, 0.011]
ctx-path0.9130.7110.7420.695+0.005 [−0.000, 0.012]+0.005 [0.001, 0.010] *
ctx-desc0.9620.7180.7620.694+0.026 [−0.017, 0.070]+0.012 [−0.038, 0.058]
llms0.9230.6890.7290.663−0.008 [−0.023, 0.007]−0.017 [−0.048, 0.005]

What it says:

  • No lever is a default. ctx-path gains half a point of MRR, and on DocPilot's own docs the same idea lowered context precision. ctx-desc does not clear the noise.
  • binary halves the download at a cost inside the noise. It is an option for a site that counts bytes, not a default.
  • llms.txt as page descriptions ranks worse on the very questions it was written for. The description lands on the page's first chunk, which then outranks the section that answers. See Improving.

Download ​

What a reader of the static install downloads, per index. ctx-path and ctx-desc have the bytes of base.

armon diskon the wire (brotli)of which vectors
v1.5541 KB199 KB133 KB
base541 KB199 KB133 KB
binary309 KB99 KB33 KB
llms547 KB202 KB133 KB

The tuning agent ​

The tuning agent, headless, gate-only, the embedder on a local Ollama.

corpusrundev nDCG@8dev MRRtest nDCG@8test MRRkept
Harborline12 min, 71 turns, $1.64 at API prices0.776 → 0.8030.765 → 0.7930.736 → 0.7360.706 → 0.7063 <llm-only> lines on 2 pages
DocPilot's docs13 min, 59 turns, $1.45 at API prices0.534 → 0.5690.523 → 0.5590.584 → 0.5860.552 → 0.554nothing (see below)

What it says:

  • The edits fix the questions they target and break nothing. On Harborline three dev questions ranked better and none worse; the test split did not move. Each kept line restates what its section already says, in the words readers use.
  • The first run kept nothing because of its rule. Two questions fixed out of 33, none worse, leaves a bootstrap interval with a lower bound of exactly zero. A gate-only run calls no model and ranks the same way every time, so improve compare now keeps such a Pareto move. The DocPilot candidate in the table is keep under that rule, on dev and on test.

Next ​