mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-16 21:00:03 +02:00
Merge 94fb876f1b into 54ee30a449
This commit is contained in:
commit
74adfd8563
|
|
@ -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