diff --git a/.gitignore b/.gitignore
index 4273fe3..6b54ef9 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 30c4e40..5101949 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 f777943..5b1b39f 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 464794b..228288e 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 eb3e718..09d99d9 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 8abf607..91f864d 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 a9de243..462958a 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 8feea49..f2da838 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 383a93a..77452f8 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
}
/**