mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 21:29:20 +02:00
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.
156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
|
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
|
|
|
/**
|
|
* Manage the frozen evaluation corpus.
|
|
*
|
|
* # Validate the corpus and goldens without touching the vector store
|
|
* node ace eval:corpus --check
|
|
*
|
|
* # Show what is currently ingested
|
|
* node ace eval:corpus --status
|
|
*
|
|
* # Wipe and rebuild (the normal path; safe — never touches user content)
|
|
* node ace eval:corpus --ingest
|
|
*
|
|
* # Remove the eval corpus entirely
|
|
* node ace eval:corpus --reset
|
|
*/
|
|
export default class EvalCorpus extends BaseCommand {
|
|
static commandName = 'eval:corpus'
|
|
static description = 'Manage the frozen RAG evaluation corpus (ingest, reset, status, check)'
|
|
|
|
@flags.boolean({ description: 'Wipe and re-ingest the corpus into the reserved eval collection' })
|
|
declare ingest: boolean
|
|
|
|
@flags.boolean({ description: 'Remove every eval corpus chunk from the vector store' })
|
|
declare reset: boolean
|
|
|
|
@flags.boolean({ description: 'Show the fingerprint and current chunk count' })
|
|
declare status: boolean
|
|
|
|
@flags.boolean({
|
|
description: 'Validate the corpus and goldens only — no Qdrant, no Ollama, no writes',
|
|
})
|
|
declare check: boolean
|
|
|
|
@flags.boolean({ description: 'Leave application debug logging on (very noisy)' })
|
|
declare debug: boolean
|
|
|
|
static options: CommandOptions = {
|
|
startApp: true,
|
|
}
|
|
|
|
async run() {
|
|
const { EvalCorpusService } = await import('#services/eval_corpus_service')
|
|
const { quietLogging } = await import('../../app/utils/eval/quiet.js')
|
|
const service = await this.app.container.make(EvalCorpusService)
|
|
const restoreLogging = quietLogging(this.debug)
|
|
try {
|
|
await this.dispatch(service)
|
|
} finally {
|
|
restoreLogging()
|
|
}
|
|
}
|
|
|
|
private async dispatch(service: any) {
|
|
|
|
// Default to --status so a bare invocation is always safe.
|
|
if (!this.ingest && !this.reset && !this.check) this.status = true
|
|
|
|
if (this.check) {
|
|
return this.runCheck(service)
|
|
}
|
|
|
|
if (this.reset) {
|
|
const removed = await service.reset()
|
|
this.logger.success(`Removed ${removed} eval corpus chunks`)
|
|
return
|
|
}
|
|
|
|
if (this.ingest) {
|
|
return this.runIngest(service)
|
|
}
|
|
|
|
const [fingerprint, chunks, goldens] = await Promise.all([
|
|
service.fingerprint(),
|
|
service.count(),
|
|
service.loadGoldens(['*']),
|
|
])
|
|
this.logger.info(`Fingerprint: ${fingerprint}`)
|
|
this.logger.info(`Chunks in KB: ${chunks}`)
|
|
this.logger.info(`Goldens: ${goldens.length}`)
|
|
if (chunks === 0) {
|
|
this.logger.warning('Corpus is not ingested. Run: node ace eval:corpus --ingest')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validation-only path. Deliberately avoids the container-resolved services'
|
|
* network dependencies so a contributor can check their golden edits without
|
|
* a running stack.
|
|
*/
|
|
private async runCheck(service: any) {
|
|
try {
|
|
const corpus = await service.loadCorpus()
|
|
const goldens = await service.loadGoldens(['*'])
|
|
const fingerprint = await service.fingerprint()
|
|
|
|
const tagCounts = new Map<string, number>()
|
|
for (const g of goldens) {
|
|
for (const tag of g.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
|
|
}
|
|
|
|
// A document no golden asks about is dead weight in the corpus: it costs
|
|
// ingest time and adds distractor noise nobody chose deliberately.
|
|
const referenced = new Set<string>(goldens.flatMap((g: any) => g.relevantDocIds))
|
|
const unreferenced = corpus
|
|
.map((d: any) => d.docId)
|
|
.filter((id: string) => !referenced.has(id))
|
|
|
|
this.logger.success(`Corpus OK: ${corpus.length} documents, ${goldens.length} goldens`)
|
|
this.logger.info(`Fingerprint: ${fingerprint}`)
|
|
this.logger.info(
|
|
`Tags: ${[...tagCounts.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([t, n]) => `${t}=${n}`)
|
|
.join(' ')}`
|
|
)
|
|
const refusals = goldens.filter((g: any) => g.expectRefusal).length
|
|
this.logger.info(`Refusal cases: ${refusals} / ${goldens.length}`)
|
|
if (unreferenced.length > 0) {
|
|
this.logger.warning(`Documents no golden references: ${unreferenced.join(', ')}`)
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(error instanceof Error ? error.message : String(error))
|
|
this.exitCode = 1
|
|
}
|
|
}
|
|
|
|
private async runIngest(service: any) {
|
|
this.logger.info('Rebuilding the eval corpus (user content is never touched)...')
|
|
// Validate before destroying anything — a typo in a golden should not cost
|
|
// you the ingest you were about to run.
|
|
await service.loadGoldens(['*'])
|
|
|
|
const summary = await service.ingest((docId: string, index: number, total: number) => {
|
|
this.logger.info(` [${index}/${total}] ${docId}`)
|
|
})
|
|
|
|
this.logger.info('')
|
|
if (summary.removedBeforeIngest > 0) {
|
|
this.logger.info(`Removed ${summary.removedBeforeIngest} chunks from the previous ingest`)
|
|
}
|
|
this.logger.success(`Ingested ${summary.documents} documents into ${summary.chunks} chunks`)
|
|
this.logger.info(`Fingerprint: ${summary.fingerprint}`)
|
|
|
|
if (summary.failures.length > 0) {
|
|
this.logger.error(`${summary.failures.length} document(s) failed to ingest:`)
|
|
for (const f of summary.failures) this.logger.error(` ${f.docId}: ${f.reason}`)
|
|
// A partial corpus produces scores that look real and are not.
|
|
this.logger.error('The corpus is incomplete — results from this state are not comparable.')
|
|
this.exitCode = 1
|
|
}
|
|
}
|
|
}
|