Files
jakeaturner a915cdcfc9 feat(AI): add opt-in relevance check for off-topic retrieval
The post-rerank relevance floor rejects noise but passes coherent
off-topic questions, and it gets worse as the library grows. Adding
Gutenberg prose, the Python tutorial and MDN pages to the eval corpus
took the off-topic leak rate from 22% to 67% at the 0.62 floor.

Score cutoff does not fix it. The weakest correct top hit scores 0.666
while "write me a haiku about autumn leaves" pulls Walden at 0.705.
A cosine floor and a top-vs-median contrast test both cost recall
faster than they cut leaks.

- Add rag.relevanceCheck (default off). After the floor, the tasks
  model, or the chat model when none is set, gives one grammar-constrained
  on_topic verdict for the whole retrieved set. Off topic means no
  context and no citations. Any failure keeps the chunks unjudged.
- Add a Settings > Models toggle recommending an 8B+ tasks model.
  On llama3:8b the check cuts off-topic leaks from 67% to 6% for about
  5 points of core recall@5 and ~0.6s per turn. On 3B models it rejects
  most valid questions, which is why it is opt-in.
- Add 6 distractor documents and an off_topic suite (18 unanswerable
  questions, 7 distractor controls) to the eval corpus.
- Report per-tag leak rates and leaked cases with top scores in
  eval:retrieval. Add --judge-model to measure the check and --dump to
  export pre-floor candidate pools for offline gate simulation.
- Pin the check off in eval:generation so a local setting cannot move
  scores.
- Record the distractor sweep on RAG_MIN_FINAL_SCORE. The 0.62 default
  stays: 0.65 halves leaks at no measured recall cost but leaves only
  0.016 of margin.

The corpus fingerprint changes to b4ebe5dce8699f5d. Retrieval reports
from before this commit are not comparable, and a new baseline is
committed.

Refs #1341
2026-09-29 15:54:41 -07:00

216 lines
8.4 KiB
TypeScript

