mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
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).
147 lines
6.7 KiB
TypeScript
147 lines
6.7 KiB
TypeScript
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 { EMBEDDING_MODELS, RESPONSE_STYLE_PRESETS } from "../../constants/ollama.js";
|
|
|
|
export const getSettingSchema = vine.compile(vine.object({
|
|
key: vine.enum(SETTINGS_KEYS),
|
|
}))
|
|
|
|
export const updateSettingSchema = vine.compile(vine.object({
|
|
key: vine.enum(SETTINGS_KEYS),
|
|
value: vine.any().optional(),
|
|
}))
|
|
|
|
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
|
|
|
|
/**
|
|
* Validate the *value* for keys that have format constraints beyond the generic
|
|
* enum/any check (the generic validator only constrains the key). Returns an
|
|
* error message string when invalid, or null when the value is acceptable.
|
|
*/
|
|
export function validateSettingValue(key: KVStoreKey, value: unknown): string | null {
|
|
switch (key) {
|
|
case 'autoUpdate.windowStart':
|
|
case 'autoUpdate.windowEnd':
|
|
case 'contentAutoUpdate.windowStart':
|
|
case 'contentAutoUpdate.windowEnd':
|
|
if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) {
|
|
return 'Time window values must be in 24-hour HH:MM format (e.g. "20:00").'
|
|
}
|
|
return null
|
|
case 'autoUpdate.cooloffHours':
|
|
case 'contentAutoUpdate.cooloffHours': {
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num < 0 || num > 8760) {
|
|
return 'Cool-off must be a whole number of hours between 0 and 8760.'
|
|
}
|
|
return null
|
|
}
|
|
case 'system.internetStatusTestUrl': {
|
|
// Empty clears the setting (reverts to env var / built-in defaults).
|
|
if (value === '' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
if (typeof value !== 'string') {
|
|
return 'Test URL must be a string.'
|
|
}
|
|
try {
|
|
const url = new URL(value)
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
return 'Test URL must use http or https.'
|
|
}
|
|
} catch {
|
|
return 'Test URL must be a valid URL (e.g. "https://example.com").'
|
|
}
|
|
return null
|
|
}
|
|
case 'contentAutoUpdate.maxBytesPerWindow': {
|
|
// Per-window download budget in bytes. 0 = unlimited.
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num < 0) {
|
|
return 'The per-window data cap must be a whole number of bytes (0 = unlimited).'
|
|
}
|
|
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
|
|
// still has to be a real ladder rung, since a value the backend can't
|
|
// honour would silently fall back to the default and confuse the user.
|
|
if (value === '' || value === 'auto' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
const num = Number(value)
|
|
if (!CONTEXT_LADDER.includes(num as (typeof CONTEXT_LADDER)[number])) {
|
|
return `Context window must be "auto" or one of: ${CONTEXT_LADDER.join(', ')}.`
|
|
}
|
|
return null
|
|
}
|
|
case 'rag.minRelevance': {
|
|
// 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:
|
|
// Number(' ') is 0, which would otherwise validate as "floor off"
|
|
// for a value the parser reads as unset.
|
|
const raw = typeof value === 'string' ? value.trim() : value
|
|
if (raw === '' || raw === 'auto' || raw === undefined || raw === null) {
|
|
return null
|
|
}
|
|
const num = Number(raw)
|
|
if (!Number.isFinite(num) || num < 0 || num > 1) {
|
|
return 'Relevance threshold must be a number between 0 and 1, or empty to use the recommended default.'
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.responseStyle': {
|
|
// Empty/unset means 'auto'. Anything else has to be a style we know,
|
|
// since an unrecognised value silently resolves back to the default
|
|
// and would leave the select showing a choice nothing honours.
|
|
if (value === '' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
const styles = RESPONSE_STYLE_PRESETS.map((preset) => preset.value)
|
|
if (typeof value !== 'string' || !styles.includes(value as (typeof styles)[number])) {
|
|
return `Response style must be one of: ${styles.join(', ')}.`
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.keepAlive': {
|
|
// Ollama duration format: "10m", "1h", "30s", "-1" (forever), "0" (evict).
|
|
if (value === '' || value === undefined || value === null) {
|
|
return null
|
|
}
|
|
if (typeof value !== 'string' || !/^(-?\d+|\d+(\.\d+)?(ms|s|m|h))$/.test(value)) {
|
|
return 'Keep-alive must be a duration like "10m", "1h", or "-1" to keep the model loaded indefinitely.'
|
|
}
|
|
return null
|
|
}
|
|
case 'ai.amdHsaOverride': {
|
|
// Lands in the Ollama container env as HSA_OVERRIDE_GFX_VERSION, so only
|
|
// the forms DockerService._resolveAmdHsaOverride honours are accepted.
|
|
// Empty clears it and 'none' disables the gfx-marker default.
|
|
const raw = typeof value === 'string' ? value.trim().toLowerCase() : value
|
|
if (raw === '' || raw === undefined || raw === null || raw === 'none') {
|
|
return null
|
|
}
|
|
if (typeof raw !== 'string' || !/^\d{1,2}\.\d{1,2}\.\d{1,2}$/.test(raw)) {
|
|
return 'GFX override must be a version like "11.0.0", "none", or empty to use the detected default.'
|
|
}
|
|
return null
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
} |