From 251362e018463d4938bdfd0edf24bb14500e0800 Mon Sep 17 00:00:00 2001
From: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:42:28 -0700
Subject: [PATCH] feat(AI): make RAG retreival optional per chat (#1247)
---
.gitignore | 6 +
admin/app/controllers/ollama_controller.ts | 5 +
admin/app/controllers/settings_controller.ts | 2 +
admin/app/services/rag_pipeline_service.ts | 6 +
admin/constants/kv_store.ts | 1 +
admin/inertia/components/chat/index.tsx | 42 ++++++
admin/inertia/pages/settings/models.tsx | 21 ++-
admin/tests/unit/rag_retrieval_toggle.spec.ts | 125 ++++++++++++++++++
admin/types/kv_store.ts | 5 +
admin/types/rag.ts | 5 +
10 files changed, 215 insertions(+), 3 deletions(-)
create mode 100644 admin/tests/unit/rag_retrieval_toggle.spec.ts
diff --git a/.gitignore b/.gitignore
index 4273fe34..6b54ef93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,12 @@ server/temp
.vscode
.idea
+# Agent Files
+.agents
+.claude
+.codegraph
+.mcp.json
+
# Frontend assets compiled code
admin/public/assets
diff --git a/admin/app/controllers/ollama_controller.ts b/admin/app/controllers/ollama_controller.ts
index 30c4e409..51019492 100644
--- a/admin/app/controllers/ollama_controller.ts
+++ b/admin/app/controllers/ollama_controller.ts
@@ -63,9 +63,14 @@ export default class OllamaController {
// Everything from system-prompt assembly through query rewriting,
// retrieval, context trimming and the num_ctx decision lives in
// RagPipelineService so the eval harness exercises this exact code path.
+ // Knowledge base retrieval is user-toggleable (chat header + AI Assistant
+ // settings, both writing rag.enabled). Unset means on, preserving the
+ // behaviour from before the toggle existed.
+ const ragEnabled = (await KVStore.getValue('rag.enabled')) ?? true
const collectionFilter: string | null = request.input('collection', null)
const trace = await this.ragPipelineService.buildPrompt(reqData.messages, reqData.model, {
collection: collectionFilter ?? undefined,
+ skipRetrieval: !ragEnabled,
})
reqData.messages = trace.messages
const numCtx = trace.numCtx
diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts
index f777943c..5b1b39fc 100644
--- a/admin/app/controllers/settings_controller.ts
+++ b/admin/app/controllers/settings_controller.ts
@@ -72,6 +72,7 @@ export default class SettingsController {
const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention')
const autoThinking = await KVStore.getValue('ai.autoThinking')
const tasksModel = await KVStore.getValue('ai.tasksModel')
+ const ragEnabled = await KVStore.getValue('rag.enabled')
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
@@ -83,6 +84,7 @@ export default class SettingsController {
ollamaFlashAttention: ollamaFlashAttention ?? true,
autoThinking: autoThinking ?? false,
tasksModel: tasksModel ?? '',
+ ragEnabled: ragEnabled ?? true,
},
},
})
diff --git a/admin/app/services/rag_pipeline_service.ts b/admin/app/services/rag_pipeline_service.ts
index 464794bc..228288e7 100644
--- a/admin/app/services/rag_pipeline_service.ts
+++ b/admin/app/services/rag_pipeline_service.ts
@@ -88,6 +88,12 @@ export class RagPipelineService {
if (opts.oracleContext) {
relevantDocs = opts.oracleContext
trace.retrieved = relevantDocs
+ } else if (opts.skipRetrieval) {
+ // Retrieval turned off by the user (rag.enabled). Bail before the
+ // hasDocuments check, the rewrite LLM call and the vector search — the
+ // whole point is to spend nothing here. relevantDocs stays empty, so no
+ // context block is injected below.
+ logger.debug('[RagPipeline] Retrieval disabled by setting, skipping')
} else {
const rewriteStart = Date.now()
const { query, didRewrite } = await this.resolveRetrievalQuery(working, model, opts)
diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts
index eb3e718c..09d99d92 100644
--- a/admin/constants/kv_store.ts
+++ b/admin/constants/kv_store.ts
@@ -13,6 +13,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
'ai.autoThinking',
'ai.tasksModel',
'rag.defaultIngestPolicy',
+ 'rag.enabled',
'autoUpdate.enabled',
'autoUpdate.windowStart',
'autoUpdate.windowEnd',
diff --git a/admin/inertia/components/chat/index.tsx b/admin/inertia/components/chat/index.tsx
index 8abf6070..91f864d7 100644
--- a/admin/inertia/components/chat/index.tsx
+++ b/admin/inertia/components/chat/index.tsx
@@ -76,6 +76,33 @@ export default function Chat({
const autoThinkingDefault =
autoThinkingSetting?.value === true || autoThinkingSetting?.value === 'true'
+ // Knowledge base retrieval, shared with AI Assistant settings (same KV key).
+ // Unset means on, so coerce off the negative — an absent value must not read
+ // as false.
+ const { data: ragEnabledSetting } = useSystemSetting({ key: 'rag.enabled', enabled })
+ const ragEnabled = !(ragEnabledSetting?.value === false || ragEnabledSetting?.value === 'false')
+
+ const ragEnabledMutation = useMutation({
+ mutationFn: async (value: boolean) => await api.updateSetting('rag.enabled', value),
+ // Flip the switch immediately rather than after the round-trip, and roll
+ // back if the write fails.
+ onMutate: async (value: boolean) => {
+ await queryClient.cancelQueries({ queryKey: ['system-setting', 'rag.enabled'] })
+ const previous = queryClient.getQueryData(['system-setting', 'rag.enabled'])
+ queryClient.setQueryData(['system-setting', 'rag.enabled'], {
+ key: 'rag.enabled',
+ value,
+ })
+ return { previous }
+ },
+ onError: (_err, _value, context) => {
+ queryClient.setQueryData(['system-setting', 'rag.enabled'], context?.previous)
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['system-setting', 'rag.enabled'] })
+ },
+ })
+
const { data: remoteStatus } = useQuery({
queryKey: ['remoteOllamaStatus'],
queryFn: () => api.getRemoteOllamaStatus(),
@@ -558,6 +585,7 @@ export default function Chat({
{remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'}
)}
+ {ragEnabled && (
+ )}
+
+ Knowledge Base:
+
+ ragEnabledMutation.mutate(v)}
+ />
+
{selectedModelSupportsThinking && (
Thinking:
diff --git a/admin/inertia/pages/settings/models.tsx b/admin/inertia/pages/settings/models.tsx
index a9de243e..462958ac 100644
--- a/admin/inertia/pages/settings/models.tsx
+++ b/admin/inertia/pages/settings/models.tsx
@@ -15,7 +15,7 @@ import { SERVICE_NAMES } from '../../../constants/service_names'
import Switch from '~/components/inputs/Switch'
import Select from '~/components/inputs/Select'
import StyledSectionHeader from '~/components/StyledSectionHeader'
-import { useMutation, useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Input from '~/components/inputs/Input'
import { IconSearch, IconRefresh } from '@tabler/icons-react'
import { formatBytes } from '~/lib/util'
@@ -27,7 +27,7 @@ export default function ModelsPage(props: {
models: {
availableModels: NomadOllamaModel[]
installedModels: NomadInstalledModel[]
- settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean; autoThinking: boolean; tasksModel: string }
+ settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean; autoThinking: boolean; tasksModel: string; ragEnabled: boolean }
}
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
@@ -36,6 +36,7 @@ export default function ModelsPage(props: {
const { openModal, closeAllModals } = useModals()
const { debounce } = useDebounce()
const { data: systemInfo } = useSystemInfo({})
+ const queryClient = useQueryClient()
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
try {
@@ -100,6 +101,7 @@ export default function ModelsPage(props: {
props.models.settings.ollamaFlashAttention
)
const [autoThinking, setAutoThinking] = useState(props.models.settings.autoThinking)
+ const [ragEnabled, setRagEnabled] = useState(props.models.settings.ragEnabled)
const [tasksModel, setTasksModel] = useState(props.models.settings.tasksModel)
const [aiAssistantCustomName, setAiAssistantCustomName] = useState(
props.models.settings.aiAssistantCustomName
@@ -258,7 +260,11 @@ export default function ModelsPage(props: {
mutationFn: async ({ key, value }: { key: string; value: boolean | string }) => {
return await api.updateSetting(key, value)
},
- onSuccess: () => {
+ onSuccess: (_data, { key }) => {
+ // Anything reading this key through useSystemSetting (e.g. the chat
+ // window's own copy of the retrieval toggle) should pick the change up
+ // without a reload.
+ queryClient.invalidateQueries({ queryKey: ['system-setting', key] })
addNotification({
message: 'Setting updated successfully.',
type: 'success',
@@ -343,6 +349,15 @@ export default function ModelsPage(props: {
label="Use thinking automatically when a model supports it"
description="Sets the default for models that can think. You can still turn thinking on or off for an individual model in the chat window."
/>
+ {
+ setRagEnabled(newVal)
+ updateSettingMutation.mutate({ key: 'rag.enabled', value: newVal })
+ }}
+ label="Knowledge Base Retrieval"
+ description="Search your knowledge base for relevant documents before answering. Turn this off to save memory and speed up replies when your knowledge base is small or empty. This is the same switch as the one in the chat window."
+ />
+ 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)
+ })
+})
diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts
index 8feea49b..f2da8381 100644
--- a/admin/types/kv_store.ts
+++ b/admin/types/kv_store.ts
@@ -4,6 +4,11 @@ export const KV_STORE_SCHEMA = {
'chat.lastModel': 'string',
'rag.docsEmbedded': 'boolean',
'rag.defaultIngestPolicy': 'string',
+ // Master switch for chat-time knowledge base retrieval. Unset/null means ON —
+ // the pre-existing behaviour. Turning it off skips the whole retrieval
+ // pipeline (hasDocuments, the query-rewrite LLM call, and the Qdrant search),
+ // which matters on small hardware and when the KB is small or empty.
+ 'rag.enabled': 'boolean',
'system.updateAvailable': 'boolean',
'system.latestVersion': 'string',
'system.earlyAccess': 'boolean',
diff --git a/admin/types/rag.ts b/admin/types/rag.ts
index 383a93a1..77452f86 100644
--- a/admin/types/rag.ts
+++ b/admin/types/rag.ts
@@ -89,6 +89,11 @@ export type PipelineOptions = {
/** 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
}
/**