diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index a2e428fe..085a8a48 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -11,7 +11,7 @@ import env from '#start/env' import { parseMinRelevance } from '../utils/misc.js' import { isRelevanceCheckEnabled } from '../utils/relevance_judge.js' import { parseResponseStyle } from '../utils/sampler.js' -import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js' +import { resolveDefaultMinFinalScore } from '../utils/rag_relevance.js' @inject() export default class SettingsController { @@ -111,7 +111,7 @@ export default class SettingsController { contextWindow: contextWindow ?? 'auto', // Sent as the resolved number so the select can match an option // without duplicating the "unset means the default" rule in the UI. - minRelevance: parseMinRelevance(minRelevance, RAG_MIN_FINAL_SCORE), + minRelevance: parseMinRelevance(minRelevance, await resolveDefaultMinFinalScore()), relevanceCheck: isRelevanceCheckEnabled(relevanceCheck), // Resolved rather than raw, for the same reason: unset means 'auto', // and the select shouldn't have to know that. diff --git a/admin/app/jobs/embed_file_job.ts b/admin/app/jobs/embed_file_job.ts index b1ae07ba..5d2eceaa 100644 --- a/admin/app/jobs/embed_file_job.ts +++ b/admin/app/jobs/embed_file_job.ts @@ -98,6 +98,15 @@ export class EmbedFileJob { throw new UnrecoverableError('Qdrant service is not installed. Install AI Assistant to enable file embeddings.') } + // The embedding model was changed without a Reset & Rebuild. Every upsert + // would be rejected, so fail once here instead of 30 retries of extraction + // and embedding that can never be stored. + if (!(await ragService.embeddingModelMatchesCollection())) { + throw new UnrecoverableError( + 'The knowledge base was built with a different embedding model. Run Reset & Rebuild to re-embed it.' + ) + } + logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`) // Anchor initial progress to where we are in the overall file. For a diff --git a/admin/app/services/eval_corpus_service.ts b/admin/app/services/eval_corpus_service.ts index cf2b5c5d..6228c0c9 100644 --- a/admin/app/services/eval_corpus_service.ts +++ b/admin/app/services/eval_corpus_service.ts @@ -5,7 +5,8 @@ import { createHash } from 'node:crypto' import { readdir, readFile } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js' -import { EMBEDDING_MODEL_NAME } from '../../constants/ollama.js' +import KVStore from '#models/kv_store' +import { pickEmbeddingModel } from '../utils/misc.js' import { assertGoldensMatchCorpus, computeCorpusFingerprint, @@ -135,14 +136,15 @@ export class EvalCorpusService { */ async fingerprint(): Promise { const corpus = await this.loadCorpus() + const embedding = pickEmbeddingModel(await KVStore.getValue('rag.embeddingModel')) return computeCorpusFingerprint( { documents: new Map(corpus.map((d) => [d.docId, d.text])), chunkTokens: RagService.TARGET_TOKENS_PER_CHUNK, chunkOverlapTokens: RagService.CHUNK_OVERLAP_TOKENS, charToTokenRatio: RagService.CHAR_TO_TOKEN_RATIO, - embeddingModel: EMBEDDING_MODEL_NAME, - embeddingDimension: RagService.EMBEDDING_DIMENSION, + embeddingModel: embedding.name, + embeddingDimension: embedding.dimension, }, (input) => createHash('sha256').update(input).digest('hex') ) diff --git a/admin/app/services/eval_generation_service.ts b/admin/app/services/eval_generation_service.ts index 4c64b5aa..983eee8d 100644 --- a/admin/app/services/eval_generation_service.ts +++ b/admin/app/services/eval_generation_service.ts @@ -4,7 +4,7 @@ import { RagPipelineService } from '#services/rag_pipeline_service' import { inject } from '@adonisjs/core' import logger from '@adonisjs/core/services/logger' import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js' -import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js' +import { resolveDefaultMinFinalScore } from '../utils/rag_relevance.js' import { resolveSamplerProfile } from '../utils/sampler.js' import type { OllamaChatMessage, ResponseStyle, SamplerProfile } from '../../types/ollama.js' import type { PipelineOptions, RetrievedChunk } from '../../types/rag.js' @@ -224,7 +224,7 @@ export class EvalGenerationService { samplerStyle: options.samplerStyle ?? null, topK: options.topK, scoreThreshold: options.scoreThreshold, - minFinalScore: options.minFinalScore ?? RAG_MIN_FINAL_SCORE, + minFinalScore: options.minFinalScore ?? (await resolveDefaultMinFinalScore()), }, overall: aggregateGeneration(cases), byTag: aggregateGenerationByTag(cases), @@ -284,11 +284,11 @@ export class EvalGenerationService { collection: KB_EVAL_COLLECTION, topK: options.topK, scoreThreshold: options.scoreThreshold, - // Pinned to the constant, not left to fall through to resolveMinFinalScore() + // Pinned to the model's default, not left to fall through to resolveMinFinalScore() // — otherwise a `rag.minRelevance` slider set on one developer's machine // would silently move every generation score. Same reasoning as the // retrieval tier; see EvalRetrievalService.run. - minFinalScore: options.minFinalScore ?? RAG_MIN_FINAL_SCORE, + minFinalScore: options.minFinalScore ?? (await resolveDefaultMinFinalScore()), // The mock run must not touch Ollama at all — that is what makes it // usable with no models installed. The rewrite is a chat-model call, so // it is always skipped there (it would 404 and silently fall back, which diff --git a/admin/app/services/eval_retrieval_service.ts b/admin/app/services/eval_retrieval_service.ts index 6d71100d..bec6a9bf 100644 --- a/admin/app/services/eval_retrieval_service.ts +++ b/admin/app/services/eval_retrieval_service.ts @@ -3,11 +3,8 @@ import { RagService } from '#services/rag_service' import { RelevanceJudgeService } from '#services/relevance_judge_service' import { inject } from '@adonisjs/core' import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js' -import { - RAG_DEFAULT_SCORE_THRESHOLD, - RAG_DEFAULT_TOP_K, - RAG_MIN_FINAL_SCORE, -} from '../../constants/ollama.js' +import { RAG_DEFAULT_SCORE_THRESHOLD, RAG_DEFAULT_TOP_K } from '../../constants/ollama.js' +import { resolveDefaultMinFinalScore } from '../utils/rag_relevance.js' import type { RetrievalStages } from '../../types/rag.js' import { docIdFromSource } from '../utils/eval/corpus_source.js' import type { Golden } from '../utils/eval/golden_set.js' @@ -107,12 +104,12 @@ export class EvalRetrievalService { async run(goldens: Golden[], options: RetrievalRunOptions = {}): Promise { const topK = options.topK ?? RAG_DEFAULT_TOP_K const scoreThreshold = options.scoreThreshold ?? RAG_DEFAULT_SCORE_THRESHOLD - // RAG_MIN_FINAL_SCORE, deliberately — NOT resolveMinFinalScore(). The chat - // path reads the user's `rag.minRelevance` setting; this tier must not, or a - // slider position on one developer's machine would silently move the numbers - // and the committed baseline would stop being reproducible anywhere else. - // Same reasoning as the harness omitting `skipRetrieval`. - const minFinalScore = options.minFinalScore ?? RAG_MIN_FINAL_SCORE + // The embedding model's default floor, deliberately — NOT resolveMinFinalScore(). + // The chat path reads the user's `rag.minRelevance` setting; this tier must not, + // or a slider position on one developer's machine would silently move the + // numbers and the committed baseline would stop being reproducible anywhere + // else. Same reasoning as the harness omitting `skipRetrieval`. + const minFinalScore = options.minFinalScore ?? (await resolveDefaultMinFinalScore()) const kValues = options.kValues ?? DEFAULT_K_VALUES const cases: RetrievalCase[] = [] diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts index 54f8e9da..85cdb1a2 100644 --- a/admin/app/services/ollama_service.ts +++ b/admin/app/services/ollama_service.ts @@ -19,6 +19,7 @@ import Fuse, { IFuseOptions } from 'fuse.js' import { BROADCAST_CHANNELS } from '../../constants/broadcast.js' import env from '#start/env' import { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js' +import { isEmbeddingModelName, pickEmbeddingModel } from '../utils/misc.js' import KVStore from '#models/kv_store' import type { ModelCapabilities } from '../utils/model_capabilities.js' import { @@ -1165,9 +1166,12 @@ export class OllamaService { const response = await axios.get(`${this.baseUrl}/api/ps`, { timeout: 5000 }) const models: Array<{ name?: string; size_vram?: number }> = response.data?.models ?? [] // Match any loaded model whose name signals it's an embedding model. - // nomic-embed-text, mxbai-embed-large, snowflake-arctic-embed, etc. all follow this convention. + // nomic-embed-text, mxbai-embed-large, snowflake-arctic-embed, etc. all follow this + // convention; bge-m3 doesn't, so the configurable models are matched by name too. return models.some( - (m) => m.name?.toLowerCase().includes('embed') && (m.size_vram ?? 0) > 0 + (m) => + (m.name?.toLowerCase().includes('embed') || isEmbeddingModelName(m.name ?? '')) && + (m.size_vram ?? 0) > 0 ) } catch (err: any) { // /api/ps unreachable (Ollama down, non-native backend, etc.) — fail closed: assume CPU, @@ -1224,14 +1228,20 @@ export class OllamaService { // message — and the whole point of routing the rewrite off the chat model was // to stop the two from fighting over Ollama's single slot. let tasksModel: string | null = null + let embeddingModel = EMBEDDING_MODEL_NAME try { tasksModel = (await KVStore.getValue('ai.tasksModel'))?.trim() || null + embeddingModel = pickEmbeddingModel(await KVStore.getValue('rag.embeddingModel')).name } catch { - // Setting unreadable; fall through and treat it as unset. + // Settings unreadable; fall through and treat them as unset. } const toUnload = loadedModels.filter( - (name) => name !== EMBEDDING_MODEL_NAME && name !== targetModel && name !== tasksModel + (name) => + name !== embeddingModel && + name !== `${embeddingModel}:latest` && + name !== targetModel && + name !== tasksModel ) await Promise.all( @@ -1275,7 +1285,7 @@ export class OllamaService { this.nativeProbe = Promise.resolve(true) const models: NomadInstalledModel[] = response.data.models if (includeEmbeddings) return models - return models.filter((m) => !m.name.includes('embed')) + return models.filter((m) => !m.name.includes('embed') && !isEmbeddingModelName(m.name)) } catch (tagsErr) { // Classify the backend only when it actually answered. A non-2xx status, or the // shape-mismatch throw above (a 200 arrived with a non-Ollama body), proves a live @@ -1294,7 +1304,7 @@ export class OllamaService { const modelList = await this.openai!.models.list() const models: NomadInstalledModel[] = installedModelsFromOpenAIResponse(modelList) if (includeEmbeddings) return models - return models.filter((m) => !m.name.includes('embed')) + return models.filter((m) => !m.name.includes('embed') && !isEmbeddingModelName(m.name)) } catch (err) { logger.error( `[OllamaService] Failed to list models: ${err instanceof Error ? err.message : err}` diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 7bf77f52..8084e75a 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -31,7 +31,8 @@ import { decideContentReindex, type ReindexOutcome } from '../utils/content_rein import KbRatioRegistry from '#models/kb_ratio_registry' import { decideWarnings } from '../utils/kb_warning_decision.js' import type { FileWarning, FileWarningsResult, RetrievalFloorStats, RetrievalStages, StoredFileInfo } from '../../types/rag.js' -import { applyRelevanceFloor } from '../utils/misc.js' +import { EMBEDDING_MODEL_NAME } from '../../constants/ollama.js' +import { applyRelevanceFloor, isInstalledTagOf, pickEmbeddingModel } from '../utils/misc.js' import { KB_EVAL_COLLECTION } from '../../constants/kb_collections.js' /** @@ -47,7 +48,6 @@ import type { KbIngestStateValue } from '../../types/kb_ingest_state.js' import { ZIMExtractionService } from './zim_extraction_service.js' import { ZIM_BATCH_SIZE } from '../../constants/zim_extraction.js' import { hasMoreArticleBatches } from '../utils/zim_batch_decision.js' -import { EMBEDDING_MODEL_NAME } from '../../constants/ollama.js' import { ProcessAndEmbedFileResponse, ProcessZIMFileResponse, @@ -71,13 +71,13 @@ export class RagService { private qdrantInitPromise: Promise | null = null private embeddingModelVerified = false private resolvedEmbeddingModel: string | null = null + private embeddingModel: ReturnType | null = null // Collections already verified this session (created + payload indexes in place). // Skips the getCollections/createPayloadIndex round-trips that otherwise run on // every embed call — ~45% of per-document Qdrant time on large ingestions (#1129) private ensuredCollections = new Set() public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads' public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base' - public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768 // Upper bound on distinct sources returned by Qdrant's facet API. Real // NOMADs cap out at a few hundred ZIM files + uploaded PDFs; 10k leaves // generous headroom without paying the cost of an unbounded request. @@ -93,9 +93,6 @@ export class RagService { public static CHAR_TO_TOKEN_RATIO = 2 // Conservative chars-per-token estimate; technical docs // (numbers, symbols, abbreviations) tokenize denser // than plain prose (~3), so 2 avoids context overflows - // Nomic Embed Text v1.5 uses task-specific prefixes for optimal performance - public static SEARCH_DOCUMENT_PREFIX = 'search_document: ' - public static SEARCH_QUERY_PREFIX = 'search_query: ' public static EMBEDDING_BATCH_SIZE = 8 // Conservative batch size for low-end hardware constructor( @@ -146,10 +143,35 @@ export class RagService { } } - private async _ensureCollection( - collectionName: string, - dimensions: number = RagService.EMBEDDING_DIMENSION - ) { + /** + * The configured embedding model (`rag.embeddingModel`), read once per + * instance so one job never embeds with two models. Each EmbedFileJob builds + * its own RagService, so a changed setting applies from the next job. + */ + private async _getEmbeddingModel() { + this.embeddingModel ??= pickEmbeddingModel(await KVStore.getValue('rag.embeddingModel')) + return this.embeddingModel + } + + /** + * False when the knowledge base was built with a different model than the + * configured one — `rag.embeddingModel` was changed without a Reset & Rebuild. + * A different width makes Qdrant reject every write and query; the same width + * (nomic ↔ embeddinggemma) is worse, returning unrelated chunks without error. + * Collections that predate the setting have no record and were built by nomic. + */ + public async embeddingModelMatchesCollection(): Promise { + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) + const info = await this.qdrant!.getCollection(RagService.CONTENT_COLLECTION_NAME) + const { name, dimension } = await this._getEmbeddingModel() + const indexedWith = (await KVStore.getValue('rag.embeddingModelIndexed')) ?? EMBEDDING_MODEL_NAME + return ( + indexedWith === name && + (info.config?.params?.vectors as { size?: number } | undefined)?.size === dimension + ) + } + + private async _ensureCollection(collectionName: string) { try { await this._ensureDependencies() @@ -160,13 +182,15 @@ export class RagService { const collections = await this.qdrant!.getCollections() const collectionExists = collections.collections.some((col) => col.name === collectionName) + const { name, dimension } = await this._getEmbeddingModel() if (!collectionExists) { await this.qdrant!.createCollection(collectionName, { vectors: { - size: dimensions, + size: dimension, distance: 'Cosine', }, }) + await KVStore.setValue('rag.embeddingModelIndexed', name) } // Create payload indexes for faster filtering (idempotent — Qdrant ignores duplicates) @@ -352,33 +376,31 @@ export class RagService { onProgress?: (percent: number) => Promise ): Promise<{ chunks: number } | null> { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) + const embedding = await this._getEmbeddingModel() if (!this.embeddingModelVerified) { const allModels = await this.ollamaService.getModels(true) const embeddingModel = - allModels.find((model) => model.name === EMBEDDING_MODEL_NAME) ?? - allModels.find((model) => model.name.toLowerCase().includes('nomic-embed-text')) + allModels.find((model) => model.name === embedding.name) ?? + allModels.find((model) => isInstalledTagOf(model.name, embedding.name)) if (!embeddingModel) { try { - const downloadResult = await this.ollamaService.downloadModel(EMBEDDING_MODEL_NAME) + const downloadResult = await this.ollamaService.downloadModel(embedding.name) if (!downloadResult.success) { throw new Error(downloadResult.message || 'Unknown error during model download') } } catch (modelError) { logger.error( - `[RAG] Embedding model ${EMBEDDING_MODEL_NAME} not found locally and failed to download:`, + `[RAG] Embedding model ${embedding.name} not found locally and failed to download:`, modelError ) this.embeddingModelVerified = false return null } } - this.resolvedEmbeddingModel = embeddingModel?.name ?? EMBEDDING_MODEL_NAME + this.resolvedEmbeddingModel = embeddingModel?.name ?? embedding.name this.embeddingModelVerified = true } @@ -408,7 +430,7 @@ export class RagService { let chunkText = chunks[i] // Final safety check: ensure chunk + prefix fits - const prefixText = RagService.SEARCH_DOCUMENT_PREFIX + const prefixText = embedding.documentPrefix const withPrefix = prefixText + chunkText const estimatedTokens = this.estimateTokenCount(withPrefix) @@ -421,7 +443,7 @@ export class RagService { chunkText = this.truncateToTokenLimit(chunkText, maxTokensForText) } - prefixedChunks.push(RagService.SEARCH_DOCUMENT_PREFIX + chunkText) + prefixedChunks.push(embedding.documentPrefix + chunkText) } // Batch embed chunks for performance @@ -438,7 +460,7 @@ export class RagService { ) const response = await this.ollamaService.embed( - this.resolvedEmbeddingModel ?? EMBEDDING_MODEL_NAME, + this.resolvedEmbeddingModel ?? embedding.name, batch ) @@ -963,10 +985,7 @@ export class RagService { try { logger.debug(`[RAG] Starting similarity search for query: "${query}"`) - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // Check if collection has any points const collectionInfo = await this.qdrant!.getCollection(RagService.CONTENT_COLLECTION_NAME) @@ -978,14 +997,22 @@ export class RagService { return [] } + const embedding = await this._getEmbeddingModel() + if (!(await this.embeddingModelMatchesCollection())) { + logger.warn( + `[RAG] Knowledge base was built with a different embedding model than ${embedding.name}. Run Reset & Rebuild to search it.` + ) + return [] + } + if (!this.embeddingModelVerified) { const allModels = await this.ollamaService.getModels(true) const embeddingModel = - allModels.find((model) => model.name === EMBEDDING_MODEL_NAME) ?? - allModels.find((model) => model.name.toLowerCase().includes('nomic-embed-text')) + allModels.find((model) => model.name === embedding.name) ?? + allModels.find((model) => isInstalledTagOf(model.name, embedding.name)) if (!embeddingModel) { - logger.warn(`[RAG] ${EMBEDDING_MODEL_NAME} not found. Cannot perform similarity search.`) + logger.warn(`[RAG] ${embedding.name} not found. Cannot perform similarity search.`) this.embeddingModelVerified = false return [] } @@ -1000,12 +1027,12 @@ export class RagService { // Generate embedding for the query with search_query prefix // Ensure query doesn't exceed token limit - const prefixTokens = this.estimateTokenCount(RagService.SEARCH_QUERY_PREFIX) + const prefixTokens = this.estimateTokenCount(embedding.queryPrefix) const maxQueryTokens = RagService.MAX_SAFE_TOKENS - prefixTokens const truncatedQuery = this.truncateToTokenLimit(processedQuery, maxQueryTokens) - const prefixedQuery = RagService.SEARCH_QUERY_PREFIX + truncatedQuery - logger.debug(`[RAG] Generating embedding with prefix: "${RagService.SEARCH_QUERY_PREFIX}"`) + const prefixedQuery = embedding.queryPrefix + truncatedQuery + logger.debug(`[RAG] Generating embedding with prefix: "${embedding.queryPrefix}"`) // Validate final token count const queryTokenCount = this.estimateTokenCount(prefixedQuery) @@ -1017,7 +1044,7 @@ export class RagService { } const response = await this.ollamaService.embed( - this.resolvedEmbeddingModel ?? EMBEDDING_MODEL_NAME, + this.resolvedEmbeddingModel ?? embedding.name, [prefixedQuery] ) @@ -1310,10 +1337,7 @@ export class RagService { */ public async hasDocuments(): Promise { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) const collectionInfo = await this.qdrant!.getCollection(RagService.CONTENT_COLLECTION_NAME) return (collectionInfo.points_count ?? 0) > 0 } catch { @@ -1323,10 +1347,7 @@ export class RagService { public async getStoredFiles(): Promise { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // Use Qdrant's facet API to enumerate distinct `source` values in one // call. The previous scroll-loop walked every point in the collection @@ -1427,7 +1448,7 @@ export class RagService { * `source` facet pattern used elsewhere in this file (see getStoredFiles). */ public async getKnowledgeCollections(): Promise { - await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, { key: 'collection', limit: RagService.FACET_SOURCE_LIMIT, @@ -1454,10 +1475,7 @@ export class RagService { collection: string | null ): Promise<{ success: boolean; message: string }> { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, { payload: { collection }, @@ -1498,10 +1516,7 @@ export class RagService { active: boolean ): Promise<{ success: boolean; message: string }> { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // A source can have chunks in Qdrant but no state row: a ZIM mid-ingestion // (markIndexed only runs after the final batch), a pre-RFC install, or a @@ -1578,10 +1593,7 @@ export class RagService { active: boolean ): Promise<{ success: boolean; message: string; affectedCount: number }> { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) const collectionQuery = () => collection === null @@ -1644,10 +1656,7 @@ export class RagService { if (!oldName || !newName || oldName === newName) { return { success: false, message: 'Invalid collection names.' } } - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, { payload: { collection: newName }, @@ -1676,10 +1685,7 @@ export class RagService { if (!name) { return { success: false, message: 'Invalid collection name.' } } - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, { payload: { collection: null }, @@ -1704,7 +1710,7 @@ export class RagService { * landed and what a future per-collection UI would want. */ public async countChunksInCollection(collection: string): Promise { - await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) const result = await this.qdrant!.count(RagService.CONTENT_COLLECTION_NAME, { filter: { must: [{ key: 'collection', match: { value: collection } }] }, exact: true, @@ -1722,7 +1728,7 @@ export class RagService { * disposable by construction. Returns the number of points removed. */ public async deleteCollectionPoints(collection: string): Promise { - await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) const before = await this.countChunksInCollection(collection) if (before === 0) return 0 await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, { @@ -1841,10 +1847,7 @@ export class RagService { */ public async computeFileWarnings(): Promise { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // Per-source chunk count via Qdrant's facet API. Was a full scroll of // every point in the collection, which on a fully-ingested NOMAD takes @@ -1922,10 +1925,7 @@ export class RagService { */ public async deleteFileBySource(source: string): Promise<{ success: boolean; message: string }> { try { - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, { filter: { @@ -2336,7 +2336,7 @@ export class RagService { */ private async _deletePointsBySources(sources: string[]): Promise { if (sources.length === 0) return - await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, { filter: { must: [{ key: 'source', match: { any: sources } }] }, }) @@ -2412,10 +2412,7 @@ export class RagService { const { files: filesInStorage, scannedRoots } = await this._discoverKbFilesWithRoots() logger.info(`[RAG] Found ${filesInStorage.length} embeddable files in storage`) - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // Collect every unique `source` already in Qdrant so we can skip files // that have already been embedded. Facet returns the distinct values in @@ -2619,10 +2616,7 @@ export class RagService { const filesInStorage = await this._discoverKbFiles() - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // Per-file: delete-then-dispatch. We tried dispatch-then-delete but that // opens a race where a fast worker can write new points before our @@ -2730,10 +2724,7 @@ export class RagService { // _ensureCollection call below actually recreates it this.ensuredCollections.delete(RagService.CONTENT_COLLECTION_NAME) - await this._ensureCollection( - RagService.CONTENT_COLLECTION_NAME, - RagService.EMBEDDING_DIMENSION - ) + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME) // Force Nomad docs to be re-dispatched. await KVStore.setValue('rag.docsEmbedded', false) diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 2e620c7f..e8b30eb0 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -932,7 +932,8 @@ export class SystemService { if (key === 'ai.assistantCustomName') { invalidateAssistantNameCache() } - if (key === 'rag.minRelevance') { + // The embedding model supplies the default floor, so switching it moves the resolved value + if (key === 'rag.minRelevance' || key === 'rag.embeddingModel') { invalidateMinRelevanceCache() } if (key === 'ai.responseStyle') { diff --git a/admin/app/utils/misc.ts b/admin/app/utils/misc.ts index 2e32dba2..965e3d0d 100644 --- a/admin/app/utils/misc.ts +++ b/admin/app/utils/misc.ts @@ -1,3 +1,5 @@ +import { EMBEDDING_MODEL_NAME, EMBEDDING_MODELS } from '../../constants/ollama.js' + export function formatSpeed(bytesPerSecond: number): string { if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` @@ -12,6 +14,38 @@ export function toTitleCase(str: string): string { .join(' ') } +/** + * Resolve the knowledge-base embedding model from the `rag.embeddingModel` + * setting. An unset or unknown value falls back to the default model, so an + * unset setting leaves prior behaviour untouched. + */ +export function pickEmbeddingModel(configured: string | null | undefined) { + const trimmed = configured?.trim() + const name = trimmed && Object.hasOwn(EMBEDDING_MODELS, trimmed) ? trimmed : EMBEDDING_MODEL_NAME + return { name, ...EMBEDDING_MODELS[name] } +} + +/** + * True for a model in EMBEDDING_MODELS, whose names don't all contain "embed" + * (bge-m3). Keeps them out of chat model lists. Any tag of a listed model + * counts (bge-m3:567m), as does the listed name itself. + */ +export function isEmbeddingModelName(model: string): boolean { + return Object.hasOwn(EMBEDDING_MODELS, model) || Object.hasOwn(EMBEDDING_MODELS, model.split(':')[0]) +} + +/** + * Whether an installed Ollama model (`installed`) can stand in for `wanted`. + * nomic-embed-text has always been accepted under any tag; other models must + * match exactly or as `:latest`, because other tags of the same family can be + * different models entirely (qwen3-embedding:8b is 4096-wide, :0.6b is 1024). + */ +export function isInstalledTagOf(installed: string, wanted: string): boolean { + const name = installed.toLowerCase() + if (wanted === EMBEDDING_MODEL_NAME) return name.includes('nomic-embed-text') + return name === wanted || name === `${wanted}:latest` +} + /** * Decide which model runs an ancillary AI task (chat titles, chat suggestions). * diff --git a/admin/app/utils/rag_relevance.ts b/admin/app/utils/rag_relevance.ts index ad025f7c..325453fc 100644 --- a/admin/app/utils/rag_relevance.ts +++ b/admin/app/utils/rag_relevance.ts @@ -1,7 +1,7 @@ import logger from '@adonisjs/core/services/logger' import KVStore from '#models/kv_store' import { RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js' -import { parseMinRelevance } from './misc.js' +import { parseMinRelevance, pickEmbeddingModel } from './misc.js' /** * Resolves the user's retrieval relevance floor (`rag.minRelevance`), cached. @@ -26,24 +26,35 @@ export function invalidateMinRelevanceCache(): void { cache = null } +/** + * The configured embedding model's default floor — what an unset + * `rag.minRelevance` means. Never reads the user's setting, so the eval harness + * can pin to it the way it pinned to RAG_MIN_FINAL_SCORE. + */ +export async function resolveDefaultMinFinalScore(): Promise { + return pickEmbeddingModel(await KVStore.getValue('rag.embeddingModel')).minFinalScore +} + /** The relevance floor to apply to this turn's retrieval. */ export async function resolveMinFinalScore(): Promise { const now = Date.now() if (cache && now < cache.expiresAt) return cache.value let raw: string | null = null + let fallback = RAG_MIN_FINAL_SCORE try { raw = await KVStore.getValue('rag.minRelevance') + fallback = await resolveDefaultMinFinalScore() } catch (error) { // A KV read failure must not take retrieval out, and must not be cached — // fall back to the default and let the next turn try again. logger.warn( - `[RAG] Failed to read rag.minRelevance, using ${RAG_MIN_FINAL_SCORE}: ${error instanceof Error ? error.message : error}` + `[RAG] Failed to read the relevance settings, using ${RAG_MIN_FINAL_SCORE}: ${error instanceof Error ? error.message : error}` ) return RAG_MIN_FINAL_SCORE } - const value = parseMinRelevance(raw, RAG_MIN_FINAL_SCORE) + const value = parseMinRelevance(raw, fallback) cache = { value, expiresAt: now + CACHE_TTL_MS } return value } diff --git a/admin/app/validators/settings.ts b/admin/app/validators/settings.ts index 2de84dbd..2cd57f1d 100644 --- a/admin/app/validators/settings.ts +++ b/admin/app/validators/settings.ts @@ -2,7 +2,7 @@ import vine from "@vinejs/vine"; import { SETTINGS_KEYS } from "../../constants/kv_store.js"; import type { KVStoreKey } from "../../types/kv_store.js"; import { CONTEXT_LADDER } from "../utils/context_window.js"; -import { RESPONSE_STYLE_PRESETS } from "../../constants/ollama.js"; +import { EMBEDDING_MODELS, RESPONSE_STYLE_PRESETS } from "../../constants/ollama.js"; export const getSettingSchema = vine.compile(vine.object({ key: vine.enum(SETTINGS_KEYS), @@ -64,6 +64,16 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string | } return null } + case 'rag.embeddingModel': { + // Empty clears the setting (reverts to the default model). + if (value === '' || value === undefined || value === null) { + return null + } + if (typeof value !== 'string' || !Object.hasOwn(EMBEDDING_MODELS, value)) { + return `Embedding model must be one of: ${Object.keys(EMBEDDING_MODELS).join(', ')}.` + } + return null + } case 'ai.contextWindow': { // "auto" (or empty) hands sizing to ContextWindowService. An explicit // value is a *cap*, so it only ever lowers the resolved window — but it @@ -79,7 +89,7 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string | return null } case 'rag.minRelevance': { - // Empty/'auto' clears the setting and reverts to RAG_MIN_FINAL_SCORE. + // Empty/'auto' clears the setting and reverts to the embedding model's default floor. // An explicit 0 is a real choice — it turns the floor off — so it is // deliberately not treated as "unset". // Trimmed first so the accepted set matches parseMinRelevance's: diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index bb5eb06d..26acb3b1 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -17,6 +17,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [ 'ai.responseStyle', 'ai.amdHsaOverride', 'rag.defaultIngestPolicy', + 'rag.embeddingModel', 'rag.enabled', 'rag.minRelevance', 'rag.relevanceCheck', diff --git a/admin/constants/ollama.ts b/admin/constants/ollama.ts index b1f7ebf0..d2af900b 100644 --- a/admin/constants/ollama.ts +++ b/admin/constants/ollama.ts @@ -167,10 +167,63 @@ export const RAG_DEFAULT_SCORE_THRESHOLD = 0.3 * alongside this one was also tried and loses recall faster than it cuts leaks: * the reranker's boosts lift relevant chunks more than irrelevant ones. * - * Unset `rag.minRelevance` resolves to this value; see app/utils/rag_relevance.ts. + * Unset `rag.minRelevance` resolves to this value when the embedding model is + * nomic; other models carry their own (EMBEDDING_MODELS). See app/utils/rag_relevance.ts. */ export const RAG_MIN_FINAL_SCORE = 0.62 +/** + * Embedding models the knowledge base can be built with (`rag.embeddingModel`), + * keyed by Ollama model name. Vectors from two models are not comparable, so + * changing the setting needs a Reset & Rebuild of the knowledge base. + * + * `minFinalScore` is each model's default relevance floor (what an unset + * `rag.minRelevance` resolves to). Score scales differ between models — bge-m3 + * puts relevant chunks around where nomic puts unrelated ones — so one floor + * cannot serve them all. Each was calibrated the way RAG_MIN_FINAL_SCORE was, + * with `eval:retrieval --min-final-score=` on the same eval corpus: the + * highest floor that costs no recall@5, less the same + * 0.04 of margin (nomic 0.66 → 0.62, bge-m3 0.48 → 0.44). Where recall already + * falls above 0.30 the floor sits at 0.30, which scores identically to no + * floor on the golden set. + */ +export const EMBEDDING_MODELS: Record< + string, + { dimension: number; documentPrefix: string; queryPrefix: string; minFinalScore: number } +> = { + // Nomic Embed Text v1.5 uses task-specific prefixes for optimal performance + [EMBEDDING_MODEL_NAME]: { + dimension: 768, + documentPrefix: 'search_document: ', + queryPrefix: 'search_query: ', + minFinalScore: RAG_MIN_FINAL_SCORE, + }, + // Multilingual (100+ languages), 8K context; dense retrieval takes no prefixes + 'bge-m3': { dimension: 1024, documentPrefix: '', queryPrefix: '', minFinalScore: 0.44 }, + // Multilingual, 32K context; queries carry an instruction, documents nothing + 'qwen3-embedding:0.6b': { + dimension: 1024, + documentPrefix: '', + queryPrefix: + 'Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery:', + minFinalScore: 0.35, + }, + // Multilingual, 8K context + 'snowflake-arctic-embed2': { + dimension: 1024, + documentPrefix: '', + queryPrefix: 'query: ', + minFinalScore: 0.3, + }, + // Multilingual, 2K context + 'embeddinggemma': { + dimension: 768, + documentPrefix: 'title: none | text: ', + queryPrefix: 'task: search result | query: ', + minFinalScore: 0.3, + }, +} + /** * Preset relevance floors offered in Settings > Models, and the labels for them. * The stored setting is the number, not the label, so retuning these presets diff --git a/admin/tests/unit/embedding_model.spec.ts b/admin/tests/unit/embedding_model.spec.ts new file mode 100644 index 00000000..bebb363b --- /dev/null +++ b/admin/tests/unit/embedding_model.spec.ts @@ -0,0 +1,95 @@ +/** + * Tests for the embedding-model decision — which model the knowledge base is + * embedded and searched with, from the `rag.embeddingModel` setting. + * + * The decision is a pure function; RagService does only the KV read around it. + * + * Pure functions only — no MySQL, Redis, Qdrant, or Ollama needed: + * npm run test:unit + */ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' + +import { EMBEDDING_MODEL_NAME, EMBEDDING_MODELS, RAG_MIN_FINAL_SCORE } from '../../constants/ollama.js' +import { isEmbeddingModelName, isInstalledTagOf, pickEmbeddingModel } from '../../app/utils/misc.js' + +test('embedding model: unset setting keeps nomic-embed-text, its prefixes and its floor', () => { + for (const configured of [null, undefined, '', ' ']) { + assert.deepEqual(pickEmbeddingModel(configured), { + name: EMBEDDING_MODEL_NAME, + dimension: 768, + documentPrefix: 'search_document: ', + queryPrefix: 'search_query: ', + minFinalScore: RAG_MIN_FINAL_SCORE, + }) + } +}) + +test('embedding model: bge-m3 is 1024-wide, takes no prefixes, and has its own floor', () => { + const bge = pickEmbeddingModel('bge-m3') + assert.equal(bge.name, 'bge-m3') + assert.equal(bge.dimension, 1024) + assert.equal(bge.documentPrefix, '') + assert.equal(bge.queryPrefix, '') + // bge-m3 scores relevant chunks where nomic scores unrelated ones; the nomic + // floor drops a tenth of the golden set's answers under it. + assert.ok(bge.minFinalScore < RAG_MIN_FINAL_SCORE) +}) + +test('embedding model: every listed model has a usable width and floor', () => { + for (const [name, model] of Object.entries(EMBEDDING_MODELS)) { + assert.ok(Number.isInteger(model.dimension) && model.dimension > 0, `${name} dimension`) + assert.ok(model.minFinalScore > 0 && model.minFinalScore < 1, `${name} minFinalScore`) + } +}) + +test('embedding model: surrounding whitespace is trimmed before matching', () => { + assert.equal(pickEmbeddingModel(' bge-m3 ').name, 'bge-m3') +}) + +test('embedding model: an unknown model falls back to the default', () => { + // A value written before a model was removed from EMBEDDING_MODELS, or by hand. + // Its dimension and prefixes are unknown, so it cannot be embedded with safely. + assert.equal(pickEmbeddingModel('mxbai-embed-large').name, EMBEDDING_MODEL_NAME) +}) + +test('embedding model: object prototype keys are not models', () => { + // `'toString' in {}` is true; a lookup that used `in` would return a + // profile with no dimension and create a collection of size undefined. + assert.equal(pickEmbeddingModel('toString').name, EMBEDDING_MODEL_NAME) + assert.equal(pickEmbeddingModel('constructor').name, EMBEDDING_MODEL_NAME) +}) + +test('embedding model names: bge-m3 is recognised with or without the :latest tag', () => { + // Ollama lists a bare `ollama pull bge-m3` as bge-m3:latest, and the name has + // no "embed" in it, so the chat model lists need this to leave it out. + assert.equal(isEmbeddingModelName('bge-m3'), true) + assert.equal(isEmbeddingModelName('bge-m3:latest'), true) + assert.equal(isEmbeddingModelName(EMBEDDING_MODEL_NAME), true) + assert.equal(isEmbeddingModelName('bge-m3:567m'), true) +}) + +test('embedding model names: chat models and prototype keys are not embedding models', () => { + assert.equal(isEmbeddingModelName('llama3.1:8b'), false) + assert.equal(isEmbeddingModelName('toString'), false) +}) + +test('installed tags: nomic-embed-text keeps its historical any-tag fallback', () => { + assert.equal(isInstalledTagOf('nomic-embed-text:latest', EMBEDDING_MODEL_NAME), true) + assert.equal(isInstalledTagOf('nomic-embed-text:v1.5', EMBEDDING_MODEL_NAME), true) +}) + +test('installed tags: other models match exactly or as :latest only', () => { + assert.equal(isInstalledTagOf('bge-m3:latest', 'bge-m3'), true) + assert.equal(isInstalledTagOf('qwen3-embedding:0.6b', 'qwen3-embedding:0.6b'), true) + // A different size of the same family is a different model with a different + // width; accepting it would build a 1024-d collection and embed 4096-d vectors. + assert.equal(isInstalledTagOf('qwen3-embedding:8b', 'qwen3-embedding:0.6b'), false) + assert.equal(isInstalledTagOf('qwen3-embedding:latest', 'qwen3-embedding:0.6b'), false) +}) + +test('embedding model: the qwen3 query instruction ends its line before the query', () => { + // Qwen3-Embedding's documented format is `Instruct: \nQuery:`; + // a literal backslash-n would still embed, just worse, with nothing failing. + assert.match(pickEmbeddingModel('qwen3-embedding:0.6b').queryPrefix, /^Instruct: .+\nQuery:$/) +}) diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts index 6daed804..0105ba6d 100644 --- a/admin/types/kv_store.ts +++ b/admin/types/kv_store.ts @@ -4,6 +4,10 @@ export const KV_STORE_SCHEMA = { 'chat.lastModel': 'string', 'rag.docsEmbedded': 'boolean', 'rag.defaultIngestPolicy': 'string', + // Key of EMBEDDING_MODELS; null → EMBEDDING_MODEL_NAME. Changing it needs a Reset & Rebuild. + 'rag.embeddingModel': 'string', + // Model the knowledge base collection was created with; written by RagService, not user-set. + 'rag.embeddingModelIndexed': '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),