This commit is contained in:
Matt Moyles 2026-10-09 18:01:13 +00:00 committed by GitHub
commit 74adfd8563
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 337 additions and 119 deletions

View File

@ -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.

View File

@ -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

View File

@ -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<string> {
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')
)

View File

@ -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

View File

@ -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<RetrievalRunResult> {
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[] = []

View File

@ -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}`

View File

@ -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<void> | null = null
private embeddingModelVerified = false
private resolvedEmbeddingModel: string | null = null
private embeddingModel: ReturnType<typeof pickEmbeddingModel> | 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<string>()
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<boolean> {
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<void>
): 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<boolean> {
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<StoredFileInfo[]> {
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<string[]> {
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<number> {
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<number> {
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<FileWarningsResult> {
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<void> {
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)

View File

@ -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') {

View File

@ -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).
*

View File

@ -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<number> {
return pickEmbeddingModel(await KVStore.getValue('rag.embeddingModel')).minFinalScore
}
/** The relevance floor to apply to this turn's retrieval. */
export async function resolveMinFinalScore(): Promise<number> {
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
}

View File

@ -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:

View File

@ -17,6 +17,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
'ai.responseStyle',
'ai.amdHsaOverride',
'rag.defaultIngestPolicy',
'rag.embeddingModel',
'rag.enabled',
'rag.minRelevance',
'rag.relevanceCheck',

View File

@ -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=<x>` 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

View File

@ -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: <task>\nQuery:<query>`;
// a literal backslash-n would still embed, just worse, with nothing failing.
assert.match(pickEmbeddingModel('qwen3-embedding:0.6b').queryPrefix, /^Instruct: .+\nQuery:$/)
})

View File

@ -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),