mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-25 07:03:12 +08:00
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
216 lines
8.4 KiB
TypeScript
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[]>
|
|
} |