project-nomad/admin/tests/unit/rag_retrieval_toggle.spec.ts
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

314 lines
12 KiB
TypeScript

/**
* The `rag.enabled` off-switch, at the level that actually matters: what
* buildPrompt does and does not call.
*
* Turning retrieval off is a resource decision, so "no context injected" is
* only half the contract — the point is that none of the three expensive steps
* (hasDocuments, the query-rewrite LLM call, the Qdrant search) run at all.
* These tests count calls, not just output.
*
* Japa suite (not the plain-node `test:unit` runner): RagPipelineService pulls
* in the AdonisJS container, so it needs the ignited app that `node ace test`
* provides. Run with:
* node ace test --suites=unit --files=rag_retrieval_toggle
*
* Deliberately NOT named rag_pipeline_*: that glob is what `npm run test:eval`
* feeds to the plain-node runner, which cannot boot Adonis.
*/
import { test } from '@japa/runner'
import { RagPipelineService } from '#services/rag_pipeline_service'
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
import type { OllamaChatMessage } from '../../types/ollama.js'
import type { RetrievedChunk } from '../../types/rag.js'
/** Records every call so the tests can assert on what was *not* run. */
function makeFakes(
opts: {
nomadMd?: string | null
searchResults?: RetrievedChunk[]
/** What the `rag.relevanceCheck` setting resolves to. Null (off) by default. */
checkModel?: string | null
/** The check's verdict when it runs. */
onTopic?: boolean
} = {}
) {
const calls = { hasDocuments: 0, search: 0, chat: 0, judge: 0 }
const judged = { question: '', model: '' }
const args = { minFinalScore: undefined as number | undefined }
const ragService = {
async hasDocuments() {
calls.hasDocuments++
return true
},
async searchSimilarDocuments(
_query: string,
_limit: number,
_scoreThreshold: number,
_collection: string | undefined,
_stagesOut: unknown,
minFinalScore: number,
floorOut?: { candidates: number; belowFloor: number }
) {
calls.search++
args.minFinalScore = minFinalScore
const results =
opts.searchResults ?? [{ text: 'retrieved body', score: 0.9, metadata: { full_title: 'A Doc' } }]
// Stand in for the real floor: whatever the fake was told to return is
// what survived, and anything it was told to withhold is what did not.
if (floorOut) {
floorOut.candidates = 3
floorOut.belowFloor = 3 - results.length
}
return results
},
}
const ollamaService = {
async chat() {
calls.chat++
return { message: { content: 'rewritten query' } }
},
async getModelInfo() {
return { hasThinking: false, parameterSize: '8.0B' }
},
async getModels() {
return []
},
async checkModelHasThinking() {
return false
},
}
const nomadMdService = {
async getSystemPrompt() {
return opts.nomadMd ?? null
},
}
// A fixed window keeps these tests about the toggle rather than about
// hardware detection; the budget planner is covered by context_budget.spec.ts.
const contextWindowService = {
async windowFor() {
return 8192
},
}
const tokenCalibration = {
async ratioFor() {
return 1
},
}
// Off unless a test turns it on, matching the setting's default.
const relevanceJudge = {
async resolveModel() {
return opts.checkModel ?? null
},
async judge(question: string, chunks: RetrievedChunk[], model: string) {
calls.judge++
judged.question = question
judged.model = model
const onTopic = opts.onTopic ?? true
return {
kept: onTopic ? chunks : [],
rejected: onTopic ? 0 : chunks.length,
judged: true,
ms: 7,
}
},
}
const service = new RagPipelineService(
ollamaService as any,
ragService as any,
nomadMdService as any,
contextWindowService as any,
tokenCalibration as any,
relevanceJudge as any
)
return { service, calls, args, judged }
}
const userTurn: OllamaChatMessage[] = [{ role: 'user', content: 'how do I purify water?' }]
const systemContents = (messages: OllamaChatMessage[]) =>
messages.filter((m) => m.role === 'system').map((m) => m.content)
test.group('buildPrompt | skipRetrieval', () => {
test('skips every expensive step when retrieval is disabled', async ({ assert }) => {
const { service, calls } = makeFakes()
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', { skipRetrieval: true })
// The whole point of the toggle: nothing reaches Qdrant or the LLM.
assert.equal(calls.hasDocuments, 0)
assert.equal(calls.search, 0)
assert.equal(calls.chat, 0)
// ...and no knowledge base context lands in the prompt.
assert.deepEqual(trace.retrieved, [])
assert.deepEqual(trace.injected, [])
assert.isNull(trace.rewrittenQuery)
assert.isFalse(trace.didRewrite)
assert.isFalse(systemContents(trace.messages).some((c) => c.includes('[Context 1')))
})
test('still injects the default system prompt and NOMAD.md when disabled', async ({ assert }) => {
// Neither of these is RAG; turning retrieval off must not silently strip
// the user's persistent instructions or the formatting prompt.
const { service } = makeFakes({ nomadMd: 'Always answer in metric units.' })
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', { skipRetrieval: true })
const systems = systemContents(trace.messages)
assert.include(systems, SYSTEM_PROMPTS.default)
assert.include(systems, 'Always answer in metric units.')
})
test('retrieves as normal when the option is omitted', async ({ assert }) => {
// The eval harness never sets skipRetrieval. If this ever fails, every eval
// run is silently scoring a no-retrieval pipeline.
const { service, calls } = makeFakes()
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', {})
assert.equal(calls.hasDocuments, 1)
assert.equal(calls.search, 1)
assert.lengthOf(trace.retrieved, 1)
assert.lengthOf(trace.injected, 1)
assert.isTrue(systemContents(trace.messages).some((c) => c.includes('[Context 1')))
})
test('oracleContext still wins over skipRetrieval', async ({ assert }) => {
// Both are bypasses; oracle mode supplies its own context and is checked
// first, so an eval running in oracle mode is unaffected by the setting.
const { service, calls } = makeFakes()
const oracle = [{ text: 'oracle body', score: 1, metadata: {} }]
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', {
skipRetrieval: true,
oracleContext: oracle,
})
assert.equal(calls.search, 0)
assert.deepEqual(trace.retrieved, oracle)
assert.lengthOf(trace.injected, 1)
})
})
test.group('buildPrompt | relevance floor', () => {
test('injects no context block when nothing clears the floor', async ({ assert }) => {
// The point of the floor. Retrieval ran, found candidates, and declined them
// all — so the model must be handed nothing rather than the best of a bad
// set, which is what the rag_context prompt used to have to talk it out of.
const { service, calls } = makeFakes({ searchResults: [] })
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', { minFinalScore: 0.62 })
assert.equal(calls.search, 1)
assert.deepEqual(trace.retrieved, [])
assert.deepEqual(trace.injected, [])
assert.isFalse(systemContents(trace.messages).some((c) => c.includes('[Context 1')))
// ...and the default system prompt is untouched: declining to retrieve is
// not the same as turning the assistant off.
assert.include(systemContents(trace.messages), SYSTEM_PROMPTS.default)
})
test('records the floor and the drop count on the trace', async ({ assert }) => {
// "The knowledge base had no match at all" and "everything found was judged
// irrelevant" are different things to tell a user, so the trace has to keep
// them apart rather than both collapsing to an empty retrieved list.
const { service } = makeFakes({ searchResults: [] })
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', { minFinalScore: 0.62 })
assert.equal(trace.minFinalScore, 0.62)
assert.equal(trace.chunksBelowFloor, 3)
})
test('an explicit option overrides the rag.minRelevance setting', async ({ assert }) => {
// What keeps the eval harness reproducible: it always passes a value, so a
// slider set on one machine cannot move the numbers a baseline was recorded
// against.
const { service, args } = makeFakes()
await service.buildPrompt(userTurn, 'llama3.1:8b', { minFinalScore: 0.9 })
assert.equal(args.minFinalScore, 0.9)
})
})
test.group('buildPrompt | relevance check', () => {
test('does not run when the setting is off', async ({ assert }) => {
// Off is the default: the check is a model call per turn and only pays off
// on ~8B models, so it must cost nothing until someone opts in.
const { service, calls } = makeFakes()
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', {})
assert.equal(calls.judge, 0)
assert.isNull(trace.relevanceCheckModel)
assert.lengthOf(trace.injected, 1)
})
test('an off-topic verdict leaves nothing to inject or cite', async ({ assert }) => {
// #1341: the floor kept a chunk, the check says it is not about the
// question, so the prompt gets no context block and the answer no sources.
const { service, calls } = makeFakes({ checkModel: 'llama3:8b', onTopic: false })
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', {})
assert.equal(calls.judge, 1)
assert.deepEqual(trace.retrieved, [])
assert.deepEqual(trace.injected, [])
assert.equal(trace.chunksRejectedByCheck, 1)
assert.equal(trace.relevanceCheckModel, 'llama3:8b')
assert.isFalse(systemContents(trace.messages).some((c) => c.includes('[Context 1')))
})
test('an on-topic verdict keeps what the floor kept', async ({ assert }) => {
const { service } = makeFakes({ checkModel: 'llama3:8b', onTopic: true })
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', {})
assert.lengthOf(trace.injected, 1)
assert.equal(trace.chunksRejectedByCheck, 0)
assert.equal(trace.timings.relevanceCheckMs, 7)
})
test('judges the retrieval query, not the raw message', async ({ assert }) => {
// On a follow-up the rewrite is the standalone question; judging "what about
// the second one?" against the passages would reject everything.
const { service, judged } = makeFakes({ checkModel: 'llama3:8b' })
const conversation: OllamaChatMessage[] = [
{ role: 'user', content: 'how do I purify water?' },
{ role: 'assistant', content: 'Boil it or use chlorine.' },
{ role: 'user', content: 'how long for the first one?' },
]
await service.buildPrompt(conversation, 'llama3.1:8b', {})
assert.equal(judged.question, 'rewritten query')
})
test('an explicit null skips it whatever the setting says', async ({ assert }) => {
// The eval harness pins it off so one machine's setting cannot move scores.
const { service, calls } = makeFakes({ checkModel: 'llama3:8b', onTopic: false })
const trace = await service.buildPrompt(userTurn, 'llama3.1:8b', { relevanceCheckModel: null })
assert.equal(calls.judge, 0)
assert.lengthOf(trace.injected, 1)
})
test('skips the call when the floor already left nothing', async ({ assert }) => {
const { service, calls } = makeFakes({ checkModel: 'llama3:8b', searchResults: [] })
await service.buildPrompt(userTurn, 'llama3.1:8b', { minFinalScore: 0.62 })
assert.equal(calls.judge, 0)
})
})