mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-16 21:00:03 +02:00
feat(rag): make the knowledge base embedding model configurable
Adds a `rag.embeddingModel` setting that selects the Ollama model the knowledge base is embedded and searched with. EMBEDDING_MODELS lists each supported model with its vector width, task prefixes and a calibrated default relevance floor: nomic-embed-text:v1.5 768 default, unchanged bge-m3 1024 multilingual qwen3-embedding:0.6b 1024 multilingual snowflake-arctic-embed2 1024 multilingual embeddinggemma 768 multilingual An unset setting behaves exactly as before. Score scales differ between models, so the relevance floor is now per model: an unset `rag.minRelevance` resolves to the configured model's floor, and the eval harness pins to it. Each floor was calibrated the way RAG_MIN_FINAL_SCORE was, with `eval:retrieval --min-final-score` sweeps. Under nomic's 0.62, bge-m3 loses a tenth of the golden set. Switching models is followed by the existing Reset & Rebuild. The model a collection was built with is recorded when it is created (collections from before this change count as nomic), and until they match embed jobs fail once with UnrecoverableError instead of retrying 30 times, and search returns no context with a warning. This also catches switches between models of the same width, which Qdrant would accept silently. Embedding models whose names lack "embed" (bge-m3) are also kept out of the chat model lists and recognised by GPU pacing, the configured model is exempt from the VRAM unload sweep, and only nomic keeps the any-tag installed-model fallback, since other tags of a family can differ in width (qwen3-embedding:8b is 4096-d).
This commit is contained in:
parent
54ee30a449
commit
94fb876f1b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
|
|||
'ai.responseStyle',
|
||||
'ai.amdHsaOverride',
|
||||
'rag.defaultIngestPolicy',
|
||||
'rag.embeddingModel',
|
||||
'rag.enabled',
|
||||
'rag.minRelevance',
|
||||
'rag.relevanceCheck',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
admin/tests/unit/embedding_model.spec.ts
Normal file
95
admin/tests/unit/embedding_model.spec.ts
Normal 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:$/)
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user