import type { OllamaChatMessage } from './ollama.js'
import type { BudgetTrace, RagPlacement } from '../app/utils/context_budget.js'
export type EmbedJobWithProgress = {
jobId: string
fileName: string
filePath: string
progress: number
status: string
error?: string
/** ms epoch of last completed batch; multi-batch ZIMs update this each batch. */
lastBatchAt?: number
/** ms epoch of first batch start; used as a fallback when lastBatchAt unset. */
startedAt?: number
/** Total chunks embedded across this job's batches so far. */
chunks?: number
}
export type ProcessAndEmbedFileResponse = {
success: boolean
message: string
chunks?: number
hasMoreBatches?: boolean
articlesProcessed?: number
totalArticles?: number
}
export type ProcessZIMFileResponse = ProcessAndEmbedFileResponse
export type RAGResult = {
text: string
score: number
keywords: string
chunk_index: number
created_at: number
article_title?: string
section_title?: string
full_title?: string
hierarchy?: string
document_id?: string
content_type?: string
source?: string
archive_title?: string
archive_date?: string
}
export type RerankedRAGResult = Omit<RAGResult, 'keywords'> & {
finalScore: number
}
/** One entry in a recorded retrieval stage: just enough to score a ranking. */
export type StageEntry = { source?: string; score: number; semanticScore?: number }
/**
* The three ranked lists retrieval produces internally, captured so the eval
* harness can score each stage separately and show whether the heuristic
* reranker and the source-diversity penalty are earning their complexity.
*
* `dense` is the raw cosine ordering from Qdrant, `reranked` adds the
* keyword/heading boosts, `diversified` adds the same-document penalty.
*/
export type RetrievalStages = {
dense?: StageEntry[]
reranked?: StageEntry[]
diversified?: StageEntry[]
/**
* The whole reranked candidate pool, unsliced and before the relevance floor,
* with both the boosted and the raw cosine score. What `eval:retrieval --dump`
* writes out, so a candidate gate can be simulated offline against every
* golden instead of costing a full sweep per setting.
*/
candidates?: StageEntry[]
}
/**
* Counts from the relevance floor, for callers that want to say *why* nothing
* came back. Written whether or not stage ablation is on — it is two integers,
* and "we searched and found nothing relevant enough" is worth being able to
* say out loud.
*/
export type RetrievalFloorStats = {
/** Candidates the floor was applied to (post-rerank, pre-diversity). */
candidates: number
/** How many of those fell below it. */
belowFloor: number
}
/**
* A chunk as returned by `RagService.searchSimilarDocuments` — the shape the
* chat pipeline consumes and the eval harness scores.
*/
export type RetrievedChunk = {
text: string
score: number
metadata?: Record<string, any>
}
/**
* Knobs on a single pipeline run. Everything is optional: the defaults
* reproduce production chat exactly. The non-default paths exist so the eval
* harness can ablate one stage at a time without a parallel implementation.
*/
export type PipelineOptions = {
topK?: number
scoreThreshold?: number
collection?: string
/** Skip the history-aware rewrite (which is an LLM call, and therefore
* non-deterministic). Retrieval then runs on the raw last user message. */
skipQueryRewrite?: boolean
/** Bypass retrieval entirely and inject these chunks as the context. Used by
* the `oracle` eval mode to isolate generation quality from retrieval. */
oracleContext?: RetrievedChunk[]
/** Ignore the user's NOMAD.md. Off in production; on in evals, where a
* developer's personal instructions would silently skew every result. */
skipNomadMd?: boolean
/** Skip the entire retrieval pipeline — the hasDocuments check, the
* query-rewrite LLM call and the Qdrant search — leaving the prompt with
* system prompts only. Set from the `rag.enabled` KV setting. Opt-out by
* design: the eval harness omits it and therefore always retrieves. */
skipRetrieval?: boolean
/** Override where the retrieved-context block sits. Defaults to RAG_PLACEMENT;
* the eval harness sets it explicitly to compare the two orderings. */
ragPlacement?: RagPlacement
/** Post-rerank relevance floor. Unset resolves the user's `rag.minRelevance`
* setting; the eval harness passes an explicit value so its numbers cannot
* depend on how one machine's slider happens to be set. */
minFinalScore?: number
/** Model for the relevance check, or null to skip it. Unset resolves the
* user's `rag.relevanceCheck` setting; the eval harness passes an explicit
* value for the same reason it pins `minFinalScore`. */
relevanceCheckModel?: string | null
}
/**
* Everything the pipeline decided on the way to a prompt. The controller uses
* only `messages` and `numCtx`; the eval harness scores the rest. Returning it
* unconditionally keeps one code path for both.
*/
export type PipelineTrace = {
/** null when retrieval was skipped entirely (empty KB, or no user message). */
rewrittenQuery: string | null
/** True when the rewrite LLM call actually ran (it is skipped on turn 1). */
didRewrite: boolean
/** Everything retrieval returned, pre-trim. */
retrieved: RetrievedChunk[]
/** What actually made it into the prompt, post model-size trim. */
injected: RetrievedChunk[]
/** The exact payload handed to Ollama. */
messages: OllamaChatMessage[]
numCtx: number | undefined
/** Generation cap, so the answer cannot run past the end of the window. */
numPredict: number | undefined
contextLimits: { maxResults: number; maxTokens: number }
timings: { rewriteMs: number; retrievalMs: number; relevanceCheckMs: number }
/**
* The relevance floor this turn was retrieved under, and how many candidates
* fell below it. `chunksBelowFloor > 0` with `retrieved.length === 0` is the
* "we searched and nothing was relevant enough" case — which is the honest
* thing to tell the user, and the data the retrieval-status UX needs to say it.
*/
minFinalScore: number
chunksBelowFloor: number
/** The model that ran the relevance check this turn, or null when it did not run. */
relevanceCheckModel: string | null
/** Chunks the check dropped after the floor had kept them. */
chunksRejectedByCheck: number
/**
* What the budget planner decided: how the window was spent and what was left
* out. Undefined only when planning was bypassed. The eval harness reads this
* to tell "the model answered badly" apart from "the model never saw it".
*/
budget?: BudgetTrace
/** Uncalibrated prompt estimate, fed to TokenCalibrationService after the call. */
uncalibratedPromptTokens?: number
}
export type FileWarning =
| { kind: 'zero_chunks'; fileSizeBytes: number }
| { kind: 'partial_stall'; chunksEmbedded: number; chunksExpected: number }
/**
* Row returned by `GET /api/rag/files`. `state` is null for sources that exist
* in Qdrant but have no `kb_ingest_state` row (pre-RFC-883 installs where the
* scanner hasn't yet backfilled). `chunksEmbedded` mirrors the state-machine
* field; 0 for state-row-less or zero-chunk files.
*/
export type StoredFileInfo = {
source: string
state: import('./kb_ingest_state.js').KbIngestStateValue | null
chunksEmbedded: number
/** Filename portion of `source` (last path segment). */
fileName: string
/** File size in bytes from disk; null if the file is missing or unreadable. */
size: number | null
/** Last-modified timestamp from disk (ISO 8601); null if unavailable. */
uploadedAt: string | null
/** True when `source` lives under the user-uploads directory. Drives which
* rows offer view/download in the UI. */
isUserUpload: boolean
/** Subject/category tag, or null if uncategorized. */
collection: string | null
/** Whether this file's chunks are included in RAG search results. Toggling
* this never deletes or re-embeds vectors — see #1119. */
active: boolean
}
/**
* Result of computing per-file warnings. `ok: false` means the computation
* itself failed (Qdrant unreachable, DB outage, FS read error) — distinct from
* `ok: true` with an empty map, which means every scanned file is healthy.
* The frontend should surface a neutral "warnings unavailable" indicator on
* `!ok` rather than implying everything is fine.
*/
export type FileWarningsResult = {
ok: boolean
warnings: Record<string, FileWarning[]>
}