project-nomad/admin/tests/unit/rag_pipeline_prompt.spec.ts
Jake Turner 9a95f7b2b8 feat(AI): budget the prompt and actually set the context window (#1253)
Chat was run through Ollama's OpenAI-compatible /v1 endpoint, which has no
context-size field, so the num_ctx we sent was silently discarded and the
entire NUM_CTX_LADDER was dead code. Every conversation ran at the backend
default and was truncated from the middle (where history and
retrieved context actually sit). Measured with identical ~7k-token prompts:
2,060 tokens processed against 7,049 on the native endpoint, and with the
needed fact in the discarded middle the old path hallucinated a plausible
wrong number.

Transport: add a native /api/chat path using the already-installed ollama
package, sending a real options object (num_ctx, num_predict, temperature,
seed) plus keep_alive. The OpenAI path is still used for LM Studio and llama.cpp,
minus the ineffective num_ctx and with include_usage so both paths return
prompt token counts. /api/show is now read for trained context, parameter
size, quantization and any modelfile num_ctx. The <think> stream normalizer
moves to app/utils/think_stream.ts so both transports share it; it gains a
flush() that fixes a dangling partial tag being dropped at end of stream.

Token accounting: replace chars/N with a structural segmenter scaled by a
per-model factor learned by EWMA from the prompt_eval_count every response
already returned and threw away. End-to-end estimator error is 4.6% against
18-20% for a fixed divisor — qwen2.5 converges to k=1.26 where llama3 sits
at 1.00, so no single global constant serves both.

Context window: resolved once per model from trained context, the exact KV
cost derived from GGUF metadata (head_count_kv, not head_count — GQA makes
them differ 4x), and available VRAM, then memoized. Memoization is
load-bearing: asking for a different num_ctx makes Ollama unload and reload
the model, stalling the turn and discarding the KV cache. The floor never
overrides the model's trained ceiling — tinyllama trains to 2048, and a
blind 4096 floor pushes RoPE past anything the weights saw.

Prompt budget: planPrompt allocates explicitly instead of delegating
overflow to silent backend truncation — response reserve, then fixed blocks
(system and current query, never dropped), then RAG chunks whole and
best-first, then history in whole user+assistant turns, evicted newest-first
in blocks with an elision marker. Block eviction keeps the prefix stable for
several turns rather than shifting on every one.

KV-cache ordering: the per-turn retrieved block moves behind history to the
tail, so the stable prefix stays byte-identical turn to turn. At turn 14 of
a long conversation, prefill is 231ms at the tail against 539ms at the front
(~1ms/turn growth against ~25ms). The win is conditional — with a large
retrieved block and short history the two measure the same — so both
orderings ship behind RAG_PLACEMENT. The per-turn query rewrite also moves
to the tasks model; on a single-slot Ollama it was evicting the chat model's
cached prefix on every single turn.

Also: OLLAMA_CONTEXT_LENGTH and OLLAMA_KV_CACHE_TYPE=q8_0 on the container
(q8_0 is treated as headroom, never as a budgeting assumption, since
unsupported architectures fall back to f16 silently), a Context Window
setting in Settings > Models, and budget fields on PipelineTrace.

Eval: golden suites (--suite) so the new long_context goldens cannot move
the aggregates the committed baselines were recorded against, plus budget
and estimator-error metrics through the report path. Retrieval is unchanged
and eval:compare reports no metric outside the tolerance band; the
long_context suite scores correctness 1.000 with historyElided 0.500 (the
two eviction cases, by design). Unit tests 337 -> 386 passing, same 7
pre-existing failures.

Ingest-side CHAR_TO_TOKEN_RATIO is deliberately untouched: changing it
re-chunks the corpus and invalidates retrieval baselines, which belongs in
its own measured change.
2026-09-29 15:54:41 -07:00

104 lines
3.9 KiB
TypeScript

/**
* Characterization tests for the prompt-assembly helpers extracted out of
* OllamaController into RagPipelineService.
*
* These lock in the *current* behaviour so any later deliberate change shows up
* as a failing test rather than a silent shift in answer quality.
*
* Token budgeting moved out of this module: the estimate now lives in
* `token_estimate.spec.ts` and the allocation in `context_budget.spec.ts`. What
* remains here is model-size tiering and context-block rendering.
*
* Pure functions only — no MySQL, Redis, Qdrant, or Ollama needed:
* npm run test:unit
*/
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import {
buildContextBlock,
getContextLimitsForModel,
type ContextLimitTier,
} from '../../app/utils/rag_prompt.js'
/** Mirrors RAG_CONTEXT_LIMITS in constants/ollama.ts. Passed in explicitly so
* these tests need no AdonisJS-flavoured imports, per the kb_ratio_lookup
* precedent — and so a change to the shipped tiers shows up here as a
* deliberate edit rather than a silently-passing test. */
const TIERS: ContextLimitTier[] = [
{ maxParams: 3, maxResults: 2, maxTokens: 1000 },
{ maxParams: 8, maxResults: 4, maxTokens: 2500 },
{ maxParams: Infinity, maxResults: 5, maxTokens: 0 },
]
const limits = (model: string) => getContextLimitsForModel(model, TIERS)
const chunk = (text: string, metadata: Record<string, any> = {}) => ({
text,
score: 0.5,
metadata,
})
// --- getContextLimitsForModel ------------------------------------------------
test('context limits: 1-3B models get the tightest budget', () => {
assert.deepEqual(limits('llama3.2:1b'), { maxResults: 2, maxTokens: 1000 })
assert.deepEqual(limits('qwen2.5:3b'), { maxResults: 2, maxTokens: 1000 })
})
test('context limits: fractional sizes parse correctly', () => {
// "1.5b" must read as 1.5, not 1 or 15 — it decides which tier the model lands in.
assert.deepEqual(limits('qwen2.5:1.5b'), { maxResults: 2, maxTokens: 1000 })
})
test('context limits: 4-8B tier', () => {
assert.deepEqual(limits('llama3.1:8b'), { maxResults: 4, maxTokens: 2500 })
})
test('context limits: 13B+ is uncapped', () => {
assert.deepEqual(limits('llama2:70b'), { maxResults: 5, maxTokens: 0 })
})
test('context limits: unparseable model name is assumed to be 8B', () => {
// Documented quirk, not an endorsement: "phi3" has no size token, so it is
// handed the 4-8B budget regardless of what it actually is.
assert.deepEqual(limits('phi3'), { maxResults: 4, maxTokens: 2500 })
})
test('context limits: quantization suffixes do not confuse the size parse', () => {
assert.deepEqual(limits('llama3.1:8b-text-q4_1'), {
maxResults: 4,
maxTokens: 2500,
})
})
// --- buildContextBlock -------------------------------------------------------
test('context block: numbers each chunk from 1', () => {
const out = buildContextBlock([chunk('first'), chunk('second')])
assert.equal(out, '[Context 1]\nfirst\n\n[Context 2]\nsecond')
})
test('context block: labels with full_title when present', () => {
const out = buildContextBlock([chunk('body', { full_title: 'Water - Boiling' })])
assert.equal(out, '[Context 1 — Water - Boiling]\nbody')
})
test('context block: falls back to article_title', () => {
const out = buildContextBlock([chunk('body', { article_title: 'Water' })])
assert.equal(out, '[Context 1 — Water]\nbody')
})
test('context block: full_title wins over article_title', () => {
const out = buildContextBlock([chunk('body', { full_title: 'A - B', article_title: 'A' })])
assert.equal(out, '[Context 1 — A - B]\nbody')
})
test('context block: never leaks the relevance score to the model', () => {
// Deliberate: nomic cosine scores for genuinely relevant passages sit around
// 0.4-0.6, and showing the model "42%" primes it to distrust correct context.
const out = buildContextBlock([chunk('body', { source: 'f.md', semantic_score: 0.42 })])
assert.ok(!out.includes('0.42'))
assert.ok(!out.includes('42'))
})