From 2e7c7ea25aa0b85d424e0451f8f0b58011aeb8fa Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 25 Aug 2026 15:37:49 +0200 Subject: [PATCH 01/37] AISQLiteService --- lib/sqlite/AISQLiteService.js | 26 ++++++++++++++++++++++++++ package.json | 5 +++++ 2 files changed, 31 insertions(+) create mode 100644 lib/sqlite/AISQLiteService.js diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js new file mode 100644 index 0000000..0a86efc --- /dev/null +++ b/lib/sqlite/AISQLiteService.js @@ -0,0 +1,26 @@ +import SQLiteService from '@cap-js/sqlite' + +export default class AISQLiteService extends SQLiteService { + init() { + // add this.xyz here + return super.init() + } + + get factory() { + const factory = super.factory + factory._create = factory.create + factory.create = async (tenant) => { + const dbc = await factory._create(tenant) + // add dbc.xyz here + return dbc + } + return factory + } + + static CQN2SQL = class CQN2AISQLite extends SQLiteService.CQN2SQL { + // add cqn2sql stuff here + static Functions = { + ...SQLiteService.CQN2SQL.Functions + } + } +} diff --git a/package.json b/package.json index a16b543..75f404d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "vcap": { "label": "aicore" } + }, + "ai-sqlite": { + "kind": "sqlite", + "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", + "credentials": { "url": ":memory:" } } } } From b8aa8d4e9ec6e2c9fe533e9de862d2789d0b69b6 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 25 Aug 2026 15:54:58 +0200 Subject: [PATCH 02/37] prettier --- lib/sqlite/AISQLiteService.js | 20 ++++++++++---------- package.json | 4 +++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js index 0a86efc..caff1e4 100644 --- a/lib/sqlite/AISQLiteService.js +++ b/lib/sqlite/AISQLiteService.js @@ -1,26 +1,26 @@ -import SQLiteService from '@cap-js/sqlite' +import SQLiteService from '@cap-js/sqlite'; export default class AISQLiteService extends SQLiteService { init() { // add this.xyz here - return super.init() + return super.init(); } get factory() { - const factory = super.factory - factory._create = factory.create + const factory = super.factory; + factory._create = factory.create; factory.create = async (tenant) => { - const dbc = await factory._create(tenant) + const dbc = await factory._create(tenant); // add dbc.xyz here - return dbc - } - return factory + return dbc; + }; + return factory; } static CQN2SQL = class CQN2AISQLite extends SQLiteService.CQN2SQL { // add cqn2sql stuff here static Functions = { ...SQLiteService.CQN2SQL.Functions - } - } + }; + }; } diff --git a/package.json b/package.json index 75f404d..bfb70e5 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,9 @@ "ai-sqlite": { "kind": "sqlite", "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", - "credentials": { "url": ":memory:" } + "credentials": { + "url": ":memory:" + } } } } From bc0d0844e90a7032ff5c1600f2edb696fa070065 Mon Sep 17 00:00:00 2001 From: Vitaly Kozyura <58591662+vkozyura@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:03:25 +0200 Subject: [PATCH 03/37] feat: sync wrapper for ONNX embeddings function (#46) * Sync wrapper for Sqlite for using ONNX embeddings function * fix imple and add tests * add semantic tests * use LOG * test 4 params * small fixes * fix: address PR bot comments - add division by zero guard and fix lint errors * chore: run prettier formatting * fix tests * dix duplicated function registration * more frixes * export vector_embedding directly * rem unused * refactor * refactor * linter * remove comment * Update CHANGELOG.md * Update README.md * export embeddings * fix: add missing exports paths for CDS plugin loading The exports field was blocking CDS from loading: - cds-plugin.js (plugin registration) - srv/* (AICoreService, MockAICoreService) - lib/* (internal modules) Without these exports, Node.js blocks access to these paths, causing "Navigation property SAP_Recommendations is not defined" errors because the CSN enhancement never registers. * fix: include cds-plugin.js in npm package files Without this, npm pack excludes cds-plugin.js from the tarball, breaking plugin auto-registration when installed as a dependency. This caused MTX integration tests to fail with 'ResourceGroup undefined' because the plugin never loaded. * feat: integrate embeddings with ai-sqlite * fix: harden local embedding runtime --------- Co-authored-by: Sebastian Van Syckel --- CHANGELOG.md | 12 ++ README.md | 53 +++++ lib/sqlite/AISQLiteService.js | 15 +- lib/vector_embedding/InferenceSession.js | 93 +++++++++ lib/vector_embedding/embedding.js | 210 ++++++++++++++++++++ lib/vector_embedding/index.js | 52 +++++ lib/vector_embedding/model-utils.js | 241 +++++++++++++++++++++++ package.json | 11 +- tests/vector-unit.test.js | 133 +++++++++++++ tests/vector.test.js | 153 ++++++++++++++ 10 files changed, 966 insertions(+), 7 deletions(-) create mode 100644 lib/vector_embedding/InferenceSession.js create mode 100644 lib/vector_embedding/embedding.js create mode 100644 lib/vector_embedding/index.js create mode 100644 lib/vector_embedding/model-utils.js create mode 100644 tests/vector-unit.test.js create mode 100644 tests/vector.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 55fc7eb..ffee0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ - The format is based on [Keep a Changelog](https://keepachangelog.com/). - This project adheres to [Semantic Versioning](https://semver.org/). +## Version 1.2.0 - tbd + +### Added + +- Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using ONNX Runtime and the `Xenova/all-MiniLM-L6-v2` model (384 dimensions) + - Downloads the pinned model revision on-demand from Hugging Face (~91MB), verifies its size and SHA-256, and caches it locally + - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` + - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions + - Synchronous execution suitable for SQLite user-defined functions + - **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios + + ## Version 1.1.0 - 2026-07-20 diff --git a/README.md b/README.md index 7a2de53..d572b53 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,59 @@ resources: type: org.cloudfoundry.managed-service ``` +### 3. Local Vector Embeddings with SQLite + +The `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX model. + +#### Usage + +Install the optional runtime dependencies: + +```sh +npm add @cap-js/sqlite onnxruntime-node@1.20.1 +``` + +`ai-sqlite` currently requires exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. + +Select `ai-sqlite` for the database service: + +```json +{ + "cds": { + "requires": { + "db": "ai-sqlite" + } + } +} +``` + +The HANA-compatible SQL function can then be used in CQL: + +```js +SELECT.from('Books').columns` + VECTOR_EMBEDDING(title, 'DOCUMENT', 'SAP_GXY.20250407') as embedding +`; +``` + +**Parameters:** +- `text` - Text to embed (`NULL` remains `NULL`; empty text returns a zero vector) +- `text_type` - Type of text, e.g., `'DOCUMENT'` (currently informational) +- `model_and_version` - Model identifier, e.g., `'SAP_GXY.20250407'` or `'SAP_GXY.20240715'` + +**Returns:** +- JSON stringified array of embedding values (384 dimensions) + +**Features:** +- **Initialization**: The ONNX model is loaded when the `ai-sqlite` service starts +- **Verified cache**: The pinned model revision is cached by default below the user's data directory; set `CDS_AI_MODEL_CACHE` to use a pre-provisioned cache root +- **Deterministic**: Same input always produces same output +- **Normalized vectors**: All embeddings are L2-normalized +- **Semantic similarity**: Embeddings capture text meaning for similarity search + +**Error Handling:** +- Starting `ai-sqlite` fails if the ONNX model cannot be initialized +- Downloads are time-limited and accepted only when their expected size and SHA-256 match +- Throws if embedding generation fails ## Test the plugin locally diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js index caff1e4..120476c 100644 --- a/lib/sqlite/AISQLiteService.js +++ b/lib/sqlite/AISQLiteService.js @@ -1,17 +1,22 @@ import SQLiteService from '@cap-js/sqlite'; +import { initializeEmbedding, vector_embedding } from '../vector_embedding/index.js'; export default class AISQLiteService extends SQLiteService { - init() { - // add this.xyz here + async init() { + await initializeEmbedding(); return super.init(); } get factory() { const factory = super.factory; - factory._create = factory.create; + const create = factory.create; factory.create = async (tenant) => { - const dbc = await factory._create(tenant); - // add dbc.xyz here + const dbc = await create(tenant); + const embedding = (input, textType, modelAndVersion) => + input == null ? null : vector_embedding(String(input), textType, modelAndVersion); + const deterministic = { deterministic: true }; + dbc.function('VECTOR_EMBEDDING', { ...deterministic, varargs: true }, embedding); + dbc.function('VECTOR_EMBEDDING', deterministic, embedding); return dbc; }; return factory; diff --git a/lib/vector_embedding/InferenceSession.js b/lib/vector_embedding/InferenceSession.js new file mode 100644 index 0000000..0ea71be --- /dev/null +++ b/lib/vector_embedding/InferenceSession.js @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Synchronous counterpart to onnxruntime-node's session handler. SQLite user +// defined functions cannot await the public asynchronous InferenceSession API. +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const SUPPORTED_ONNX_RUNTIME_VERSION = '1.20.1'; +const runtimeVersion = require('onnxruntime-node/package.json').version; + +if (runtimeVersion !== SUPPORTED_ONNX_RUNTIME_VERSION) { + throw new Error( + `Unsupported onnxruntime-node version ${runtimeVersion}; @cap-js/ai requires ${SUPPORTED_ONNX_RUNTIME_VERSION} because its synchronous SQLite integration uses the runtime's private native API.` + ); +} + +const ort = require('onnxruntime-node'); +const binding = require('onnxruntime-node/dist/binding.js').binding; + +class InferenceSession { + constructor(handler) { + this.handler = handler; + } + + run(feeds) { + if ( + typeof feeds !== 'object' || + feeds === null || + feeds instanceof ort.Tensor || + Array.isArray(feeds) + ) { + throw new TypeError( + "'feeds' must be an object that uses input names as keys and tensors as values." + ); + } + + for (const name of this.handler.inputNames) { + if (feeds[name] === undefined) throw new Error(`input '${name}' is missing in 'feeds'.`); + } + + const fetches = Object.fromEntries(this.handler.outputNames.map((name) => [name, null])); + const results = this.handler.run(feeds, fetches, {}); + const output = {}; + + for (const key in results) { + const result = results[key]; + output[key] = + result instanceof ort.Tensor + ? result + : new ort.Tensor(result.type, result.data, result.dims); + } + + return output; + } + + static async create(pathOrBuffer) { + if (typeof pathOrBuffer !== 'string' && !(pathOrBuffer instanceof Uint8Array)) { + throw new TypeError('Expected an ONNX model path or Uint8Array'); + } + return new InferenceSession(new SynchronousSessionHandler(pathOrBuffer)); + } +} + +class SynchronousSessionHandler { + constructor(pathOrBuffer) { + this.session = new binding.InferenceSession(); + if (typeof pathOrBuffer === 'string') { + this.session.loadModel(pathOrBuffer, {}); + } else { + this.session.loadModel( + pathOrBuffer.buffer, + pathOrBuffer.byteOffset, + pathOrBuffer.byteLength, + {} + ); + } + this.inputNames = this.session.inputNames; + this.outputNames = this.session.outputNames; + } + + run(feeds, fetches, options) { + return this.session.run(feeds, fetches, options); + } + + async dispose() { + this.session.dispose(); + } +} + +const { Tensor } = ort; + +export { InferenceSession, Tensor }; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js new file mode 100644 index 0000000..cb35081 --- /dev/null +++ b/lib/vector_embedding/embedding.js @@ -0,0 +1,210 @@ +import os from 'os'; +import path from 'path'; +import { Tensor } from './InferenceSession.js'; +import { + downloadModelIfNeeded, + loadModelAndTokenizer, + preTokenize, + wordPieceTokenize, + validateTokenIds +} from './model-utils.js'; + +const MODEL = { + repository: 'Xenova/all-MiniLM-L6-v2', + revision: '751bff37182d3f1213fa05d7196b954e230abad9', + files: [ + { + name: 'model.onnx', + path: 'onnx/model.onnx', + size: 90387606, + sha256: '759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e' + }, + { + name: 'tokenizer.json', + path: 'tokenizer.json', + size: 711661, + sha256: 'da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0' + } + ] +}; + +/** + * Main tokenization function that combines all steps + */ +function wordPieceTokenizer(text, tokenizer) { + const unkToken = '[UNK]'; + const clsToken = '[CLS]'; + const sepToken = '[SEP]'; + const { vocab, maxLength, normalizer } = tokenizer; + + const clsId = vocab.get(clsToken) ?? 101; + const sepId = vocab.get(sepToken) ?? 102; + const unkId = vocab.get(unkToken) ?? 100; + + if (typeof clsId !== 'number' || typeof sepId !== 'number' || typeof unkId !== 'number') { + throw new Error('Special tokens must have numeric IDs'); + } + + const preTokens = preTokenize(text, normalizer); + + const tokens = [clsToken]; + const ids = [clsId]; + + for (const preToken of preTokens) { + const wordPieceTokens = wordPieceTokenize(preToken, vocab, unkToken); + + for (const wpToken of wordPieceTokens) { + const tokenId = vocab.get(wpToken) ?? unkId; + tokens.push(wpToken); + ids.push(tokenId); + } + } + + tokens.push(sepToken); + ids.push(sepId); + + if (tokens.length <= maxLength) return [{ tokens, ids }]; + + // Keep each chunk within the limit embedded in the pinned tokenizer. + const maxContentLength = maxLength - 2; + const chunks = []; + const contentTokens = tokens.slice(1, -1); + const contentIds = ids.slice(1, -1); + + for (let i = 0; i < contentTokens.length; i += maxContentLength) { + const chunkTokens = [clsToken, ...contentTokens.slice(i, i + maxContentLength), sepToken]; + const chunkIds = [clsId, ...contentIds.slice(i, i + maxContentLength), sepId]; + + chunks.push({ + tokens: chunkTokens, + ids: chunkIds + }); + } + + return chunks; +} + +/** + * Process embeddings for multiple chunks and combine them + */ +function processChunkedEmbeddings(chunks, session) { + const embeddings = []; + + for (const chunk of chunks) { + const { ids } = chunk; + const validIds = validateTokenIds(ids); + + const inputIds = new BigInt64Array(validIds.map((i) => BigInt(i))); + const attentionMask = new BigInt64Array(validIds.length).fill(BigInt(1)); + const tokenTypeIds = new BigInt64Array(validIds.length).fill(BigInt(0)); + + const inputTensor = new Tensor('int64', inputIds, [1, validIds.length]); + const attentionTensor = new Tensor('int64', attentionMask, [1, validIds.length]); + const tokenTypeTensor = new Tensor('int64', tokenTypeIds, [1, validIds.length]); + + const feeds = { + input_ids: inputTensor, + attention_mask: attentionTensor, + token_type_ids: tokenTypeTensor + }; + + const results = session.run(feeds); + const lastHiddenState = results['last_hidden_state']; + if (!lastHiddenState) + throw new Error( + `ONNX model output 'last_hidden_state' not found. Available outputs: ${Object.keys(results).join(', ')}` + ); + const [, sequenceLength, hiddenSize] = lastHiddenState.dims; + const embeddingData = lastHiddenState.data; + + // Apply mean pooling across the sequence dimension + const pooledEmbedding = new Float32Array(hiddenSize); + for (let i = 0; i < hiddenSize; i++) { + let sum = 0; + for (let j = 0; j < sequenceLength; j++) { + sum += embeddingData[j * hiddenSize + i]; + } + pooledEmbedding[i] = sum / sequenceLength; + } + + embeddings.push(pooledEmbedding); + } + + // If multiple chunks, average the embeddings + if (embeddings.length === 1) return embeddings[0]; + + const hiddenSize = embeddings[0].length; + const avgEmbedding = new Float32Array(hiddenSize); + + for (let i = 0; i < hiddenSize; i++) { + let sum = 0; + for (const embedding of embeddings) { + sum += embedding[i]; + } + avgEmbedding[i] = sum / embeddings.length; + } + + return avgEmbedding; +} + +let session = null; +let tokenizer = null; + +async function createSession() { + const modelDir = getModelDir(); + await downloadModelIfNeeded(modelDir, MODEL); + ({ session, tokenizer } = await loadModelAndTokenizer(modelDir)); +} + +function embedding(text) { + if (!session || !tokenizer) + throw new Error( + 'Embedding session not initialized. Call createSession() before using embedding().' + ); + const chunks = wordPieceTokenizer(text, tokenizer); + const vector = normalizeEmbedding(processChunkedEmbeddings(chunks, session)); + + const chunkObj = { content: text }; + return Object.defineProperty(chunkObj, 'embedding', { + value: vector, + writable: true, + configurable: true, + enumerable: false + }); + + function normalizeEmbedding(embedding) { + let norm = 0; + for (let i = 0; i < embedding.length; i++) { + norm += embedding[i] * embedding[i]; + } + norm = Math.sqrt(norm); + if (norm === 0) return embedding; // Guard against division by zero + for (let i = 0; i < embedding.length; i++) { + embedding[i] = embedding[i] / norm; + } + return embedding; + } +} + +/** + * Get the platform-specific data directory for the application + * @param {string} appName - The application name (defaults to 'semantic-search') + * @returns {string} The full path to the data directory + */ +function getDataDir(appName = 'semantic-search') { + const home = os.homedir(); + const dir = + os.platform() === 'win32' + ? process.env.LOCALAPPDATA || process.env.APPDATA || path.join(home, 'AppData', 'Local') + : process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'); + + return path.join(dir, appName); +} + +function getModelDir() { + const cacheRoot = process.env.CDS_AI_MODEL_CACHE || path.join(getDataDir(), 'models'); + return path.join(cacheRoot, MODEL.repository.replace('/', '_'), MODEL.revision); +} + +export default embedding; +export { embedding, createSession, wordPieceTokenizer }; diff --git a/lib/vector_embedding/index.js b/lib/vector_embedding/index.js new file mode 100644 index 0000000..edfa0b1 --- /dev/null +++ b/lib/vector_embedding/index.js @@ -0,0 +1,52 @@ +import cds from '@sap/cds'; + +const LOG = cds.log('@cap-js/ai'); + +let embeddingModule; +let initialization; + +async function initializeEmbedding() { + if (embeddingModule) return embeddingModule; + + initialization ??= import('./embedding.js') + .then(async (module) => { + await module.createSession(); + LOG.info('Vector embedding ONNX model initialized'); + return (embeddingModule = module); + }) + .catch((error) => { + initialization = undefined; + throw error; + }); + + return initialization; +} + +const model_dimensions = { + 'SAP_GXY.20250407': 384, + 'SAP_GXY.20240715': 384 +}; + +/** + * Synchronous wrapper for vector embedding function. + * Generates embeddings using ONNX model. + * The model is initialized automatically when this module is imported. + * + * @param {string} text - Text to embed + * @param {string} text_type - Type of text (e.g., 'DOCUMENT') + * @param {string} model_and_version - Model identifier (e.g., 'SAP_GXY.20250407') + * @returns {string} JSON stringified array of embedding values + * @throws {Error} If embedding module failed to initialize or generation fails + */ +function vector_embedding(text, text_type, model_and_version) { + if (!embeddingModule) { + throw new Error('Embedding module is not initialized'); + } + + if (text) { + return JSON.stringify(Array.from(embeddingModule.embedding(text).embedding)); + } + return JSON.stringify(new Array(model_dimensions[model_and_version] ?? 384).fill(0)); +} + +export { initializeEmbedding, vector_embedding }; diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js new file mode 100644 index 0000000..da2bb61 --- /dev/null +++ b/lib/vector_embedding/model-utils.js @@ -0,0 +1,241 @@ +import { createHash, randomUUID } from 'crypto'; +import { createReadStream } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; +import { InferenceSession } from './InferenceSession.js'; + +const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; + +async function sha256(filePath) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) hash.update(chunk); + return hash.digest('hex'); +} + +async function isValidFile(filePath, file) { + try { + const stat = await fs.stat(filePath); + return stat.isFile() && stat.size === file.size && (await sha256(filePath)) === file.sha256; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +async function downloadFile(url, outputPath, file, options = {}) { + const { fetchImpl = globalThis.fetch, timeoutMs = DOWNLOAD_TIMEOUT_MS } = options; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const temporaryPath = `${outputPath}.${process.pid}.${randomUUID()}.tmp`; + let handle; + + try { + const response = await fetchImpl(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error( + `Failed to download ${url}, status ${response.status} (${response.statusText})` + ); + } + if (!response.body) throw new Error(`Failed to download ${url}: response has no body`); + + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > file.size) { + throw new Error(`Refusing ${url}: response exceeds the expected ${file.size} bytes`); + } + + handle = await fs.open(temporaryPath, 'wx', 0o600); + const hash = createHash('sha256'); + let bytesWritten = 0; + + for await (const value of response.body) { + const chunk = Buffer.from(value); + bytesWritten += chunk.byteLength; + if (bytesWritten > file.size) { + throw new Error(`Refusing ${url}: response exceeds the expected ${file.size} bytes`); + } + hash.update(chunk); + await handle.writeFile(chunk); + } + + await handle.sync(); + await handle.close(); + handle = undefined; + + if (bytesWritten !== file.size) { + throw new Error(`Invalid size for ${url}: expected ${file.size}, received ${bytesWritten}`); + } + const digest = hash.digest('hex'); + if (digest !== file.sha256) { + throw new Error(`Invalid SHA-256 for ${url}: expected ${file.sha256}, received ${digest}`); + } + + try { + await fs.rename(temporaryPath, outputPath); + } catch (error) { + if (error.code !== 'EEXIST' && error.code !== 'EPERM') throw error; + if (await isValidFile(outputPath, file)) await fs.unlink(temporaryPath); + else { + await fs.unlink(outputPath).catch(() => {}); + await fs.rename(temporaryPath, outputPath); + } + } + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Timed out after ${timeoutMs} ms while downloading ${url}`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timeout); + await handle?.close().catch(() => {}); + await fs.unlink(temporaryPath).catch(() => {}); + } +} + +async function downloadModelIfNeeded(modelDir, model, options) { + await fs.mkdir(modelDir, { recursive: true }); + + for (const file of model.files) { + const filePath = path.join(modelDir, file.name); + // eslint-disable-next-line no-await-in-loop + if (await isValidFile(filePath, file)) continue; + + const url = `https://huggingface.co/${model.repository}/resolve/${model.revision}/${file.path}`; + // Files are downloaded serially to avoid multiplying startup bandwidth and memory usage. + // eslint-disable-next-line no-await-in-loop + await downloadFile(url, filePath, file, options); + } +} + +async function loadModelAndTokenizer(modelDir) { + const modelPath = path.join(modelDir, 'model.onnx'); + const tokenizerPath = path.join(modelDir, 'tokenizer.json'); + const tokenizerJson = JSON.parse(await fs.readFile(tokenizerPath, 'utf8')); + + if (!tokenizerJson.model?.vocab) { + throw new Error('Invalid tokenizer structure: missing model.vocab'); + } + + const vocab = new Map(); + for (const [token, id] of Object.entries(tokenizerJson.model.vocab)) { + if (Number.isSafeInteger(id) && id >= 0) vocab.set(token, id); + } + + const maxLength = tokenizerJson.truncation?.max_length; + if (!Number.isSafeInteger(maxLength) || maxLength < 2) { + throw new Error('Invalid tokenizer structure: missing truncation.max_length'); + } + + const session = await InferenceSession.create(modelPath); + return { + session, + tokenizer: { + vocab, + maxLength, + normalizer: tokenizerJson.normalizer ?? {} + } + }; +} + +function preTokenize(text, normalizer = {}) { + const { + clean_text: cleanText = true, + handle_chinese_chars: handleChineseChars = true, + lowercase = true, + strip_accents: configuredStripAccents + } = normalizer; + const stripAccents = configuredStripAccents ?? lowercase; + let normalized = String(text); + + if (cleanText) { + normalized = Array.from(normalized, (character) => { + if (/\s/u.test(character)) return ' '; + if (character.codePointAt(0) === 0 || character.codePointAt(0) === 0xfffd) return ''; + if (/[\p{Cc}\p{Cf}]/u.test(character)) return ''; + return character; + }).join(''); + } + + if (handleChineseChars) { + normalized = Array.from(normalized, (character) => + isChineseCharacter(character.codePointAt(0)) ? ` ${character} ` : character + ).join(''); + } + + const output = []; + for (let token of normalized.trim().split(/\s+/u)) { + if (!token) continue; + if (lowercase) token = token.toLowerCase(); + if (stripAccents) token = token.normalize('NFD').replace(/\p{M}/gu, ''); + + let current = ''; + for (const character of token) { + if (/\p{P}/u.test(character)) { + if (current) output.push(current); + output.push(character); + current = ''; + } else current += character; + } + if (current) output.push(current); + } + return output; +} + +function isChineseCharacter(codePoint) { + return ( + (codePoint >= 0x4e00 && codePoint <= 0x9fff) || + (codePoint >= 0x3400 && codePoint <= 0x4dbf) || + (codePoint >= 0x20000 && codePoint <= 0x2a6df) || + (codePoint >= 0x2a700 && codePoint <= 0x2b73f) || + (codePoint >= 0x2b740 && codePoint <= 0x2b81f) || + (codePoint >= 0x2b820 && codePoint <= 0x2ceaf) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0x2f800 && codePoint <= 0x2fa1f) + ); +} + +function wordPieceTokenize(token, vocab, unkToken = '[UNK]', maxInputCharsPerWord = 100) { + if (Array.from(token).length > maxInputCharsPerWord) return [unkToken]; + + const outputTokens = []; + let start = 0; + while (start < token.length) { + let end = token.length; + let currentSubstring = null; + + while (start < end) { + let substring = token.substring(start, end); + if (start > 0) substring = '##' + substring; + if (vocab.has(substring)) { + currentSubstring = substring; + break; + } + end -= 1; + } + + if (currentSubstring === null) return [unkToken]; + + outputTokens.push(currentSubstring); + start = end; + } + + return outputTokens; +} + +function validateTokenIds(ids) { + ids.forEach((id) => { + if (!Number.isSafeInteger(id) || id < 0) { + throw new Error(`Invalid token ID detected: ${id} (type: ${typeof id})`); + } + }); + return ids; +} + +export { + downloadFile, + downloadModelIfNeeded, + isValidFile, + loadModelAndTokenizer, + preTokenize, + wordPieceTokenize, + validateTokenIds +}; diff --git a/package.json b/package.json index bfb70e5..8df0da8 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,17 @@ ], "devDependencies": { "@cap-js/cds-test": "^1", - "@cap-js/cds-types": "^0.16.0" + "@cap-js/cds-types": "^0.16.0", + "onnxruntime-node": "1.20.1" }, "peerDependencies": { - "@sap/cds": ">=9" + "@sap/cds": ">=9", + "onnxruntime-node": "1.20.1" + }, + "peerDependenciesMeta": { + "onnxruntime-node": { + "optional": true + } }, "engines": { "node": ">=20.0.0" diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js new file mode 100644 index 0000000..76e30f7 --- /dev/null +++ b/tests/vector-unit.test.js @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { wordPieceTokenizer } from '../lib/vector_embedding/embedding.js'; +import { downloadModelIfNeeded } from '../lib/vector_embedding/model-utils.js'; + +const temporaryDirectories = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })) + ); +}); + +describe('BERT tokenizer', () => { + const tokenizer = { + maxLength: 128, + normalizer: { + clean_text: true, + handle_chinese_chars: true, + lowercase: true, + strip_accents: null + }, + vocab: new Map([ + ['[UNK]', 100], + ['[CLS]', 101], + ['[SEP]', 102], + ['hello', 200], + [',', 201], + ['cafe', 202], + ['中', 203], + ['文', 204], + ['token', 205] + ]) + }; + + test('applies BERT accent, punctuation, and Chinese character normalization', () => { + const [chunk] = wordPieceTokenizer('Héllo, café中文', tokenizer); + + assert.deepEqual(chunk.tokens, ['[CLS]', 'hello', ',', 'cafe', '中', '文', '[SEP]']); + assert.deepEqual(chunk.ids, [101, 200, 201, 202, 203, 204, 102]); + }); + + test('uses the tokenizer model limit without an off-by-one', () => { + const chunks = wordPieceTokenizer(new Array(130).fill('token').join(' '), tokenizer); + + assert.deepEqual( + chunks.map(({ ids }) => ids.length), + [128, 6] + ); + assert.ok(chunks.every(({ ids }) => ids.length <= tokenizer.maxLength)); + }); +}); + +describe('model download', () => { + test('uses a pinned revision and atomically caches a verified file', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('verified model fixture'); + const model = fixtureModel(content); + const requestedUrls = []; + const fetchImpl = async (url) => { + requestedUrls.push(url); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(content.subarray(0, 5)); + controller.enqueue(content.subarray(5)); + controller.close(); + } + }); + return new Response(body, { + headers: { 'content-length': String(content.length) } + }); + }; + + await downloadModelIfNeeded(directory, model, { fetchImpl }); + await downloadModelIfNeeded(directory, model, { fetchImpl }); + + assert.deepEqual(requestedUrls, [ + 'https://huggingface.co/example/model/resolve/deadbeef/model.onnx' + ]); + assert.deepEqual(await fs.readFile(path.join(directory, 'model.onnx')), content); + assert.deepEqual(await fs.readdir(directory), ['model.onnx']); + }); + + test('rejects oversized content without exposing a partial cache file', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('expected'); + const model = fixtureModel(content); + const fetchImpl = async () => new Response(Buffer.concat([content, Buffer.from('extra')])); + + await assert.rejects( + downloadModelIfNeeded(directory, model, { fetchImpl }), + /exceeds the expected 8 bytes/ + ); + assert.deepEqual(await fs.readdir(directory), []); + }); + + test('rejects content that does not match the pinned checksum', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('expected'); + const model = fixtureModel(content); + const fetchImpl = async () => new Response(Buffer.from('tampered')); + + await assert.rejects(downloadModelIfNeeded(directory, model, { fetchImpl }), /Invalid SHA-256/); + assert.deepEqual(await fs.readdir(directory), []); + }); +}); + +async function createTemporaryDirectory() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'cap-ai-model-')); + temporaryDirectories.push(directory); + return directory; +} + +function fixtureModel(content) { + return { + repository: 'example/model', + revision: 'deadbeef', + files: [ + { + name: 'model.onnx', + path: 'model.onnx', + size: content.length, + sha256: createHash('sha256').update(content).digest('hex') + } + ] + }; +} diff --git a/tests/vector.test.js b/tests/vector.test.js new file mode 100644 index 0000000..0c20ab3 --- /dev/null +++ b/tests/vector.test.js @@ -0,0 +1,153 @@ +import { after, before, describe, test } from 'node:test'; +import assert from 'node:assert'; +import cds from '@sap/cds'; +import { initializeEmbedding, vector_embedding } from '../lib/vector_embedding/index.js'; + +before(initializeEmbedding); + +describe('Vector embedding function (standalone)', () => { + describe('vector_embedding', () => { + test('computes embedding with ONNX model', async () => { + const result = vector_embedding('Hello world', 'DOCUMENT', 'SAP_GXY.20250407'); + + const embedding = JSON.parse(result); + assert.ok(Array.isArray(embedding), 'Embedding should be an array'); + assert.strictEqual(embedding.length, 384, 'Embedding should have 384 dimensions'); + + // Check that values are floats in reasonable range + embedding.forEach((val, idx) => { + assert.strictEqual(typeof val, 'number', `Value at index ${idx} should be a number`); + assert.ok(Math.abs(val) <= 1, `Value at index ${idx} should be normalized (-1 to 1)`); + }); + }); + + test('deterministic - same input produces same output', async () => { + const e1 = vector_embedding('test text', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('test text', 'DOCUMENT', 'SAP_GXY.20250407'); + + assert.strictEqual(e1, e2, 'Same input should produce identical embeddings'); + }); + + test('different inputs produce different outputs', async () => { + const e1 = vector_embedding('hello world', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407'); + + assert.notStrictEqual(e1, e2, 'Different inputs should produce different embeddings'); + }); + + test('semantically similar sentences produce similar vectors', async () => { + const e1 = vector_embedding('I love programming', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('I enjoy coding', 'DOCUMENT', 'SAP_GXY.20250407'); + + const v1 = JSON.parse(e1); + const v2 = JSON.parse(e2); + + const similarity = cosineSimilarity(v1, v2); + assert.ok( + similarity > 0.8, + `Semantically similar sentences should have high cosine similarity (got ${similarity.toFixed(3)})` + ); + }); + + test('semantically different sentences are far apart in vector space', async () => { + const e1 = vector_embedding('The cat sat on the mat', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('Quantum physics is fascinating', 'DOCUMENT', 'SAP_GXY.20250407'); + + const v1 = JSON.parse(e1); + const v2 = JSON.parse(e2); + + const similarity = cosineSimilarity(v1, v2); + assert.ok( + similarity < 0.1, + `Semantically different sentences should have low cosine similarity (got ${similarity.toFixed(3)})` + ); + }); + + test('handles empty text', async () => { + const result = vector_embedding('', 'DOCUMENT', 'SAP_GXY.20250407'); + + const embedding = JSON.parse(result); + assert.ok(Array.isArray(embedding), 'Empty text should return zero vector'); + assert.strictEqual(embedding.length, 384, 'Should have 384 dimensions'); + assert.ok( + embedding.every((v) => v === 0), + 'Empty text should return all zeros' + ); + }); + + test('handles null text', async () => { + const result = vector_embedding(null, 'DOCUMENT', 'SAP_GXY.20250407'); + + const embedding = JSON.parse(result); + assert.ok(Array.isArray(embedding), 'Null text should return zero vector'); + assert.strictEqual(embedding.length, 384, 'Should have 384 dimensions'); + assert.ok( + embedding.every((v) => v === 0), + 'Null text should return all zeros' + ); + }); + + test('uses correct dimensions for different models', async () => { + const result1 = vector_embedding('test', 'DOCUMENT', 'SAP_GXY.20250407'); + const embedding1 = JSON.parse(result1); + assert.strictEqual(embedding1.length, 384, 'SAP_GXY.20250407 should have 384 dimensions'); + + const result2 = vector_embedding('test', 'DOCUMENT', 'SAP_GXY.20240715'); + const embedding2 = JSON.parse(result2); + assert.strictEqual(embedding2.length, 384, 'SAP_GXY.20240715 should have 384 dimensions'); + + const result3 = vector_embedding('test', 'DOCUMENT', 'unknown_model'); + const embedding3 = JSON.parse(result3); + assert.strictEqual(embedding3.length, 384, 'Unknown model should default to 384 dimensions'); + }); + }); +}); + +describe('ai-sqlite integration', () => { + let db; + + before(async () => { + db = await cds.connect.to('vector-db', { + kind: 'ai-sqlite', + credentials: { url: ':memory:' } + }); + }); + + after(async () => { + await db?.disconnect(); + }); + + test('registers VECTOR_EMBEDDING for three and four arguments', async () => { + const [row] = await db.run(`SELECT + VECTOR_EMBEDDING('Hello world', 'DOCUMENT', 'SAP_GXY.20250407') AS local, + VECTOR_EMBEDDING('Hello world', 'DOCUMENT', 'SAP_GXY.20250407', 'remote') AS remote`); + + assert.strictEqual(JSON.parse(row.local).length, 384); + assert.strictEqual(row.remote, row.local); + }); + + test('preserves SQL null semantics', async () => { + const [row] = await db.run( + `SELECT VECTOR_EMBEDDING(NULL, 'DOCUMENT', 'SAP_GXY.20250407') AS embedding` + ); + + assert.strictEqual(row.embedding, null); + }); +}); + +// Helper function to calculate cosine similarity between two vectors +function cosineSimilarity(a, b) { + if (a.length !== b.length) throw new Error('Vectors must have the same length'); + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); +} From 8f8399b83beedb45a294d9c8a90d86b888002464 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 15:43:18 +0200 Subject: [PATCH 04/37] chore: address ai-sqlite review feedback --- CHANGELOG.md | 2 +- README.md | 2 +- package.json | 5 +++++ tests/vector.test.js | 1 - 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffee0b7..882ebad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using ONNX Runtime and the `Xenova/all-MiniLM-L6-v2` model (384 dimensions) +- **Beta:** Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using ONNX Runtime and the `Xenova/all-MiniLM-L6-v2` model (384 dimensions) - Downloads the pinned model revision on-demand from Hugging Face (~91MB), verifies its size and SHA-256, and caches it locally - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions diff --git a/README.md b/README.md index d572b53..dc21231 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ resources: ### 3. Local Vector Embeddings with SQLite -The `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX model. +The beta `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX model. #### Usage diff --git a/package.json b/package.json index 8df0da8..0bce33e 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,18 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", + "@cap-js/sqlite": ">=2", "onnxruntime-node": "1.20.1" }, "peerDependencies": { + "@cap-js/sqlite": ">=2", "@sap/cds": ">=9", "onnxruntime-node": "1.20.1" }, "peerDependenciesMeta": { + "@cap-js/sqlite": { + "optional": true + }, "onnxruntime-node": { "optional": true } diff --git a/tests/vector.test.js b/tests/vector.test.js index 0c20ab3..20898b9 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -12,7 +12,6 @@ describe('Vector embedding function (standalone)', () => { const embedding = JSON.parse(result); assert.ok(Array.isArray(embedding), 'Embedding should be an array'); - assert.strictEqual(embedding.length, 384, 'Embedding should have 384 dimensions'); // Check that values are floats in reasonable range embedding.forEach((val, idx) => { From 14e8fd62ba8cf7ec6bd9f8a1c05fc1799fed299a Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 15:48:18 +0200 Subject: [PATCH 05/37] test: use CDS 9-compatible sqlite version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0bce33e..9b24be7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", - "@cap-js/sqlite": ">=2", + "@cap-js/sqlite": "2.1.3", "onnxruntime-node": "1.20.1" }, "peerDependencies": { From 1dc7a88fc95e1af4cf3bd5b3688fab9a563c5115 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 15:49:54 +0200 Subject: [PATCH 06/37] fix: keep sqlite as an optional peer --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 9b24be7..555ffdf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", - "@cap-js/sqlite": "2.1.3", "onnxruntime-node": "1.20.1" }, "peerDependencies": { From 7b407bc232d2eddb1e75bd1d365db41d8effdf57 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 15:52:44 +0200 Subject: [PATCH 07/37] ci: align sqlite peer for CDS 9 matrix --- .github/actions/integration-tests/action.yml | 1 + .github/workflows/test.yml | 1 + package.json | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 63c8feb..4fd4f2e 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -73,6 +73,7 @@ runs: if: inputs.CDS_VERSION == '9' shell: bash run: | + npm pkg set "peerDependencies.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/hana=^2" npm pkg set "overrides.@sap/cds-mtxs=^3" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8e98b1..6a52d32 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,7 @@ jobs: - name: Pin CDS 9 compatible stack if: matrix.cds-version == '9' run: | + npm pkg set "peerDependencies.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/hana=^2" npm pkg set "overrides.@sap/cds-mtxs=^3" diff --git a/package.json b/package.json index 555ffdf..8d5bd9a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "onnxruntime-node": "1.20.1" }, "peerDependencies": { - "@cap-js/sqlite": ">=2", + "@cap-js/sqlite": "^2 || ^3", "@sap/cds": ">=9", "onnxruntime-node": "1.20.1" }, From d92e446836fbc3736dfbe3037a58f557448140e9 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 15:54:23 +0200 Subject: [PATCH 08/37] fix: align sqlite peer with CDS 9 override --- .github/actions/integration-tests/action.yml | 1 - .github/workflows/test.yml | 1 - package.json | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 4fd4f2e..63c8feb 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -73,7 +73,6 @@ runs: if: inputs.CDS_VERSION == '9' shell: bash run: | - npm pkg set "peerDependencies.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/hana=^2" npm pkg set "overrides.@sap/cds-mtxs=^3" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a52d32..d8e98b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,6 @@ jobs: - name: Pin CDS 9 compatible stack if: matrix.cds-version == '9' run: | - npm pkg set "peerDependencies.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/hana=^2" npm pkg set "overrides.@sap/cds-mtxs=^3" diff --git a/package.json b/package.json index 8d5bd9a..72e0732 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "onnxruntime-node": "1.20.1" }, "peerDependencies": { - "@cap-js/sqlite": "^2 || ^3", + "@cap-js/sqlite": "^2", "@sap/cds": ">=9", "onnxruntime-node": "1.20.1" }, From a69ca7a06b3dae27bd8eca8535a44f43bfdee883 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 15:59:56 +0200 Subject: [PATCH 09/37] fix: install supported sqlite version for development --- README.md | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dc21231..344bfe6 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ The beta `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic Install the optional runtime dependencies: ```sh -npm add @cap-js/sqlite onnxruntime-node@1.20.1 +npm add @cap-js/sqlite@2 onnxruntime-node@1.20.1 ``` `ai-sqlite` currently requires exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. diff --git a/package.json b/package.json index 72e0732..6a8c543 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", + "@cap-js/sqlite": "^2", "onnxruntime-node": "1.20.1" }, "peerDependencies": { From b037f45121ac05a98ba785d93119a35f666ac073 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Wed, 26 Aug 2026 16:07:07 +0200 Subject: [PATCH 10/37] fix: test ai-sqlite across CDS versions --- .github/actions/integration-tests/action.yml | 2 +- .github/workflows/test.yml | 2 +- README.md | 2 +- package.json | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 63c8feb..5c55965 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -73,7 +73,7 @@ runs: if: inputs.CDS_VERSION == '9' shell: bash run: | - npm pkg set "overrides.@cap-js/sqlite=^2" + npm pkg set "devDependencies.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/hana=^2" npm pkg set "overrides.@sap/cds-mtxs=^3" npm pkg set "devDependencies.@sap/cds=^9" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8e98b1..3c3bfa3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: - name: Pin CDS 9 compatible stack if: matrix.cds-version == '9' run: | - npm pkg set "overrides.@cap-js/sqlite=^2" + npm pkg set "devDependencies.@cap-js/sqlite=^2" npm pkg set "overrides.@cap-js/hana=^2" npm pkg set "overrides.@sap/cds-mtxs=^3" npm pkg set "devDependencies.@sap/cds=^9" diff --git a/README.md b/README.md index 344bfe6..dc21231 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ The beta `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic Install the optional runtime dependencies: ```sh -npm add @cap-js/sqlite@2 onnxruntime-node@1.20.1 +npm add @cap-js/sqlite onnxruntime-node@1.20.1 ``` `ai-sqlite` currently requires exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. diff --git a/package.json b/package.json index 6a8c543..0bce33e 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,11 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", - "@cap-js/sqlite": "^2", + "@cap-js/sqlite": ">=2", "onnxruntime-node": "1.20.1" }, "peerDependencies": { - "@cap-js/sqlite": "^2", + "@cap-js/sqlite": ">=2", "@sap/cds": ">=9", "onnxruntime-node": "1.20.1" }, From 4f44634e97705226ee9117e01de870a5c2ce4389 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:31:32 +0200 Subject: [PATCH 11/37] fix: truncate embeddings to one model window (#58) * fix: truncate embeddings to one model window * Update lib/vector_embedding/embedding.js Co-authored-by: hyperspace-pr-bot[bot] <209611008+hyperspace-pr-bot[bot]@users.noreply.github.com> --------- Co-authored-by: hyperspace-pr-bot[bot] <209611008+hyperspace-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 3 + lib/vector_embedding/embedding.js | 112 ++++++++++-------------------- tests/vector-unit.test.js | 15 ++-- tests/vector.test.js | 12 ++++ 5 files changed, 60 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 882ebad..c7a163a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions + - Embeds one model input window; applications split long documents and store one vector per chunk - **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios diff --git a/README.md b/README.md index dc21231..7902bd4 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,8 @@ SELECT.from('Books').columns` `; ``` +`VECTOR_EMBEDDING` embeds one model input window. Text beyond the tokenizer's input limit is truncated. For long-document retrieval, split documents before persistence and store one vector per chunk instead of combining chunk embeddings in this function. + **Parameters:** - `text` - Text to embed (`NULL` remains `NULL`; empty text returns a zero vector) - `text_type` - Type of text, e.g., `'DOCUMENT'` (currently informational) @@ -250,6 +252,7 @@ SELECT.from('Books').columns` **Features:** - **Initialization**: The ONNX model is loaded when the `ai-sqlite` service starts - **Verified cache**: The pinned model revision is cached by default below the user's data directory; set `CDS_AI_MODEL_CACHE` to use a pre-provisioned cache root +- **Predictable input**: Embeds the first model input window and truncates longer text - **Deterministic**: Same input always produces same output - **Normalized vectors**: All embeddings are L2-normalized - **Semantic similarity**: Embeddings capture text meaning for similarity search diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index cb35081..d81b63f 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -50,10 +50,11 @@ function wordPieceTokenizer(text, tokenizer) { const tokens = [clsToken]; const ids = [clsId]; - for (const preToken of preTokens) { + tokenize: for (const preToken of preTokens) { const wordPieceTokens = wordPieceTokenize(preToken, vocab, unkToken); for (const wpToken of wordPieceTokens) { + if (ids.length + wordPieceTokens.length > maxLength - 1) break tokenize; const tokenId = vocab.get(wpToken) ?? unkId; tokens.push(wpToken); ids.push(tokenId); @@ -63,88 +64,49 @@ function wordPieceTokenizer(text, tokenizer) { tokens.push(sepToken); ids.push(sepId); - if (tokens.length <= maxLength) return [{ tokens, ids }]; - - // Keep each chunk within the limit embedded in the pinned tokenizer. - const maxContentLength = maxLength - 2; - const chunks = []; - const contentTokens = tokens.slice(1, -1); - const contentIds = ids.slice(1, -1); - - for (let i = 0; i < contentTokens.length; i += maxContentLength) { - const chunkTokens = [clsToken, ...contentTokens.slice(i, i + maxContentLength), sepToken]; - const chunkIds = [clsId, ...contentIds.slice(i, i + maxContentLength), sepId]; - - chunks.push({ - tokens: chunkTokens, - ids: chunkIds - }); - } - - return chunks; + return { tokens, ids }; } /** - * Process embeddings for multiple chunks and combine them + * Process one model input window. */ -function processChunkedEmbeddings(chunks, session) { - const embeddings = []; - - for (const chunk of chunks) { - const { ids } = chunk; - const validIds = validateTokenIds(ids); - - const inputIds = new BigInt64Array(validIds.map((i) => BigInt(i))); - const attentionMask = new BigInt64Array(validIds.length).fill(BigInt(1)); - const tokenTypeIds = new BigInt64Array(validIds.length).fill(BigInt(0)); - - const inputTensor = new Tensor('int64', inputIds, [1, validIds.length]); - const attentionTensor = new Tensor('int64', attentionMask, [1, validIds.length]); - const tokenTypeTensor = new Tensor('int64', tokenTypeIds, [1, validIds.length]); - - const feeds = { - input_ids: inputTensor, - attention_mask: attentionTensor, - token_type_ids: tokenTypeTensor - }; - - const results = session.run(feeds); - const lastHiddenState = results['last_hidden_state']; - if (!lastHiddenState) - throw new Error( - `ONNX model output 'last_hidden_state' not found. Available outputs: ${Object.keys(results).join(', ')}` - ); - const [, sequenceLength, hiddenSize] = lastHiddenState.dims; - const embeddingData = lastHiddenState.data; - - // Apply mean pooling across the sequence dimension - const pooledEmbedding = new Float32Array(hiddenSize); - for (let i = 0; i < hiddenSize; i++) { - let sum = 0; - for (let j = 0; j < sequenceLength; j++) { - sum += embeddingData[j * hiddenSize + i]; - } - pooledEmbedding[i] = sum / sequenceLength; - } - - embeddings.push(pooledEmbedding); - } - - // If multiple chunks, average the embeddings - if (embeddings.length === 1) return embeddings[0]; - - const hiddenSize = embeddings[0].length; - const avgEmbedding = new Float32Array(hiddenSize); +function processEmbedding({ ids }, session) { + const validIds = validateTokenIds(ids); + + const inputIds = new BigInt64Array(validIds.map((i) => BigInt(i))); + const attentionMask = new BigInt64Array(validIds.length).fill(BigInt(1)); + const tokenTypeIds = new BigInt64Array(validIds.length).fill(BigInt(0)); + + const inputTensor = new Tensor('int64', inputIds, [1, validIds.length]); + const attentionTensor = new Tensor('int64', attentionMask, [1, validIds.length]); + const tokenTypeTensor = new Tensor('int64', tokenTypeIds, [1, validIds.length]); + + const feeds = { + input_ids: inputTensor, + attention_mask: attentionTensor, + token_type_ids: tokenTypeTensor + }; + + const results = session.run(feeds); + const lastHiddenState = results['last_hidden_state']; + if (!lastHiddenState) + throw new Error( + `ONNX model output 'last_hidden_state' not found. Available outputs: ${Object.keys(results).join(', ')}` + ); + const [, sequenceLength, hiddenSize] = lastHiddenState.dims; + const embeddingData = lastHiddenState.data; + // Apply mean pooling across the sequence dimension. + const pooledEmbedding = new Float32Array(hiddenSize); for (let i = 0; i < hiddenSize; i++) { let sum = 0; - for (const embedding of embeddings) { - sum += embedding[i]; + for (let j = 0; j < sequenceLength; j++) { + sum += embeddingData[j * hiddenSize + i]; } - avgEmbedding[i] = sum / embeddings.length; + pooledEmbedding[i] = sum / sequenceLength; } - return avgEmbedding; + return pooledEmbedding; } let session = null; @@ -161,8 +123,8 @@ function embedding(text) { throw new Error( 'Embedding session not initialized. Call createSession() before using embedding().' ); - const chunks = wordPieceTokenizer(text, tokenizer); - const vector = normalizeEmbedding(processChunkedEmbeddings(chunks, session)); + const input = wordPieceTokenizer(text, tokenizer); + const vector = normalizeEmbedding(processEmbedding(input, session)); const chunkObj = { content: text }; return Object.defineProperty(chunkObj, 'embedding', { diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index 76e30f7..a8df931 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -40,20 +40,19 @@ describe('BERT tokenizer', () => { }; test('applies BERT accent, punctuation, and Chinese character normalization', () => { - const [chunk] = wordPieceTokenizer('Héllo, café中文', tokenizer); + const chunk = wordPieceTokenizer('Héllo, café中文', tokenizer); assert.deepEqual(chunk.tokens, ['[CLS]', 'hello', ',', 'cafe', '中', '文', '[SEP]']); assert.deepEqual(chunk.ids, [101, 200, 201, 202, 203, 204, 102]); }); - test('uses the tokenizer model limit without an off-by-one', () => { - const chunks = wordPieceTokenizer(new Array(130).fill('token').join(' '), tokenizer); + test('truncates input to one model window without an off-by-one', () => { + const firstWindow = wordPieceTokenizer(new Array(126).fill('token').join(' '), tokenizer); + const longInput = wordPieceTokenizer(new Array(130).fill('token').join(' '), tokenizer); - assert.deepEqual( - chunks.map(({ ids }) => ids.length), - [128, 6] - ); - assert.ok(chunks.every(({ ids }) => ids.length <= tokenizer.maxLength)); + assert.equal(longInput.ids.length, tokenizer.maxLength); + assert.deepEqual(longInput.ids, [101, ...new Array(126).fill(205), 102]); + assert.deepEqual(longInput, firstWindow); }); }); diff --git a/tests/vector.test.js b/tests/vector.test.js index 20898b9..f3b0a24 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -27,6 +27,18 @@ describe('Vector embedding function (standalone)', () => { assert.strictEqual(e1, e2, 'Same input should produce identical embeddings'); }); + test('ignores text beyond the first model input window', () => { + const firstWindow = new Array(126).fill('token').join(' '); + const truncated = vector_embedding(firstWindow, 'DOCUMENT', 'SAP_GXY.20250407'); + const withAdditionalText = vector_embedding( + `${firstWindow} this text must not affect the embedding`, + 'DOCUMENT', + 'SAP_GXY.20250407' + ); + + assert.strictEqual(withAdditionalText, truncated); + }); + test('different inputs produce different outputs', async () => { const e1 = vector_embedding('hello world', 'DOCUMENT', 'SAP_GXY.20250407'); const e2 = vector_embedding('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407'); From 326bed620ec65a4307c74a66f51e5a204c351668 Mon Sep 17 00:00:00 2001 From: Bob den Os <108393871+BobdenOs@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:36:13 +0200 Subject: [PATCH 12/37] feat: triple store support for `@cap-js/sqlite` (#49) * Add triple store support for SQLiteService to match HANA capabilities * fix: harden SQLite knowledge graph loading * Apply suggestion from @sjvans --------- Co-authored-by: Sebastian Van Syckel Co-authored-by: sjvans <30337871+sjvans@users.noreply.github.com> --- CHANGELOG.md | 3 +- lib/knowledge-graph/triplestore.js | 88 ++++++++++++++++++++++ lib/sqlite/AISQLiteService.js | 62 ++++++++++++++- package.json | 9 ++- tests/bookshop/db/data/cap.ttl | 17 +++++ tests/bookshop/db/data/cap.ttl.gz | Bin 0 -> 234 bytes tests/knowledge-graph.test.js | 116 +++++++++++++++++++++++++++++ 7 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 lib/knowledge-graph/triplestore.js create mode 100644 tests/bookshop/db/data/cap.ttl create mode 100644 tests/bookshop/db/data/cap.ttl.gz create mode 100644 tests/knowledge-graph.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a163a..90f8aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,7 @@ - Synchronous execution suitable for SQLite user-defined functions - Embeds one model input window; applications split long documents and store one vector per chunk - **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios - - +- Experimental!: Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency ## Version 1.1.0 - 2026-07-20 diff --git a/lib/knowledge-graph/triplestore.js b/lib/knowledge-graph/triplestore.js new file mode 100644 index 0000000..8a48f6d --- /dev/null +++ b/lib/knowledge-graph/triplestore.js @@ -0,0 +1,88 @@ +let oxigraph; +try { + oxigraph = await import('oxigraph'); +} catch (err) { + if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err; +} + +import { pipeline } from 'node:stream/promises'; +import { text } from 'node:stream/consumers'; +import { createReadStream } from 'node:fs'; +import { realpath } from 'node:fs/promises'; +import { createGunzip } from 'node:zlib'; + +import cds from '@sap/cds'; +const { path } = cds.utils; + +const formats = { + '.jsonld': 'application/ld+json', + '.nq': 'application/n-quads', + '.nt': 'application/n-triples', + '.rdf': 'application/rdf+xml', + '.trig': 'application/trig', + '.ttl': 'text/turtle' +}; + +export default class TripleStore extends (oxigraph?.Store || class Store {}) { + async load(file, graph) { + this._ready(); + + const root = await realpath(path.resolve(cds.root)); + const resolved = path.resolve(root, file); + if (!resolved.startsWith(`${root}${path.sep}`)) { + throw new Error(`Cannot load RDF data from outside the project: ${file}`); + } + + let ext = path.extname(resolved).toLowerCase(); + if (ext.endsWith('.gz')) { + ext = path.extname(resolved.slice(0, -3)).toLowerCase(); + } + const format = formats[ext]; + if (!format) throw new Error(`Unsupported RDF file format: ${ext || '(none)'}`); + + // Resolve the target before opening it: a project-local symlink must not make + // files outside of cds.root available through SPARQL LOAD. + const target = await realpath(resolved); + if (!target.startsWith(`${root}${path.sep}`)) { + throw new Error(`Cannot load RDF data from outside the project: ${file}`); + } + + const graphNode = graph == null ? oxigraph.defaultGraph() : oxigraph.namedNode(graph); + const steps = [createReadStream(target)]; + if (path.extname(resolved).toLowerCase() === '.gz') steps.push(createGunzip()); + steps.push(text); + return super.load(await pipeline(...steps), { format, to_graph_name: graphNode }); + } + + query(query, headers) { + this._ready(); + + const accept = ( + headers?.split('\r\n').find((header) => /accept:/i.test(header)) ?? + 'accept:application/sparql-results+json' + ) + .replace(/accept:/i, '') + .trim(); // strip HTTP header formatting + + const RESPONSE = super.query(query, { + use_default_graph_as_union: true, + results_format: accept + }); + return { RESPONSE }; + } + + async execute(query, headers) { + this._ready(); + + if (!/^\s*LOAD\b/i.test(query)) return this.query(query, headers); + + const match = /^\s*LOAD\s+<([^>]*)>(?:\s+INTO\s+GRAPH\s+<([^>]*)>)?\s*$/i.exec(query); + if (!match) throw new Error(`Unsupported LOAD syntax: ${query}`); + return this.load(match[1], match[2]); + } + + _ready() { + if (!oxigraph) + throw new Error(`Cannot find 'oxigraph'. Make sure to install it with 'npm i oxigraph'`); + } +} diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js index 120476c..0a548e0 100644 --- a/lib/sqlite/AISQLiteService.js +++ b/lib/sqlite/AISQLiteService.js @@ -1,7 +1,12 @@ import SQLiteService from '@cap-js/sqlite'; import { initializeEmbedding, vector_embedding } from '../vector_embedding/index.js'; +import TripleStore from '../knowledge-graph/triplestore.js'; + +const $tripleStore = Symbol('tripleStore'); export default class AISQLiteService extends SQLiteService { + _tripleStores = new Map(); + async init() { await initializeEmbedding(); return super.init(); @@ -17,15 +22,68 @@ export default class AISQLiteService extends SQLiteService { const deterministic = { deterministic: true }; dbc.function('VECTOR_EMBEDDING', { ...deterministic, varargs: true }, embedding); dbc.function('VECTOR_EMBEDDING', deterministic, embedding); + + const key = tenant ?? ''; + const store = this._tripleStores.get(key) ?? new TripleStore(); + this._tripleStores.set(key, store); + dbc[$tripleStore] = store; + dbc.function('sparql_table', (query) => store.query(query).RESPONSE); return dbc; }; return factory; } + async disconnect(tenant) { + await super.disconnect(tenant); + if (tenant == null) this._tripleStores.clear(); + else this._tripleStores.delete(tenant); + } + + onPlainSQL(req, next) { + const { query } = req; + if (!/^\s*CALL\s+SPARQL_EXECUTE\b/i.test(query)) return super.onPlainSQL(req, next); + + const match = + /^\s*CALL\s+SPARQL_EXECUTE\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'\s*,\s*\?\s*,\s*\?\s*\)\s*;?\s*$/i.exec( + query + ); + if (!match) throw new Error(`Unsupported SPARQL_EXECUTE syntax: ${query}`); + + const store = this.dbc?.[$tripleStore]; + if (!store) throw new Error('SPARQL_EXECUTE requires an active database connection'); + const unescape = (value) => value.replace(/''/g, "'"); + return store.execute(unescape(match[1]), unescape(match[2])); + } + static CQN2SQL = class CQN2AISQLite extends SQLiteService.CQN2SQL { - // add cqn2sql stuff here static Functions = { - ...SQLiteService.CQN2SQL.Functions + ...SQLiteService.CQN2SQL.Functions, + sparql_table(query) { + if (typeof query.val !== 'string') { + throw new Error('sparql_table expects a literal SPARQL SELECT query'); + } + + // Keep the SQL projection deliberately narrow, but accept the SPARQL + // prologue and the optional WHERE keyword (both are valid SPARQL). + const iri = '<(?:[^>\\\\]|\\\\.)*>'; + const prologue = `(?:(?:BASE\\s+${iri}|PREFIX\\s+(?:[A-Za-z][A-Za-z0-9._-]*)?:\\s*${iri})\\s*)*`; + const match = new RegExp( + `^\\s*${prologue}SELECT\\s+(?:(?:DISTINCT|REDUCED)\\s+)?((?:[?$][A-Za-z_][A-Za-z0-9_]*\\s*)+)(?:WHERE\\s*)?\\{`, + 'is' + ).exec(query.val); + if (!match) { + throw new Error('sparql_table only supports explicitly projected SPARQL variables'); + } + + const projection = match[1]; + const variables = projection.match(/[?$][A-Za-z_][A-Za-z0-9_]*/g); + + const columns = variables.map((variable) => variable.slice(1)); + const select = columns.map( + (column) => `value->>'$.${column}.value' as ${this.quote(column)}` + ); + return `(SELECT ${select} FROM json_each(sparql_table(${query})->'$.results.bindings'))`; + } }; }; } diff --git a/package.json b/package.json index 0bce33e..7fe7fa6 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,14 @@ "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", "@cap-js/sqlite": ">=2", - "onnxruntime-node": "1.20.1" + "onnxruntime-node": "1.20.1", + "oxigraph": "^0.5.9" }, "peerDependencies": { "@cap-js/sqlite": ">=2", "@sap/cds": ">=9", - "onnxruntime-node": "1.20.1" + "onnxruntime-node": "1.20.1", + "oxigraph": "^0.5.9" }, "peerDependenciesMeta": { "@cap-js/sqlite": { @@ -37,6 +39,9 @@ }, "onnxruntime-node": { "optional": true + }, + "oxigraph": { + "optional": true } }, "engines": { diff --git a/tests/bookshop/db/data/cap.ttl b/tests/bookshop/db/data/cap.ttl new file mode 100644 index 0000000..c58a8c0 --- /dev/null +++ b/tests/bookshop/db/data/cap.ttl @@ -0,0 +1,17 @@ +@prefix cap: . + +# CAP sample turtle file + +cap:Service cap:label "Service" . +cap:DatabaseService a cap:Service . +cap:DatabaseService cap:label "Database Service" . +cap:SQLiteService a cap:DatabaseService . +cap:SQLiteService cap:name "SQLite Service" . +cap:SQLiteService cap:label "SQLite Service" . +cap:HANAService cap:implementedBy cap:cap-js-sqlite . +cap:HANAService a cap:DatabaseService . +cap:HANAService cap:name "HANA Service" . +cap:HANAService cap:label "HANA Service" . +cap:HANAService cap:implementedBy cap:cap-js-hana . +cap:cap-js-hana cap:label "@cap-js/hana" . +cap:cap-js-sqlite cap:label "@cap-js/sqlite" . diff --git a/tests/bookshop/db/data/cap.ttl.gz b/tests/bookshop/db/data/cap.ttl.gz new file mode 100644 index 0000000000000000000000000000000000000000..9595224c325550a296930f3844d6b1161960d88b GIT binary patch literal 234 zcmVGzC#7B@= z2~MEeM(J0+!wvnlV;KF9 k`f+lJH4Yh@SoKlQ1H;`{b-3@0+@Bcz36wdA { + let db; + + const data = fileURLToPath(new URL('./bookshop/db/data/cap.ttl', import.meta.url)); + const graph = 'https://cap.cloud.sap/example'; + + before(async () => { + db = await cds.connect.to('knowledge-graph-db', { + kind: 'ai-sqlite', + credentials: { url: ':memory:' } + }); + }); + + beforeEach(async () => { + await db.disconnect(); + }); + + after(async () => { + await db?.disconnect(); + }); + + test('loads Turtle data', async () => { + await load(data); + assert.strictEqual((await triples()).length, 13); + }); + + test('loads compressed Turtle data', async () => { + await load(`${data}.gz`); + assert.strictEqual((await triples()).length, 13); + }); + + test('rejects malformed SPARQL_EXECUTE calls', async () => { + await assert.rejects( + db.run(`CALL SPARQL_EXECUTE('SELECT * WHERE { ?s ?p ?o }')`), + /Unsupported SPARQL_EXECUTE syntax/ + ); + }); + + test('rejects RDF files outside the project', async () => { + await assert.rejects( + db.run(`CALL SPARQL_EXECUTE('LOAD ','', ?, ?)`), + /outside the project/ + ); + }); + + test('rejects project-local symlinks pointing outside the project', async () => { + const link = path.join(cds.root, 'tests/bookshop/db/data/outside.ttl'); + await symlink('/etc/passwd', link); + try { + await assert.rejects( + db.run(`CALL SPARQL_EXECUTE('LOAD <${link}>','', ?, ?)`), + /outside the project/ + ); + } finally { + await unlink(link); + } + }); + + test('checks RDF format before trying to open the file', async () => { + await assert.rejects( + db.run(`CALL SPARQL_EXECUTE('LOAD <${data}.unsupported>','', ?, ?)`), + /Unsupported RDF file format: .unsupported/ + ); + }); + + test('supports SPARQL prologues and SELECT without WHERE', async () => { + await load(data); + const result = await db.run({ + SELECT: { + from: cds.ql.func( + 'sparql_table', + `BASE \nPREFIX cap: \nSELECT ?subject ?predicate { ?subject ?predicate ?object . }` + ) + } + }); + assert.ok(result.length > 0); + assert.deepStrictEqual(Object.keys(result[0]), ['subject', 'predicate']); + }); + + test('keeps a graph unchanged when a valid RDF file is malformed', async () => { + await load(data); + const malformed = path.join(cds.root, 'tests/bookshop/db/data/malformed.ttl'); + await writeFile( + malformed, + ' .\nnot turtle' + ); + try { + await assert.rejects(load(malformed)); + assert.strictEqual((await triples()).length, 13); + } finally { + await unlink(malformed); + } + }); + + async function load(file) { + return db.run(`CALL SPARQL_EXECUTE('LOAD <${file}> INTO GRAPH <${graph}>','', ?, ?)`); + } + + async function triples() { + return db.run({ + SELECT: { + from: cds.ql.func( + 'sparql_table', + 'SELECT ?subject ?predicate ?object WHERE { ?subject ?predicate ?object . }' + ) + } + }); + } +}); From 16cf65cc975a68105e12efe9c4757c25c1ffb975 Mon Sep 17 00:00:00 2001 From: Bob den Os <108393871+BobdenOs@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:01:25 +0200 Subject: [PATCH 13/37] feat: configure and provision local embedding models (#51) * feat: support configurable local embedding models * feat: provision embedding models by name (#55) * feat: add explicit embedding model provisioning * feat: support lazy embedding model provisioning * docs: explain embedding model provisioning * fix: require explicit embedding model * refactor: require provisioned embedding models * feat: provision embedding models by name * fix: make tokenizer an optional peer --------- Co-authored-by: Sebastian Van Syckel Co-authored-by: sjvans <30337871+sjvans@users.noreply.github.com> --- .gitignore | 3 +- CHANGELOG.md | 8 +- README.md | 89 ++- bin/cds-ai.js | 10 + lib/sqlite/AISQLiteService.js | 41 +- lib/vector_embedding/InferenceSession.js | 48 +- lib/vector_embedding/cli.js | 58 ++ lib/vector_embedding/embedding.js | 479 +++++++++++---- lib/vector_embedding/index.js | 62 +- lib/vector_embedding/model-discovery.js | 390 ++++++++++++ lib/vector_embedding/model-install.js | 64 ++ lib/vector_embedding/model-utils.js | 739 +++++++++++++++++++---- package.json | 12 + tests/knowledge-graph.test.js | 1 + tests/model-discovery.test.js | 326 ++++++++++ tests/model-provisioning.test.js | 598 ++++++++++++++++++ tests/vector-unit.test.js | 266 ++++++-- tests/vector.test.js | 56 +- 18 files changed, 2889 insertions(+), 361 deletions(-) create mode 100755 bin/cds-ai.js create mode 100644 lib/vector_embedding/cli.js create mode 100644 lib/vector_embedding/model-discovery.js create mode 100644 lib/vector_embedding/model-install.js create mode 100644 tests/model-discovery.test.js create mode 100644 tests/model-provisioning.test.js diff --git a/.gitignore b/.gitignore index 4104bfe..2ce6852 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ gen/ package-lock.json .env .cdsrc-private.json -resources/ \ No newline at end of file +resources/ +.cds/models/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f8aca..6653424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,15 @@ ### Added -- **Beta:** Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using ONNX Runtime and the `Xenova/all-MiniLM-L6-v2` model (384 dimensions) - - Downloads the pinned model revision on-demand from Hugging Face (~91MB), verifies its size and SHA-256, and caches it locally +- **Beta:** Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using compatible ONNX encoder models + - Requires `cds.env.requires.db.embedding.model`; automatically discovers model metadata and supports warned, on-demand provisioning into `.cds/models` + - Adds `npx @cap-js/ai install-model ` with an optional shared model-cache root + - Uses the optional `@huggingface/tokenizers` peer dependency and truncates long input to the first model input window + - Configures embedding runtimes only through `model` and an optional relative, absolute, or home-relative `directory`; discovered metadata remains in the provisioned lock - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions - Embeds one model input window; applications split long documents and store one vector per chunk - - **Note**: Produces 384-dimensional vectors (vs. 768 in SAP HANA) for efficiency in local development scenarios - Experimental!: Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency ## Version 1.1.0 - 2026-07-20 diff --git a/README.md b/README.md index 7902bd4..5d994fa 100644 --- a/README.md +++ b/README.md @@ -207,30 +207,79 @@ resources: ### 3. Local Vector Embeddings with SQLite -The beta `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX model. +The beta `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX encoder model. Configure the model explicitly for every service. #### Usage Install the optional runtime dependencies: ```sh -npm add @cap-js/sqlite onnxruntime-node@1.20.1 +npm add @cap-js/sqlite @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 ``` -`ai-sqlite` currently requires exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. +The `@huggingface/tokenizers` and `onnxruntime-node` packages are optional peer dependencies of `@cap-js/ai`, but are required when using `ai-sqlite`. `ai-sqlite` currently requires exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. -Select `ai-sqlite` for the database service: +#### Model provisioning + +Runtime configuration is intentionally limited to a model name and an optional model-cache root: ```json { "cds": { "requires": { - "db": "ai-sqlite" + "db": { + "kind": "ai-sqlite", + "embedding": { + "model": "foo/bar" + } + } } } } ``` +`embedding.model` is required. If it is absent, `ai-sqlite` fails during startup. No revision, dimensions, tokenizer, file, pooling, checksum, or descriptor settings are accepted in runtime configuration. + +Without `directory`, the model is stored below the CAP project at `.cds/models/foo/bar`. Startup reuses a valid installation from there. If it is missing, startup logs a warning, discovers and downloads the model, generates `embedding.lock.json`, and reuses that installation on subsequent starts. + +To provision the project-local model before startup instead: + +```sh +npx @cap-js/ai install-model foo/bar +``` + +To share a model across projects, select another cache root: + +```sh +npx @cap-js/ai install-model foo/bar --directory ~/.cds/models +``` + +```json +{ + "cds": { + "requires": { + "db": { + "kind": "ai-sqlite", + "embedding": { + "model": "foo/bar", + "directory": "~/.cds/models" + } + } + } + } +} +``` + +`directory` always names the cache root; the model is stored below it using the repository path, for example `~/.cds/models/foo/bar`. Relative directories are resolved from `cds.root`, absolute directories are used unchanged, and `~/` is resolved from the user's home directory. + +When `directory` is configured, startup treats it as a pre-installed shared cache: it verifies the model but does not download or modify it. This makes runtime deployment deterministic and allows the shared directory to be read-only. + +##### Automatic model discovery + +The installer resolves the model's current Hugging Face revision to an immutable commit, selects the conventional `onnx/model.onnx` and tokenizer/configuration files, calculates or obtains their checksums, and derives the dimensions, tokenizer limit, pooling, and normalization metadata. It then writes all resolved metadata to `embedding.lock.json` alongside the downloaded artifacts. + +Discovery supports compatible Hugging Face ONNX Sentence Transformers models with machine-readable pooling semantics. Repositories with missing or ambiguous artifacts or semantics fail with a compatibility error instead of using guessed defaults. Once installed, startup uses the pinned lock and does not follow later changes to the model repository. + The HANA-compatible SQL function can then be used in CQL: ```js @@ -242,24 +291,40 @@ SELECT.from('Books').columns` `VECTOR_EMBEDDING` embeds one model input window. Text beyond the tokenizer's input limit is truncated. For long-document retrieval, split documents before persistence and store one vector per chunk instead of combining chunk embeddings in this function. **Parameters:** + - `text` - Text to embed (`NULL` remains `NULL`; empty text returns a zero vector) - `text_type` - Type of text, e.g., `'DOCUMENT'` (currently informational) -- `model_and_version` - Model identifier, e.g., `'SAP_GXY.20250407'` or `'SAP_GXY.20240715'` +- `model_and_version` - Compatibility model identifier, e.g., `'SAP_GXY.20250407'` or `'SAP_GXY.20240715'` (currently informational; the service's `embedding` option selects the local model) **Returns:** -- JSON stringified array of embedding values (384 dimensions) + +- JSON stringified array of embedding values with the configured model's dimensions **Features:** + - **Initialization**: The ONNX model is loaded when the `ai-sqlite` service starts -- **Verified cache**: The pinned model revision is cached by default below the user's data directory; set `CDS_AI_MODEL_CACHE` to use a pre-provisioned cache root -- **Predictable input**: Embeds the first model input window and truncates longer text +- **Automatic provisioning**: Use model-only configuration for warned, on-demand installation into `.cds/models` +- **Explicit provisioning**: Preinstall local or shared models with `npx @cap-js/ai install-model` +- **Verified artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums +- **Hugging Face tokenization**: Uses `@huggingface/tokenizers` and truncates text to the first model input window - **Deterministic**: Same input always produces same output -- **Normalized vectors**: All embeddings are L2-normalized +- **Automatic output handling**: Pooling and normalization are derived from Sentence Transformers metadata - **Semantic similarity**: Embeddings capture text meaning for similarity search +#### Compatible encoder models + +Compatible repositories must provide `onnx/model.onnx`, `tokenizer.json`, `tokenizer_config.json`, and `config.json`. The model must accept `input_ids` and may additionally accept `attention_mask` and `token_type_ids`, all as `int64` tensors, and expose a `last_hidden_state` float output. + +Pooling semantics are read from Sentence Transformers `modules.json` and its pooling configuration. Converted repositories such as `Xenova/*` can declare a single `base_model`; its immutable Sentence Transformers metadata is used to determine mean or CLS pooling and normalization. Unsupported module chains, ambiguous pooling modes, missing metadata, or incompatible ONNX inputs and outputs fail explicitly. + +Provisioning canonicalizes symlinked parent directories and rejects a model directory that is itself a symlink. Existing valid locks remain pinned and are reused rather than silently following changes to the repository's default branch. + **Error Handling:** -- Starting `ai-sqlite` fails if the ONNX model cannot be initialized -- Downloads are time-limited and accepted only when their expected size and SHA-256 match + +- Starting `ai-sqlite` fails if `cds.env.requires.db.embedding.model` is not set or the ONNX model cannot be initialized +- A missing model in the project-local `.cds/models` cache is installed after a startup warning +- Starting `ai-sqlite` fails with a provisioning command if a configured model directory is missing or fails integrity checks +- Provisioning downloads are time-limited and accepted only when their expected size and SHA-256 match - Throws if embedding generation fails ## Test the plugin locally diff --git a/bin/cds-ai.js b/bin/cds-ai.js new file mode 100755 index 0000000..788d63f --- /dev/null +++ b/bin/cds-ai.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { runModelCommand } from '../lib/vector_embedding/cli.js'; + +try { + await runModelCommand(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +} diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js index 0a548e0..b20e3f6 100644 --- a/lib/sqlite/AISQLiteService.js +++ b/lib/sqlite/AISQLiteService.js @@ -1,15 +1,40 @@ +import cds from '@sap/cds'; import SQLiteService from '@cap-js/sqlite'; -import { initializeEmbedding, vector_embedding } from '../vector_embedding/index.js'; +import { createEmbeddingRuntime } from '../vector_embedding/embedding.js'; import TripleStore from '../knowledge-graph/triplestore.js'; +const LOG = cds.log('@cap-js/ai'); const $tripleStore = Symbol('tripleStore'); export default class AISQLiteService extends SQLiteService { _tripleStores = new Map(); async init() { - await initializeEmbedding(); - return super.init(); + this._embeddingRuntime = await createEmbeddingRuntime(this.options.embedding, { + root: cds.root, + warn: (message) => LOG.warn(message) + }); + try { + const service = await super.init(); + LOG.info('Vector embedding ONNX model initialized'); + return service; + } catch (error) { + await this._embeddingRuntime.dispose().catch(() => {}); + this._embeddingRuntime = undefined; + throw error; + } + } + + async disconnect(tenant) { + try { + return await super.disconnect(tenant); + } finally { + if (tenant == null) { + this._tripleStores.clear(); + await this._embeddingRuntime?.dispose(); + this._embeddingRuntime = undefined; + } else this._tripleStores.delete(tenant); + } } get factory() { @@ -18,7 +43,9 @@ export default class AISQLiteService extends SQLiteService { factory.create = async (tenant) => { const dbc = await create(tenant); const embedding = (input, textType, modelAndVersion) => - input == null ? null : vector_embedding(String(input), textType, modelAndVersion); + input == null + ? null + : this._embeddingRuntime.vectorEmbedding(String(input), textType, modelAndVersion); const deterministic = { deterministic: true }; dbc.function('VECTOR_EMBEDDING', { ...deterministic, varargs: true }, embedding); dbc.function('VECTOR_EMBEDDING', deterministic, embedding); @@ -33,12 +60,6 @@ export default class AISQLiteService extends SQLiteService { return factory; } - async disconnect(tenant) { - await super.disconnect(tenant); - if (tenant == null) this._tripleStores.clear(); - else this._tripleStores.delete(tenant); - } - onPlainSQL(req, next) { const { query } = req; if (!/^\s*CALL\s+SPARQL_EXECUTE\b/i.test(query)) return super.onPlainSQL(req, next); diff --git a/lib/vector_embedding/InferenceSession.js b/lib/vector_embedding/InferenceSession.js index 0ea71be..ecb61fd 100644 --- a/lib/vector_embedding/InferenceSession.js +++ b/lib/vector_embedding/InferenceSession.js @@ -23,6 +23,14 @@ class InferenceSession { this.handler = handler; } + get inputNames() { + return this.handler.inputNames; + } + + get outputNames() { + return this.handler.outputNames; + } + run(feeds) { if ( typeof feeds !== 'object' || @@ -54,6 +62,13 @@ class InferenceSession { return output; } + dispose() { + const handler = this.handler; + if (!handler) return; + this.handler = undefined; + return handler.dispose(); + } + static async create(pathOrBuffer) { if (typeof pathOrBuffer !== 'string' && !(pathOrBuffer instanceof Uint8Array)) { throw new TypeError('Expected an ONNX model path or Uint8Array'); @@ -65,25 +80,34 @@ class InferenceSession { class SynchronousSessionHandler { constructor(pathOrBuffer) { this.session = new binding.InferenceSession(); - if (typeof pathOrBuffer === 'string') { - this.session.loadModel(pathOrBuffer, {}); - } else { - this.session.loadModel( - pathOrBuffer.buffer, - pathOrBuffer.byteOffset, - pathOrBuffer.byteLength, - {} - ); + try { + if (typeof pathOrBuffer === 'string') { + this.session.loadModel(pathOrBuffer, {}); + } else { + this.session.loadModel( + pathOrBuffer.buffer, + pathOrBuffer.byteOffset, + pathOrBuffer.byteLength, + {} + ); + } + this.inputNames = this.session.inputNames; + this.outputNames = this.session.outputNames; + } catch (error) { + try { + this.session.dispose(); + } catch { + // Preserve the model loading error. + } + throw error; } - this.inputNames = this.session.inputNames; - this.outputNames = this.session.outputNames; } run(feeds, fetches, options) { return this.session.run(feeds, fetches, options); } - async dispose() { + dispose() { this.session.dispose(); } } diff --git a/lib/vector_embedding/cli.js b/lib/vector_embedding/cli.js new file mode 100644 index 0000000..956c0ed --- /dev/null +++ b/lib/vector_embedding/cli.js @@ -0,0 +1,58 @@ +import { installModel } from './model-install.js'; +import { validateEmbeddingModel } from './embedding.js'; + +const HELP = `Usage: + npx @cap-js/ai install-model [--directory ] + +Options: + --directory Use this model-cache root instead of .cds/models + --help Show this help +`; + +async function runModelCommand(argv, options = {}) { + const { cwd = process.cwd(), stdout = process.stdout } = options; + const command = parseArguments(argv); + if (command.help) { + stdout.write(HELP); + return; + } + + const { modelDir } = await installModel(command.model, { + root: cwd, + directory: command.directory, + home: options.home, + fetchImpl: options.fetchImpl, + discover: options.discover, + validate: options.validate ?? validateEmbeddingModel, + timeoutMs: options.timeoutMs, + retryMs: options.retryMs + }); + stdout.write(`Installed ${command.model} in ${modelDir}\n`); +} + +function parseArguments(argv) { + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) return { help: true }; + if (argv[0] !== 'install-model') { + throw new Error(`Unsupported command.\n\n${HELP}`); + } + + let model; + let directory; + for (let index = 1; index < argv.length; index++) { + const argument = argv[index]; + if (argument === '--directory') { + const value = argv[++index]; + if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); + directory = value; + continue; + } + if (argument.startsWith('-')) throw new Error(`Unknown option '${argument}'`); + if (model) throw new Error(`Unexpected argument '${argument}'`); + model = argument; + } + + if (!model) throw new Error('Specify a model name'); + return { directory, model }; +} + +export { HELP, parseArguments, runModelCommand }; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index d81b63f..b19d206 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -1,172 +1,399 @@ -import os from 'os'; -import path from 'path'; import { Tensor } from './InferenceSession.js'; +import { installModel } from './model-install.js'; import { - downloadModelIfNeeded, + getModelDirectory, + getModelRoot, loadModelAndTokenizer, - preTokenize, - wordPieceTokenize, - validateTokenIds + readModelLock, + verifyModelDirectory } from './model-utils.js'; -const MODEL = { - repository: 'Xenova/all-MiniLM-L6-v2', - revision: '751bff37182d3f1213fa05d7196b954e230abad9', - files: [ - { - name: 'model.onnx', - path: 'onnx/model.onnx', - size: 90387606, - sha256: '759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e' - }, - { - name: 'tokenizer.json', - path: 'tokenizer.json', - size: 711661, - sha256: 'da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0' - } - ] -}; +const STANDARD_INPUT_NAMES = new Set(['input_ids', 'attention_mask', 'token_type_ids']); + +async function createEmbeddingRuntime(configuration, options = {}) { + const { model, modelDir } = await resolveEmbeddingModel(configuration, options); + return createEmbeddingRuntimeFromModel(modelDir, model); +} + +async function createEmbeddingRuntimeFromModel(modelDir, model) { + const { session, tokenizer } = await loadModelAndTokenizer(modelDir, model); + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + await session.dispose(); + }; -/** - * Main tokenization function that combines all steps - */ -function wordPieceTokenizer(text, tokenizer) { - const unkToken = '[UNK]'; - const clsToken = '[CLS]'; - const sepToken = '[SEP]'; - const { vocab, maxLength, normalizer } = tokenizer; + try { + const tokenizerState = createTokenizerState(tokenizer, model.maxLength); - const clsId = vocab.get(clsToken) ?? 101; - const sepId = vocab.get(sepToken) ?? 102; - const unkId = vocab.get(unkToken) ?? 100; + validateSession(session, model); - if (typeof clsId !== 'number' || typeof sepId !== 'number' || typeof unkId !== 'number') { - throw new Error('Special tokens must have numeric IDs'); + const runtime = { + dimensions: model.dimensions, + embedding(text) { + const input = tokenizeToWindow(String(text), tokenizer, tokenizerState); + return processEmbedding(input, session, model); + }, + vectorEmbedding(text) { + if (!text) return JSON.stringify(new Array(model.dimensions).fill(0)); + return JSON.stringify(Array.from(this.embedding(text))); + }, + dispose + }; + + const probe = runtime.embedding('embedding model startup probe'); + if (probe.length !== model.dimensions) { + throw new Error( + `Embedding model produced ${probe.length} dimensions; configured ${model.dimensions}` + ); + } + return runtime; + } catch (error) { + await dispose().catch(() => {}); + throw error; } +} - const preTokens = preTokenize(text, normalizer); +async function validateEmbeddingModel(modelDir, model) { + const runtime = await createEmbeddingRuntimeFromModel(modelDir, model); + await runtime.dispose(); +} - const tokens = [clsToken]; - const ids = [clsId]; +async function resolveEmbeddingModel(configuration, options = {}) { + const { + root = process.cwd(), + warn = (message) => console.warn(message), + fetchImpl, + discover, + validate + } = options; + const { model: modelName, directory } = normalizeEmbeddingConfiguration(configuration); + const modelRoot = getModelRoot(directory, root, options.home); + const modelDir = getModelDirectory(modelRoot, modelName); + const installOptions = { + root, + directory: modelRoot, + home: options.home, + fetchImpl, + discover, + validate: validate ?? validateEmbeddingModel, + timeoutMs: options.provisionTimeoutMs, + retryMs: options.provisionRetryMs + }; - tokenize: for (const preToken of preTokens) { - const wordPieceTokens = wordPieceTokenize(preToken, vocab, unkToken); + let model; + try { + model = await readModelLock(modelDir); + } catch (error) { + if (directory !== undefined || !/Embedding model lock not found/.test(error.message)) { + const recovery = /Embedding model lock not found/.test(error.message) + ? modelInstallHint(modelName, directory) + : `Remove or replace the invalid lock explicitly, then ${lowercaseFirst( + modelInstallHint(modelName, directory) + )}`; + throw new Error(`${error.message}. ${recovery}`, { cause: error }); + } + return installModelOnDemand(modelName, modelDir, installOptions, warn); + } - for (const wpToken of wordPieceTokens) { - if (ids.length + wordPieceTokens.length > maxLength - 1) break tokenize; - const tokenId = vocab.get(wpToken) ?? unkId; - tokens.push(wpToken); - ids.push(tokenId); + if (model.repository !== modelName) { + throw new Error( + `Embedding model directory ${modelDir} contains ${model.repository}, not ${modelName}. Choose another directory or provision the configured model there.` + ); + } + try { + await verifyModelDirectory(modelDir, model); + } catch (error) { + if (directory !== undefined) { + throw new Error(`${error.message}. ${modelInstallHint(modelName, directory)}`, { + cause: error + }); } + return installModelOnDemand(modelName, modelDir, installOptions, warn); + } + return { model, modelDir }; +} + +async function installModelOnDemand(modelName, modelDir, options, warn) { + warn( + `Embedding model '${modelName}' is not available in '${modelDir}'. Downloading it now; application startup may be delayed. ${modelInstallHint(modelName)}` + ); + try { + return await installModel(modelName, options); + } catch (error) { + throw new Error( + `Failed to install embedding model '${modelName}': ${error.message}. ${modelInstallHint(modelName)}`, + { cause: error } + ); + } +} + +function normalizeEmbeddingConfiguration(configuration) { + if (configuration == null) { + throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); + } + if (typeof configuration !== 'object' || Array.isArray(configuration)) { + throw new TypeError('embedding must be an object with model and optional directory'); } - tokens.push(sepToken); - ids.push(sepId); + const unsupported = Object.keys(configuration).filter( + (name) => name !== 'model' && name !== 'directory' + ); + if (unsupported.length > 0) { + throw new Error( + `Unsupported embedding configuration: ${unsupported.join(', ')}. Only model and directory are supported.` + ); + } + if (typeof configuration.model !== 'string' || !configuration.model.trim()) { + throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); + } + if ( + configuration.directory !== undefined && + (typeof configuration.directory !== 'string' || !configuration.directory.trim()) + ) { + throw new Error('embedding.directory must be a non-empty string'); + } + return { model: configuration.model, directory: configuration.directory }; +} - return { tokens, ids }; +function modelInstallHint(repository, directory) { + const directoryArgument = directory === undefined ? '' : ` --directory ${directory}`; + return `Run 'npx @cap-js/ai install-model ${repository}${directoryArgument}'.`; } -/** - * Process one model input window. - */ -function processEmbedding({ ids }, session) { - const validIds = validateTokenIds(ids); +function lowercaseFirst(value) { + return `${value[0].toLowerCase()}${value.slice(1)}`; +} - const inputIds = new BigInt64Array(validIds.map((i) => BigInt(i))); - const attentionMask = new BigInt64Array(validIds.length).fill(BigInt(1)); - const tokenTypeIds = new BigInt64Array(validIds.length).fill(BigInt(0)); +function createTokenizerState(tokenizer, maxLength) { + const probeText = 'embedding tokenizer boundary probe'; + const content = normalizeEncoding( + tokenizer.encode(probeText, { + add_special_tokens: false, + return_token_type_ids: true + }) + ); + const wrapped = normalizeEncoding( + tokenizer.encode(probeText, { + add_special_tokens: true, + return_token_type_ids: true + }) + ); - const inputTensor = new Tensor('int64', inputIds, [1, validIds.length]); - const attentionTensor = new Tensor('int64', attentionMask, [1, validIds.length]); - const tokenTypeTensor = new Tensor('int64', tokenTypeIds, [1, validIds.length]); + const contentOffset = findSubarray(wrapped.ids, content.ids); + if (content.ids.length === 0 || contentOffset < 0) { + throw new Error('Tokenizer special-token layout is incompatible with windowed encoding'); + } - const feeds = { - input_ids: inputTensor, - attention_mask: attentionTensor, - token_type_ids: tokenTypeTensor + const prefix = sliceEncoding(wrapped, 0, contentOffset); + const suffix = sliceEncoding(wrapped, contentOffset + content.ids.length); + if (prefix.ids.length + suffix.ids.length >= maxLength) { + throw new Error('embedding.maxLength leaves no room for tokenizer content'); + } + return { maxLength, prefix, suffix }; +} + +function tokenizeToWindow(text, tokenizer, { maxLength, prefix, suffix }) { + // tokenizers.js intentionally ignores tokenizer.json truncation. Encode the complete + // content without special tokens, truncate it, then add the tokenizer-derived boundaries. + const encoded = normalizeEncoding( + tokenizer.encode(text, { + add_special_tokens: false, + return_token_type_ids: true + }) + ); + const maxContentLength = maxLength - prefix.ids.length - suffix.ids.length; + return concatenateEncodings(prefix, sliceEncoding(encoded, 0, maxContentLength), suffix); +} + +function normalizeEncoding(encoding) { + if (!encoding || typeof encoding !== 'object') { + throw new Error('Tokenizer did not return an encoding'); + } + validateTokenIds(encoding.ids); + const attentionMask = encoding.attention_mask ?? new Array(encoding.ids.length).fill(1); + const tokenTypeIds = encoding.token_type_ids ?? new Array(encoding.ids.length).fill(0); + validateAttentionMask(attentionMask); + validateTokenIds(tokenTypeIds); + if (attentionMask.length !== encoding.ids.length || tokenTypeIds.length !== encoding.ids.length) { + throw new Error('Tokenizer metadata length does not match its token IDs'); + } + return { ids: encoding.ids, attention_mask: attentionMask, token_type_ids: tokenTypeIds }; +} + +function sliceEncoding(encoding, start, end) { + return Object.fromEntries( + Object.entries(encoding).map(([name, values]) => [name, values.slice(start, end)]) + ); +} + +function concatenateEncodings(...encodings) { + return { + ids: encodings.flatMap(({ ids }) => ids), + attention_mask: encodings.flatMap(({ attention_mask: attentionMask }) => attentionMask), + token_type_ids: encodings.flatMap(({ token_type_ids: tokenTypeIds }) => tokenTypeIds) }; +} - const results = session.run(feeds); - const lastHiddenState = results['last_hidden_state']; - if (!lastHiddenState) +function findSubarray(values, expected) { + outer: for (let offset = 0; offset <= values.length - expected.length; offset++) { + for (let index = 0; index < expected.length; index++) { + if (values[offset + index] !== expected[index]) continue outer; + } + return offset; + } + return -1; +} + +function validateSession(session, model) { + const inputNames = session.inputNames; + const outputNames = session.outputNames; + if (!Array.isArray(inputNames) || !inputNames.includes('input_ids')) { + throw new Error("Embedding model must expose the standard int64 input 'input_ids'"); + } + const unsupportedInputs = inputNames.filter((name) => !STANDARD_INPUT_NAMES.has(name)); + if (unsupportedInputs.length > 0) { + throw new Error(`Embedding model has unsupported inputs: ${unsupportedInputs.join(', ')}`); + } + if (!Array.isArray(outputNames) || !outputNames.includes(model.output.name)) { + throw new Error( + `Embedding model output '${model.output.name}' not found. Available outputs: ${outputNames?.join(', ') || 'none'}` + ); + } +} + +function processEmbedding(input, session, model) { + const results = session.run(createFeeds(input, session.inputNames)); + const output = results[model.output.name]; + if (!output) { throw new Error( - `ONNX model output 'last_hidden_state' not found. Available outputs: ${Object.keys(results).join(', ')}` + `Embedding model output '${model.output.name}' not found. Available outputs: ${Object.keys(results).join(', ')}` ); - const [, sequenceLength, hiddenSize] = lastHiddenState.dims; - const embeddingData = lastHiddenState.data; - - // Apply mean pooling across the sequence dimension. - const pooledEmbedding = new Float32Array(hiddenSize); - for (let i = 0; i < hiddenSize; i++) { - let sum = 0; - for (let j = 0; j < sequenceLength; j++) { - sum += embeddingData[j * hiddenSize + i]; + } + const embedding = poolOutput(output, model.output.pooling); + if (embedding.length !== model.dimensions) { + throw new Error( + `Embedding model produced ${embedding.length} dimensions; configured ${model.dimensions}` + ); + } + return model.output.normalize ? normalizeEmbedding(embedding) : embedding; +} + +function createFeeds(encoding, inputNames) { + const { + ids, + attention_mask: attentionMask, + token_type_ids: tokenTypeIds + } = normalizeEncoding(encoding); + const dimensions = [1, ids.length]; + const values = { + input_ids: new BigInt64Array(ids.map((id) => BigInt(id))), + attention_mask: new BigInt64Array(attentionMask.map((value) => BigInt(value))), + token_type_ids: new BigInt64Array(tokenTypeIds.map((value) => BigInt(value))) + }; + return Object.fromEntries( + inputNames.map((name) => [name, new Tensor('int64', values[name], dimensions)]) + ); +} + +function poolOutput(output, pooling) { + const { data, dims, type } = output; + if (!data || !Array.isArray(dims)) throw new Error('Embedding model returned an invalid tensor'); + if (type !== 'float32' && type !== 'float64') { + throw new Error(`Embedding model output must be float32 or float64, received '${type}'`); + } + + if (pooling === 'none') { + if (dims.length === 1) return Float32Array.from(data); + if (dims.length === 2 && dims[0] === 1) return Float32Array.from(data); + throw new Error("Pooling 'none' requires an output shaped [dimensions] or [1, dimensions]"); + } + + if (dims.length !== 3 || dims[0] !== 1 || data.length !== dims[1] * dims[2]) { + throw new Error(`Pooling '${pooling}' requires an output shaped [1, sequence, dimensions]`); + } + const [, sequenceLength, dimensions] = dims; + if (sequenceLength < 1) throw new Error('Embedding model returned an empty sequence'); + if (pooling === 'cls') return Float32Array.from(data.slice(0, dimensions)); + + const embedding = new Float32Array(dimensions); + for (let token = 0; token < sequenceLength; token++) { + for (let dimension = 0; dimension < dimensions; dimension++) { + embedding[dimension] += data[token * dimensions + dimension]; } - pooledEmbedding[i] = sum / sequenceLength; } + for (let dimension = 0; dimension < dimensions; dimension++) { + embedding[dimension] /= sequenceLength; + } + return embedding; +} + +function normalizeEmbedding(embedding) { + let squaredNorm = 0; + for (const value of embedding) squaredNorm += value * value; + const norm = Math.sqrt(squaredNorm); + if (norm === 0) return embedding; + for (let index = 0; index < embedding.length; index++) embedding[index] /= norm; + return embedding; +} - return pooledEmbedding; +function validateTokenIds(ids) { + if (!Array.isArray(ids)) throw new Error('Tokenizer did not return an ID array'); + for (const id of ids) { + if (!Number.isSafeInteger(id) || id < 0) { + throw new Error(`Invalid token ID detected: ${id} (type: ${typeof id})`); + } + } + return ids; +} + +function validateAttentionMask(mask) { + if (!Array.isArray(mask) || mask.some((value) => value !== 0 && value !== 1)) { + throw new Error('Tokenizer attention mask must contain only zeros and ones'); + } + return mask; } -let session = null; -let tokenizer = null; +let sessionRuntime; +let sessionInitialization; -async function createSession() { - const modelDir = getModelDir(); - await downloadModelIfNeeded(modelDir, MODEL); - ({ session, tokenizer } = await loadModelAndTokenizer(modelDir)); +async function createSession(configuration, options) { + sessionInitialization ??= createEmbeddingRuntime(configuration, options) + .then((runtime) => (sessionRuntime = runtime)) + .catch((error) => { + sessionInitialization = undefined; + throw error; + }); + return sessionInitialization; } function embedding(text) { - if (!session || !tokenizer) + if (!sessionRuntime) { throw new Error( 'Embedding session not initialized. Call createSession() before using embedding().' ); - const input = wordPieceTokenizer(text, tokenizer); - const vector = normalizeEmbedding(processEmbedding(input, session)); - - const chunkObj = { content: text }; - return Object.defineProperty(chunkObj, 'embedding', { - value: vector, + } + const chunk = { content: text }; + return Object.defineProperty(chunk, 'embedding', { + value: sessionRuntime.embedding(text), writable: true, configurable: true, enumerable: false }); - - function normalizeEmbedding(embedding) { - let norm = 0; - for (let i = 0; i < embedding.length; i++) { - norm += embedding[i] * embedding[i]; - } - norm = Math.sqrt(norm); - if (norm === 0) return embedding; // Guard against division by zero - for (let i = 0; i < embedding.length; i++) { - embedding[i] = embedding[i] / norm; - } - return embedding; - } } -/** - * Get the platform-specific data directory for the application - * @param {string} appName - The application name (defaults to 'semantic-search') - * @returns {string} The full path to the data directory - */ -function getDataDir(appName = 'semantic-search') { - const home = os.homedir(); - const dir = - os.platform() === 'win32' - ? process.env.LOCALAPPDATA || process.env.APPDATA || path.join(home, 'AppData', 'Local') - : process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'); - - return path.join(dir, appName); -} - -function getModelDir() { - const cacheRoot = process.env.CDS_AI_MODEL_CACHE || path.join(getDataDir(), 'models'); - return path.join(cacheRoot, MODEL.repository.replace('/', '_'), MODEL.revision); -} +export { + createSession, + createEmbeddingRuntime, + createEmbeddingRuntimeFromModel, + createFeeds, + createTokenizerState, + embedding, + poolOutput, + resolveEmbeddingModel, + tokenizeToWindow, + validateEmbeddingModel +}; export default embedding; -export { embedding, createSession, wordPieceTokenizer }; diff --git a/lib/vector_embedding/index.js b/lib/vector_embedding/index.js index edfa0b1..aad5cca 100644 --- a/lib/vector_embedding/index.js +++ b/lib/vector_embedding/index.js @@ -1,52 +1,30 @@ import cds from '@sap/cds'; +import * as embeddingModule from './embedding.js'; const LOG = cds.log('@cap-js/ai'); - -let embeddingModule; -let initialization; - -async function initializeEmbedding() { - if (embeddingModule) return embeddingModule; - - initialization ??= import('./embedding.js') - .then(async (module) => { - await module.createSession(); - LOG.info('Vector embedding ONNX model initialized'); - return (embeddingModule = module); - }) - .catch((error) => { - initialization = undefined; - throw error; - }); - - return initialization; +let loggedInitialization = false; +let dimensions; + +async function initializeEmbedding(configuration, options) { + const runtime = await embeddingModule.createSession(configuration, options); + dimensions = runtime.dimensions; + if (!loggedInitialization) { + LOG.info('Vector embedding ONNX model initialized'); + loggedInitialization = true; + } + return embeddingModule; } -const model_dimensions = { - 'SAP_GXY.20250407': 384, - 'SAP_GXY.20240715': 384 -}; - -/** - * Synchronous wrapper for vector embedding function. - * Generates embeddings using ONNX model. - * The model is initialized automatically when this module is imported. - * - * @param {string} text - Text to embed - * @param {string} text_type - Type of text (e.g., 'DOCUMENT') - * @param {string} model_and_version - Model identifier (e.g., 'SAP_GXY.20250407') - * @returns {string} JSON stringified array of embedding values - * @throws {Error} If embedding module failed to initialize or generation fails - */ function vector_embedding(text, text_type, model_and_version) { - if (!embeddingModule) { - throw new Error('Embedding module is not initialized'); - } - - if (text) { - return JSON.stringify(Array.from(embeddingModule.embedding(text).embedding)); + void text_type; // Retained for HANA-compatible function arity. + void model_and_version; // The configured embedding model determines vector dimensions. + if (text) return JSON.stringify(Array.from(embeddingModule.embedding(text).embedding)); + if (!dimensions) { + throw new Error( + 'Embedding session not initialized. Call initializeEmbedding() with an embedding configuration before using vector_embedding().' + ); } - return JSON.stringify(new Array(model_dimensions[model_and_version] ?? 384).fill(0)); + return JSON.stringify(new Array(dimensions).fill(0)); } export { initializeEmbedding, vector_embedding }; diff --git a/lib/vector_embedding/model-discovery.js b/lib/vector_embedding/model-discovery.js new file mode 100644 index 0000000..b2a716f --- /dev/null +++ b/lib/vector_embedding/model-discovery.js @@ -0,0 +1,390 @@ +import { createHash } from 'crypto'; + +import { assertSafeRepository, validateModelDescriptor } from './model-utils.js'; + +const HUGGING_FACE_ORIGIN = 'https://huggingface.co'; +const REQUIRED_ARTIFACTS = [ + { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx' }, + { role: 'tokenizer', name: 'tokenizer.json', path: 'tokenizer.json' }, + { + role: 'tokenizerConfig', + name: 'tokenizer_config.json', + path: 'tokenizer_config.json' + }, + { role: 'auxiliary', name: 'config.json', path: 'config.json' } +]; +const MODULES_FILE = 'modules.json'; +const SENTENCE_CONFIG_FILE = 'sentence_bert_config.json'; +const SUPPORTED_MODULES = new Set([ + 'sentence_transformers.models.Transformer', + 'sentence_transformers.models.Pooling', + 'sentence_transformers.models.Normalize' +]); + +async function discoverModel(repository, options = {}) { + assertSafeRepository(repository); + const fetchImpl = + typeof options === 'function' ? options : (options.fetchImpl ?? globalThis.fetch); + if (typeof fetchImpl !== 'function') throw new TypeError('A fetch implementation is required'); + + const context = createContext( + fetchImpl, + typeof options === 'function' ? undefined : options.origin + ); + const modelInfo = await fetchModelInfo(context, repository); + const revision = immutableRevision(modelInfo, repository); + const siblings = siblingMap(modelInfo, repository); + + const tokenizer = await fetchJsonFile(context, repository, revision, 'tokenizer.json'); + const tokenizerConfig = await fetchJsonFile( + context, + repository, + revision, + 'tokenizer_config.json' + ); + const config = await fetchJsonFile(context, repository, revision, 'config.json'); + const dimensions = positiveInteger(config.value.hidden_size); + if (!dimensions) { + throw new Error(`Cannot determine embedding dimensions from '${repository}/config.json'`); + } + + const selected = REQUIRED_ARTIFACTS.map((artifact) => { + const sibling = siblings.get(artifact.path); + if (!sibling) { + throw new Error( + `Hugging Face model '${repository}' must contain the exact file '${artifact.path}'` + ); + } + return { ...artifact, sibling }; + }); + const externalData = [...siblings.values()] + .filter(({ rfilename }) => /^onnx\/model\.onnx_data(?:$|[._-])/u.test(rfilename)) + .map((sibling) => ({ + role: 'auxiliary', + name: sibling.rfilename.slice('onnx/'.length), + path: sibling.rfilename, + sibling + })); + if (usesExternalData(config.value) && externalData.length === 0) { + throw new Error( + `Hugging Face model '${repository}' declares external ONNX data but does not contain 'onnx/model.onnx_data'` + ); + } + selected.push(...externalData); + + const semantics = await discoverSentenceTransformerSemantics( + context, + repository, + modelInfo, + new Set() + ); + const maxLength = minimumPositiveInteger([ + tokenizer.value?.truncation?.max_length, + tokenizerConfig.value.max_length, + semantics.maxLength, + tokenizerConfig.value.model_max_length, + config.value.max_position_embeddings + ]); + if (!maxLength) { + throw new Error(`Cannot determine the maximum input length for '${repository}'`); + } + + const knownFiles = new Map([ + ['tokenizer.json', tokenizer], + ['tokenizer_config.json', tokenizerConfig], + ['config.json', config] + ]); + const files = await Promise.all( + selected.map(async ({ sibling, ...artifact }) => ({ + ...artifact, + ...(await discoverFileIntegrity( + context, + repository, + revision, + sibling, + knownFiles.get(artifact.path) + )) + })) + ); + + return validateModelDescriptor({ + repository, + revision, + dimensions, + maxLength, + files, + output: { + name: 'last_hidden_state', + pooling: semantics.pooling, + normalize: semantics.normalize + } + }); +} + +function createContext(fetchImpl, origin = HUGGING_FACE_ORIGIN) { + if (typeof origin !== 'string' || !origin.trim()) { + throw new TypeError('The Hugging Face origin must be a non-empty string'); + } + return { fetchImpl, origin: origin.replace(/\/$/, ''), jsonFiles: new Map() }; +} + +function usesExternalData(config) { + const value = config?.['transformers.js_config']?.use_external_data_format; + return value === true || value?.['model.onnx'] === 1 || value?.['model.onnx'] === true; +} + +async function fetchModelInfo(context, repository) { + const url = `${context.origin}/api/models/${repositoryPath(repository)}?blobs=true`; + const response = await checkedFetch(context, url); + const value = await readJsonResponse(response, url); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid Hugging Face model information for '${repository}'`); + } + return value; +} + +function immutableRevision(modelInfo, repository) { + if (typeof modelInfo.sha !== 'string' || !/^[a-fA-F0-9]{40,64}$/.test(modelInfo.sha)) { + throw new Error(`Hugging Face did not return an immutable revision for '${repository}'`); + } + return modelInfo.sha.toLowerCase(); +} + +function siblingMap(modelInfo, repository) { + if (!Array.isArray(modelInfo.siblings)) { + throw new Error(`Hugging Face did not return a file list for '${repository}'`); + } + const siblings = new Map(); + for (const sibling of modelInfo.siblings) { + if (typeof sibling?.rfilename !== 'string') continue; + if (siblings.has(sibling.rfilename)) { + throw new Error(`Hugging Face returned duplicate file '${sibling.rfilename}'`); + } + siblings.set(sibling.rfilename, sibling); + } + return siblings; +} + +async function discoverSentenceTransformerSemantics(context, repository, modelInfo, visited) { + if (visited.has(repository)) { + throw new Error(`Circular Hugging Face base_model chain involving '${repository}'`); + } + visited.add(repository); + + const revision = immutableRevision(modelInfo, repository); + const siblings = siblingMap(modelInfo, repository); + if (siblings.has(MODULES_FILE)) { + return readSentenceTransformerSemantics(context, repository, revision, siblings); + } + + const baseModel = baseModelRepository(modelInfo.cardData?.base_model); + if (!baseModel) { + throw new Error( + `Cannot determine pooling and normalization for '${repository}': no Sentence Transformers modules or unambiguous base_model metadata` + ); + } + assertSafeRepository(baseModel); + const baseInfo = await fetchModelInfo(context, baseModel); + return discoverSentenceTransformerSemantics(context, baseModel, baseInfo, visited); +} + +async function readSentenceTransformerSemantics(context, repository, revision, siblings) { + const modules = (await fetchJsonFile(context, repository, revision, MODULES_FILE)).value; + if (!Array.isArray(modules)) { + throw new Error(`Invalid Sentence Transformers modules in '${repository}/${MODULES_FILE}'`); + } + + for (const module of modules) { + if (!module || typeof module.type !== 'string' || !SUPPORTED_MODULES.has(module.type)) { + throw new Error( + `Unsupported Sentence Transformers module '${module?.type ?? 'unknown'}' in '${repository}'` + ); + } + } + const expectedTypes = [ + 'sentence_transformers.models.Transformer', + 'sentence_transformers.models.Pooling' + ]; + if (modules.length === 3) expectedTypes.push('sentence_transformers.models.Normalize'); + if ( + modules.length < 2 || + modules.length > 3 || + modules.some((module, index) => module.type !== expectedTypes[index]) + ) { + throw new Error(`Cannot determine an unambiguous pooling pipeline for '${repository}'`); + } + + const poolingModule = modules[1]; + const poolingPath = moduleConfigPath(poolingModule, repository); + if (!siblings.has(poolingPath)) { + throw new Error(`Sentence Transformers pooling configuration '${poolingPath}' is missing`); + } + const poolingConfig = (await fetchJsonFile(context, repository, revision, poolingPath)).value; + const pooling = determinePooling(poolingConfig, repository); + + let maxLength; + const transformer = modules[0]; + const sentenceConfigPaths = [SENTENCE_CONFIG_FILE]; + if (transformer?.path) { + sentenceConfigPaths.unshift( + `${normalizedModulePath(transformer.path)}/${SENTENCE_CONFIG_FILE}` + ); + } + const sentenceConfigPath = sentenceConfigPaths.find((configPath) => siblings.has(configPath)); + if (sentenceConfigPath) { + const sentenceConfig = await fetchJsonFile(context, repository, revision, sentenceConfigPath); + maxLength = positiveInteger(sentenceConfig.value.max_seq_length); + } + + return { pooling, normalize: modules.length === 3, maxLength }; +} + +function moduleConfigPath(module, repository) { + const modulePath = normalizedModulePath(module.path); + if (!modulePath) { + throw new Error(`Sentence Transformers pooling module in '${repository}' has no path`); + } + return `${modulePath}/config.json`; +} + +function normalizedModulePath(value) { + if ( + typeof value !== 'string' || + !value || + value.includes('\\') || + value.startsWith('/') || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + return undefined; + } + return value; +} + +function determinePooling(config, repository) { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Invalid Sentence Transformers pooling configuration for '${repository}'`); + } + const enabled = [ + ['cls', config.pooling_mode_cls_token], + ['mean', config.pooling_mode_mean_tokens], + ['max', config.pooling_mode_max_tokens], + ['mean_sqrt_len', config.pooling_mode_mean_sqrt_len_tokens], + ['weightedmean', config.pooling_mode_weightedmean_tokens], + ['lasttoken', config.pooling_mode_lasttoken] + ].filter(([, value]) => value === true); + if (config.pooling_mode !== undefined) { + if (!['mean', 'cls'].includes(config.pooling_mode)) { + throw new Error(`Unsupported or ambiguous Sentence Transformers pooling for '${repository}'`); + } + if (enabled.length > 0 && (enabled.length !== 1 || enabled[0][0] !== config.pooling_mode)) { + throw new Error(`Unsupported or ambiguous Sentence Transformers pooling for '${repository}'`); + } + return config.pooling_mode; + } + if (enabled.length !== 1 || !['mean', 'cls'].includes(enabled[0][0])) { + throw new Error(`Unsupported or ambiguous Sentence Transformers pooling for '${repository}'`); + } + return enabled[0][0]; +} + +function baseModelRepository(value) { + if (typeof value === 'string') return value; + if (Array.isArray(value) && value.length === 1 && typeof value[0] === 'string') return value[0]; + if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.id === 'string') { + return value.id; + } + return undefined; +} + +async function discoverFileIntegrity(context, repository, revision, sibling, knownFile) { + const metadataChecksum = lfsChecksum(sibling); + const metadataSize = positiveInteger(sibling.size) ?? positiveInteger(sibling.lfs?.size); + if (metadataChecksum && metadataSize) { + return { size: metadataSize, sha256: metadataChecksum }; + } + + const file = knownFile ?? (await fetchFile(context, repository, revision, sibling.rfilename)); + return { + size: file.bytes.byteLength, + sha256: createHash('sha256').update(file.bytes).digest('hex') + }; +} + +function lfsChecksum(sibling) { + const candidate = sibling.lfs?.sha256 ?? sibling.lfs?.oid; + if (typeof candidate !== 'string') return undefined; + const checksum = candidate.replace(/^sha256:/, '').toLowerCase(); + return /^[a-f0-9]{64}$/.test(checksum) ? checksum : undefined; +} + +async function fetchJsonFile(context, repository, revision, remotePath) { + const key = `${repository}@${revision}/${remotePath}`; + let file = context.jsonFiles.get(key); + if (!file) { + file = fetchFile(context, repository, revision, remotePath).then(({ bytes, url }) => { + try { + return { bytes, value: JSON.parse(bytes.toString('utf8')) }; + } catch (error) { + throw new Error(`Invalid JSON returned from ${url}: ${error.message}`, { cause: error }); + } + }); + context.jsonFiles.set(key, file); + } + return file; +} + +async function fetchFile(context, repository, revision, remotePath) { + const url = `${context.origin}/${repositoryPath(repository)}/resolve/${revision}/${remotePath + .split('/') + .map(encodeURIComponent) + .join('/')}`; + const response = await checkedFetch(context, url); + return { bytes: await readBytes(response), url }; +} + +async function checkedFetch(context, url) { + let response; + try { + response = await context.fetchImpl(url); + } catch (error) { + throw new Error(`Cannot fetch ${url}: ${error.message}`, { cause: error }); + } + if (!response || response.ok !== true) { + throw new Error(`Cannot fetch ${url}: HTTP ${response?.status ?? 'unknown'}`); + } + return response; +} + +async function readJsonResponse(response, url) { + try { + if (typeof response.json === 'function') return await response.json(); + return JSON.parse((await readBytes(response)).toString('utf8')); + } catch (error) { + throw new Error(`Invalid JSON returned from ${url}: ${error.message}`, { cause: error }); + } +} + +async function readBytes(response) { + if (typeof response.arrayBuffer === 'function') { + return Buffer.from(await response.arrayBuffer()); + } + if (typeof response.text === 'function') return Buffer.from(await response.text()); + throw new Error('Fetch response does not expose arrayBuffer() or text()'); +} + +function positiveInteger(value) { + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function minimumPositiveInteger(values) { + const candidates = values.map(positiveInteger).filter((value) => value !== undefined); + return candidates.length > 0 ? Math.min(...candidates) : undefined; +} + +function repositoryPath(repository) { + return repository.split('/').map(encodeURIComponent).join('/'); +} + +const discoverModelDescriptor = discoverModel; + +export { discoverModel, discoverModelDescriptor }; diff --git a/lib/vector_embedding/model-install.js b/lib/vector_embedding/model-install.js new file mode 100644 index 0000000..13f2ed3 --- /dev/null +++ b/lib/vector_embedding/model-install.js @@ -0,0 +1,64 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { discoverModel } from './model-discovery.js'; +import { + MODEL_PROVISIONING_IN_PROGRESS, + assertSafeRepository, + getModelDirectory, + getModelRoot, + provisionModel, + readModelLock +} from './model-utils.js'; + +const MODEL_PROVISION_TIMEOUT_MS = 15 * 60 * 1000; +const MODEL_PROVISION_RETRY_MS = 250; + +async function installModel(repository, options = {}) { + assertSafeRepository(repository); + const modelRoot = getModelRoot(options.directory, options.root, options.home); + const modelDir = getModelDirectory(modelRoot, repository); + const discover = options.discover ?? discoverModel; + + let model; + try { + model = await readModelLock(modelDir); + assertRepository(model, repository, modelDir); + } catch (error) { + if (!/Embedding model lock not found/.test(error.message)) throw error; + model = await discover(repository, { fetchImpl: options.fetchImpl }); + assertRepository(model, repository, modelDir); + } + + const deadline = Date.now() + (options.timeoutMs ?? MODEL_PROVISION_TIMEOUT_MS); + const retryMs = options.retryMs ?? MODEL_PROVISION_RETRY_MS; + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + await provisionModel(model, { + directory: modelDir, + fetchImpl: options.fetchImpl, + validate: options.validate + }); + return { model, modelDir, modelRoot }; + } catch (error) { + if (error.code !== MODEL_PROVISIONING_IN_PROGRESS) throw error; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Timed out waiting for embedding model provisioning in ${modelDir}`, { + cause: error + }); + } + // eslint-disable-next-line no-await-in-loop + await delay(Math.min(retryMs, remaining)); + } + } +} + +function assertRepository(model, repository, modelDir) { + if (model.repository !== repository) { + throw new Error( + `Embedding model directory ${modelDir} contains ${model.repository}, not ${repository}. Choose another directory or remove it explicitly before installing the configured model.` + ); + } +} + +export { installModel }; diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index da2bb61..60c6771 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -1,10 +1,173 @@ import { createHash, randomUUID } from 'crypto'; import { createReadStream } from 'fs'; import fs from 'fs/promises'; +import os from 'os'; import path from 'path'; -import { InferenceSession } from './InferenceSession.js'; const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; +const MODEL_LOCK_FILE = 'embedding.lock.json'; +const MODEL_INSTALL_LOCK_FILE = '.embedding.install.lock'; +const MODEL_LOCK_VERSION = 1; +const MODEL_PROVISIONING_IN_PROGRESS = 'ERR_EMBEDDING_MODEL_PROVISIONING_IN_PROGRESS'; +const INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; +const PROVISIONED_DIRECTORY_MODE = 0o755; +const PROVISIONED_FILE_MODE = 0o644; +const REQUIRED_FILE_ROLES = ['model', 'tokenizer', 'tokenizerConfig']; +const ALLOWED_FILE_ROLES = new Set([...REQUIRED_FILE_ROLES, 'auxiliary']); +const RESERVED_ARTIFACT_PATHS = [MODEL_LOCK_FILE, MODEL_INSTALL_LOCK_FILE]; + +function validateModelDescriptor(model) { + if (!model || typeof model !== 'object' || Array.isArray(model)) { + throw new TypeError('The embedding model descriptor must be an object'); + } + + assertSafeRepository(model.repository); + if (typeof model.revision !== 'string' || !/^[a-fA-F0-9]{40,64}$/.test(model.revision)) { + throw new Error('embedding.revision must be an immutable 40-64 character commit hash'); + } + if (!Number.isSafeInteger(model.dimensions) || model.dimensions < 1) { + throw new Error('embedding.dimensions must be a positive integer'); + } + if (!Number.isSafeInteger(model.maxLength) || model.maxLength < 1) { + throw new Error('embedding.maxLength must be a positive integer'); + } + if (!Array.isArray(model.files) || model.files.length < REQUIRED_FILE_ROLES.length) { + throw new Error('embedding.files must include model, tokenizer, and tokenizerConfig files'); + } + + const names = new Set(); + const requiredRoles = new Set(); + for (const file of model.files) { + if (!file || typeof file !== 'object' || Array.isArray(file)) { + throw new TypeError('Each embedding file descriptor must be an object'); + } + if (!ALLOWED_FILE_ROLES.has(file.role)) { + throw new Error(`Unsupported embedding file role '${file.role}'`); + } + if (file.role !== 'auxiliary' && requiredRoles.has(file.role)) { + throw new Error(`Duplicate embedding file role '${file.role}'`); + } + requiredRoles.add(file.role); + assertSafeRelativePath(file.name, `embedding.files[${file.role}].name`); + assertSafeRelativePath(file.path, `embedding.files[${file.role}].path`); + if (names.has(file.name)) throw new Error(`Duplicate embedding file name '${file.name}'`); + for (const existingName of names) assertNoPathCollision(file.name, existingName); + for (const reservedPath of RESERVED_ARTIFACT_PATHS) { + assertNoPathCollision(file.name, reservedPath, 'provisioning metadata'); + } + names.add(file.name); + if (!Number.isSafeInteger(file.size) || file.size < 1) { + throw new Error(`Invalid size for embedding file '${file.name}'`); + } + if (typeof file.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256)) { + throw new Error(`Invalid SHA-256 for embedding file '${file.name}'`); + } + } + for (const role of REQUIRED_FILE_ROLES) { + if (!requiredRoles.has(role)) throw new Error(`Missing embedding file role '${role}'`); + } + + const output = model.output; + if (!output || typeof output !== 'object' || Array.isArray(output)) { + throw new Error('embedding.output must describe the model output'); + } + if (typeof output.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(output.name)) { + throw new Error('embedding.output.name must be a valid ONNX output name'); + } + if (!['mean', 'cls', 'none'].includes(output.pooling)) { + throw new Error("embedding.output.pooling must be 'mean', 'cls', or 'none'"); + } + if (typeof output.normalize !== 'boolean') { + throw new Error('embedding.output.normalize must be a boolean'); + } + + return model; +} + +function assertNoPathCollision(left, right, description = 'another embedding file') { + const normalizedLeft = left.toLowerCase(); + const normalizedRight = right.toLowerCase(); + if ( + normalizedLeft === normalizedRight || + normalizedLeft.startsWith(`${normalizedRight}/`) || + normalizedRight.startsWith(`${normalizedLeft}/`) + ) { + throw new Error(`Embedding file '${left}' conflicts with ${description} '${right}'`); + } +} + +function assertSafeRepository(repository) { + if ( + typeof repository !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(repository) || + repository.split('/').some((part) => part === '.' || part === '..') + ) { + throw new Error('embedding.repository must be a safe Hugging Face repository ID'); + } +} + +function getModelRoot(directory, root = process.cwd(), home = os.homedir()) { + if (directory === undefined) return path.join(root, '.cds', 'models'); + if (directory === '~') return home; + if (/^~[\\/]/.test(directory)) return path.join(home, directory.slice(2)); + return path.resolve(root, directory); +} + +function getModelDirectory(root, repository) { + assertSafeRepository(repository); + return path.join(root, ...repository.split('/')); +} + +function assertSafeRelativePath(value, field) { + const parts = typeof value === 'string' ? value.split('/') : []; + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + path.posix.isAbsolute(value) || + path.posix.normalize(value) !== value || + parts.some((part) => part === '' || part === '.' || part === '..') || + parts.some((part) => part.endsWith('.') || isWindowsDeviceName(part)) || + !/^[A-Za-z0-9._/-]+$/.test(value) + ) { + throw new Error(`${field} must be a safe relative path`); + } +} + +function isWindowsDeviceName(value) { + const basename = value.split('.')[0].toUpperCase(); + return /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/.test(basename); +} + +function fileForRole(model, role) { + return model.files.find((file) => file.role === role); +} + +function modelDescriptorDigest(model) { + validateModelDescriptor(model); + const files = model.files + .map(({ role, name, path: remotePath, size, sha256: checksum }) => ({ + role, + name, + path: remotePath, + size, + sha256: checksum + })) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); + const canonical = JSON.stringify({ + repository: model.repository, + revision: model.revision.toLowerCase(), + dimensions: model.dimensions, + maxLength: model.maxLength, + files, + output: { + name: model.output.name, + pooling: model.output.pooling, + normalize: model.output.normalize + } + }); + return createHash('sha256').update(canonical).digest('hex'); +} async function sha256(filePath) { const hash = createHash('sha256'); @@ -14,7 +177,7 @@ async function sha256(filePath) { async function isValidFile(filePath, file) { try { - const stat = await fs.stat(filePath); + const stat = await fs.lstat(filePath); return stat.isFile() && stat.size === file.size && (await sha256(filePath)) === file.sha256; } catch (error) { if (error.code === 'ENOENT') return false; @@ -22,6 +185,27 @@ async function isValidFile(filePath, file) { } } +async function verifyModelDirectory(modelDir, model) { + validateModelDescriptor(model); + await assertModelDirectory(modelDir); + const validity = await Promise.all( + model.files.map(async (file) => { + await assertNoSymlinkComponents(modelDir, file.name); + return { + file, + valid: await isValidFile(path.join(modelDir, file.name), file) + }; + }) + ); + const invalid = validity.filter(({ valid }) => !valid).map(({ file }) => file.name); + if (invalid.length > 0) { + throw new Error( + `Embedding model is not provisioned or failed integrity checks in ${modelDir}. Missing or invalid files: ${invalid.join(', ')}` + ); + } + return modelDir; +} + async function downloadFile(url, outputPath, file, options = {}) { const { fetchImpl = globalThis.fetch, timeoutMs = DOWNLOAD_TIMEOUT_MS } = options; const controller = new AbortController(); @@ -43,7 +227,8 @@ async function downloadFile(url, outputPath, file, options = {}) { throw new Error(`Refusing ${url}: response exceeds the expected ${file.size} bytes`); } - handle = await fs.open(temporaryPath, 'wx', 0o600); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + handle = await fs.open(temporaryPath, 'wx', PROVISIONED_FILE_MODE); const hash = createHash('sha256'); let bytesWritten = 0; @@ -69,6 +254,8 @@ async function downloadFile(url, outputPath, file, options = {}) { throw new Error(`Invalid SHA-256 for ${url}: expected ${file.sha256}, received ${digest}`); } + await fs.chmod(temporaryPath, PROVISIONED_FILE_MODE); + try { await fs.rename(temporaryPath, outputPath); } catch (error) { @@ -79,6 +266,7 @@ async function downloadFile(url, outputPath, file, options = {}) { await fs.rename(temporaryPath, outputPath); } } + await fs.chmod(outputPath, PROVISIONED_FILE_MODE); } catch (error) { if (error.name === 'AbortError') { throw new Error(`Timed out after ${timeoutMs} ms while downloading ${url}`, { cause: error }); @@ -92,12 +280,20 @@ async function downloadFile(url, outputPath, file, options = {}) { } async function downloadModelIfNeeded(modelDir, model, options) { - await fs.mkdir(modelDir, { recursive: true }); + validateModelDescriptor(model); + await ensureDirectory(modelDir); for (const file of model.files) { const filePath = path.join(modelDir, file.name); // eslint-disable-next-line no-await-in-loop - if (await isValidFile(filePath, file)) continue; + await prepareArtifactPath(modelDir, file.name); + // eslint-disable-next-line no-await-in-loop + if (await isValidFile(filePath, file)) { + // Keep build-time provisioning readable when the runtime uses another UID. + // eslint-disable-next-line no-await-in-loop + await fs.chmod(filePath, PROVISIONED_FILE_MODE); + continue; + } const url = `https://huggingface.co/${model.repository}/resolve/${model.revision}/${file.path}`; // Files are downloaded serially to avoid multiplying startup bandwidth and memory usage. @@ -106,136 +302,465 @@ async function downloadModelIfNeeded(modelDir, model, options) { } } -async function loadModelAndTokenizer(modelDir) { - const modelPath = path.join(modelDir, 'model.onnx'); - const tokenizerPath = path.join(modelDir, 'tokenizer.json'); - const tokenizerJson = JSON.parse(await fs.readFile(tokenizerPath, 'utf8')); +async function prepareArtifactPath(modelDir, relativePath) { + await assertNoSymlinkComponents(modelDir, relativePath); + let current = modelDir; + for (const part of relativePath.split('/').slice(0, -1)) { + current = path.join(current, part); + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(current, { mode: PROVISIONED_DIRECTORY_MODE }).catch((error) => { + if (error.code !== 'EEXIST') throw error; + }); + // eslint-disable-next-line no-await-in-loop + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`Embedding artifact path must not contain symbolic links: ${current}`); + } + if (!stat.isDirectory()) { + throw new Error(`Embedding artifact parent is not a directory: ${current}`); + } + // eslint-disable-next-line no-await-in-loop + await fs.chmod(current, PROVISIONED_DIRECTORY_MODE); + } + await assertNoSymlinkComponents(modelDir, relativePath); +} - if (!tokenizerJson.model?.vocab) { - throw new Error('Invalid tokenizer structure: missing model.vocab'); +async function assertNoSymlinkComponents(modelDir, relativePath) { + const exists = await assertModelDirectory(modelDir); + if (!exists) return; + let current = modelDir; + const parts = relativePath.split('/'); + for (let index = 0; index < parts.length; index++) { + current = path.join(current, parts[index]); + let stat; + try { + // eslint-disable-next-line no-await-in-loop + stat = await fs.lstat(current); + } catch (error) { + if (error.code === 'ENOENT') return; + throw error; + } + if (stat.isSymbolicLink()) { + throw new Error(`Embedding artifact path must not contain symbolic links: ${current}`); + } + if (index < parts.length - 1 && !stat.isDirectory()) { + throw new Error(`Embedding artifact parent is not a directory: ${current}`); + } } +} - const vocab = new Map(); - for (const [token, id] of Object.entries(tokenizerJson.model.vocab)) { - if (Number.isSafeInteger(id) && id >= 0) vocab.set(token, id); +async function assertModelDirectory(modelDir) { + try { + const stat = await fs.lstat(modelDir); + if (stat.isSymbolicLink()) { + throw new Error(`Embedding model directory must not be a symbolic link: ${modelDir}`); + } + if (!stat.isDirectory()) { + throw new Error(`Embedding model path is not a directory: ${modelDir}`); + } + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; } +} - const maxLength = tokenizerJson.truncation?.max_length; - if (!Number.isSafeInteger(maxLength) || maxLength < 2) { - throw new Error('Invalid tokenizer structure: missing truncation.max_length'); +async function ensureDirectory(directory) { + await ensureParentDirectories(directory); + await assertModelDirectory(directory); + await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE); +} + +async function ensureParentDirectories(directory) { + const missing = []; + let current = directory; + // eslint-disable-next-line no-await-in-loop + while (!(await pathExists(current))) { + missing.push(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; } - const session = await InferenceSession.create(modelPath); - return { - session, - tokenizer: { - vocab, - maxLength, - normalizer: tokenizerJson.normalizer ?? {} + for (const missingDirectory of missing.reverse()) { + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(missingDirectory, { mode: PROVISIONED_DIRECTORY_MODE }).catch((error) => { + if (error.code !== 'EEXIST') throw error; + }); + // Explicit chmod avoids umask making build-time model directories unreadable at runtime. + // eslint-disable-next-line no-await-in-loop + await fs.chmod(missingDirectory, PROVISIONED_DIRECTORY_MODE); + } +} + +async function pathExists(filePath) { + try { + await fs.lstat(filePath); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +async function canonicalizeProvisioningDirectory(directory) { + const requestedDirectory = path.resolve(directory); + let current = requestedDirectory; + const missing = []; + + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + const stat = await fs.lstat(current); + if (current === requestedDirectory && stat.isSymbolicLink()) { + throw new Error(`Embedding model directory must not be a symbolic link: ${directory}`); + } + // eslint-disable-next-line no-await-in-loop + const canonicalAncestor = await fs.realpath(current); + // eslint-disable-next-line no-await-in-loop + const canonicalStat = await fs.stat(canonicalAncestor); + if (!canonicalStat.isDirectory()) { + throw new Error(`Embedding model path is not a directory: ${current}`); + } + return path.join(canonicalAncestor, ...missing.reverse()); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + missing.push(path.basename(current)); + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; } - }; + } } -function preTokenize(text, normalizer = {}) { - const { - clean_text: cleanText = true, - handle_chinese_chars: handleChineseChars = true, - lowercase = true, - strip_accents: configuredStripAccents - } = normalizer; - const stripAccents = configuredStripAccents ?? lowercase; - let normalized = String(text); - - if (cleanText) { - normalized = Array.from(normalized, (character) => { - if (/\s/u.test(character)) return ' '; - if (character.codePointAt(0) === 0 || character.codePointAt(0) === 0xfffd) return ''; - if (/[\p{Cc}\p{Cf}]/u.test(character)) return ''; - return character; - }).join(''); - } - - if (handleChineseChars) { - normalized = Array.from(normalized, (character) => - isChineseCharacter(character.codePointAt(0)) ? ` ${character} ` : character - ).join(''); - } - - const output = []; - for (let token of normalized.trim().split(/\s+/u)) { - if (!token) continue; - if (lowercase) token = token.toLowerCase(); - if (stripAccents) token = token.normalize('NFD').replace(/\p{M}/gu, ''); - - let current = ''; - for (const character of token) { - if (/\p{P}/u.test(character)) { - if (current) output.push(current); - output.push(character); - current = ''; - } else current += character; - } - if (current) output.push(current); - } - return output; -} - -function isChineseCharacter(codePoint) { - return ( - (codePoint >= 0x4e00 && codePoint <= 0x9fff) || - (codePoint >= 0x3400 && codePoint <= 0x4dbf) || - (codePoint >= 0x20000 && codePoint <= 0x2a6df) || - (codePoint >= 0x2a700 && codePoint <= 0x2b73f) || - (codePoint >= 0x2b740 && codePoint <= 0x2b81f) || - (codePoint >= 0x2b820 && codePoint <= 0x2ceaf) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0x2f800 && codePoint <= 0x2fa1f) - ); +async function readModelLock(modelDir) { + const lockPath = path.join(modelDir, MODEL_LOCK_FILE); + let lock; + try { + await assertModelDirectory(modelDir); + const stat = await fs.lstat(lockPath); + if (stat.isSymbolicLink()) { + throw new Error(`Embedding model lock must not be a symbolic link at ${lockPath}`); + } + lock = JSON.parse(await fs.readFile(lockPath, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error(`Embedding model lock not found at ${lockPath}`, { cause: error }); + } + throw new Error(`Cannot read embedding model lock at ${lockPath}: ${error.message}`, { + cause: error + }); + } + if (lock.formatVersion !== MODEL_LOCK_VERSION) { + throw new Error( + `Unsupported embedding model lock version ${lock.formatVersion ?? 'missing'} at ${lockPath}` + ); + } + const { formatVersion, ...model } = lock; + void formatVersion; + return validateModelDescriptor(model); } -function wordPieceTokenize(token, vocab, unkToken = '[UNK]', maxInputCharsPerWord = 100) { - if (Array.from(token).length > maxInputCharsPerWord) return [unkToken]; +async function writeModelLock(modelDir, model) { + const lockPath = path.join(modelDir, MODEL_LOCK_FILE); + const temporaryPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`; + const contents = `${JSON.stringify({ ...model, formatVersion: MODEL_LOCK_VERSION }, null, 2)}\n`; + let handle; + try { + await ensureDirectory(modelDir); + handle = await fs.open(temporaryPath, 'wx', PROVISIONED_FILE_MODE); + await handle.writeFile(contents); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.chmod(temporaryPath, PROVISIONED_FILE_MODE); + await fs.rename(temporaryPath, lockPath); + await fs.chmod(lockPath, PROVISIONED_FILE_MODE); + } finally { + await handle?.close().catch(() => {}); + await fs.unlink(temporaryPath).catch(() => {}); + } +} - const outputTokens = []; - let start = 0; - while (start < token.length) { - let end = token.length; - let currentSubstring = null; +async function provisionModel(model, options = {}) { + validateModelDescriptor(model); + if (typeof options.directory !== 'string' || !options.directory.trim()) { + throw new Error('A non-empty provisioning directory is required'); + } + const requestedDirectory = path.resolve(options.directory); + const directory = await canonicalizeProvisioningDirectory(requestedDirectory); + await ensureParentDirectories(path.dirname(directory)); - while (start < end) { - let substring = token.substring(start, end); - if (start > 0) substring = '##' + substring; - if (vocab.has(substring)) { - currentSubstring = substring; - break; + return withInstallLock(directory, async () => { + const directoryExists = await assertModelDirectory(directory); + let lockedModel; + try { + lockedModel = await readModelLock(directory); + if (modelDescriptorDigest(lockedModel) !== modelDescriptorDigest(model)) { + throw new Error( + `Embedding model directory ${directory} is locked to a different model descriptor for ${lockedModel.repository}@${lockedModel.revision}. Choose another directory or remove it explicitly.` + ); + } + } catch (error) { + if (!/Embedding model lock not found/.test(error.message)) throw error; + } + + if (directoryExists) { + try { + await verifyModelDirectory(directory, model); + if (!lockedModel) await writeModelLock(directory, model); + await makeModelDirectoryReadable(directory, model); + await options.validate?.(directory, model); + return directory; + } catch (error) { + if (/symbolic link/.test(error.message)) throw error; + if (!lockedModel && (await directoryHasEntries(directory))) { + throw new Error( + `Embedding model directory ${directory} is not empty and has no valid lock. Choose an empty directory or remove its contents explicitly.`, + { cause: error } + ); + } } - end -= 1; } - if (currentSubstring === null) return [unkToken]; + const stagingDirectory = await createStagingDirectory(directory); + let published = false; + try { + await downloadModelIfNeeded(stagingDirectory, model, options); + await verifyModelDirectory(stagingDirectory, model); + await writeModelLock(stagingDirectory, model); + await options.validate?.(stagingDirectory, model); + await publishModelDirectory(stagingDirectory, directory); + published = true; + } finally { + if (!published) await fs.rm(stagingDirectory, { recursive: true, force: true }); + } + return directory; + }); +} + +async function withInstallLock(directory, callback) { + const lockPath = path.join( + path.dirname(directory), + `.${path.basename(directory)}${MODEL_INSTALL_LOCK_FILE}` + ); + const owner = { + formatVersion: 1, + pid: process.pid, + hostname: os.hostname(), + createdAt: new Date().toISOString(), + token: randomUUID() + }; + let handle; + let heartbeat; + try { + handle = await acquireInstallLock(lockPath, directory, owner); + heartbeat = setInterval( + () => { + const now = new Date(); + fs.utimes(lockPath, now, now).catch(() => {}); + }, + Math.min(INSTALL_LOCK_STALE_MS / 3, 60 * 1000) + ); + heartbeat.unref(); + return await callback(); + } finally { + clearInterval(heartbeat); + await handle?.close().catch(() => {}); + if (handle) await releaseInstallLock(lockPath, owner); + } +} + +async function acquireInstallLock(lockPath, directory, owner) { + try { + return await createInstallLock(lockPath, owner); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + if (await recoverStaleInstallLock(lockPath)) { + return createInstallLock(lockPath, owner); + } + throw Object.assign( + new Error(`Embedding model directory ${directory} is already being provisioned`, { + cause: error + }), + { code: MODEL_PROVISIONING_IN_PROGRESS } + ); + } +} + +async function createInstallLock(lockPath, owner) { + let handle; + try { + handle = await fs.open(lockPath, 'wx', 0o600); + await handle.writeFile(`${JSON.stringify(owner)}\n`); + await handle.sync(); + return handle; + } catch (error) { + await handle?.close().catch(() => {}); + if (handle) await fs.unlink(lockPath).catch(() => {}); + throw error; + } +} + +async function recoverStaleInstallLock(lockPath) { + let contents; + let stat; + try { + [contents, stat] = await Promise.all([fs.readFile(lockPath, 'utf8'), fs.lstat(lockPath)]); + } catch (error) { + return error.code === 'ENOENT'; + } + + let owner; + try { + owner = JSON.parse(contents); + } catch { + if (Date.now() - stat.mtimeMs <= INSTALL_LOCK_STALE_MS) return false; + } + if (!isStaleInstallLock(owner, stat)) return false; + + const stalePath = `${lockPath}.${randomUUID()}.stale`; + try { + await fs.rename(lockPath, stalePath); + const movedContents = await fs.readFile(stalePath, 'utf8'); + if (movedContents !== contents) { + await fs.rename(stalePath, lockPath).catch(() => {}); + return false; + } + await fs.unlink(stalePath); + return true; + } catch (error) { + await fs.unlink(stalePath).catch(() => {}); + return error.code === 'ENOENT'; + } +} + +function isStaleInstallLock(owner, stat) { + if (!owner || typeof owner !== 'object') { + return Date.now() - stat.mtimeMs > INSTALL_LOCK_STALE_MS; + } + if (owner.hostname === os.hostname() && Number.isSafeInteger(owner.pid)) { + try { + process.kill(owner.pid, 0); + return false; + } catch (error) { + if (error.code === 'EPERM') return false; + if (error.code === 'ESRCH') return true; + return false; + } + } + return Date.now() - stat.mtimeMs > INSTALL_LOCK_STALE_MS; +} - outputTokens.push(currentSubstring); - start = end; +async function releaseInstallLock(lockPath, owner) { + try { + const currentOwner = JSON.parse(await fs.readFile(lockPath, 'utf8')); + if (currentOwner.token === owner.token) await fs.unlink(lockPath); + } catch (error) { + if (error.code !== 'ENOENT') throw error; } +} - return outputTokens; +async function createStagingDirectory(directory) { + const parent = path.dirname(directory); + const stagingDirectory = await fs.mkdtemp( + path.join(parent, `.${path.basename(directory)}.staging-`) + ); + await fs.chmod(stagingDirectory, PROVISIONED_DIRECTORY_MODE); + return stagingDirectory; } -function validateTokenIds(ids) { - ids.forEach((id) => { - if (!Number.isSafeInteger(id) || id < 0) { - throw new Error(`Invalid token ID detected: ${id} (type: ${typeof id})`); +async function publishModelDirectory(stagingDirectory, directory) { + const targetExists = await assertModelDirectory(directory); + let backupDirectory; + if (targetExists) { + backupDirectory = path.join( + path.dirname(directory), + `.${path.basename(directory)}.${randomUUID()}.backup` + ); + await fs.rename(directory, backupDirectory); + } + + try { + await fs.rename(stagingDirectory, directory); + } catch (error) { + if (backupDirectory) await fs.rename(backupDirectory, directory).catch(() => {}); + throw error; + } + if (backupDirectory) await fs.rm(backupDirectory, { recursive: true, force: true }); +} + +async function directoryHasEntries(directory) { + return (await fs.readdir(directory)).length > 0; +} + +async function makeModelDirectoryReadable(directory, model) { + await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE); + for (const file of model.files) { + let current = directory; + for (const part of file.name.split('/').slice(0, -1)) { + current = path.join(current, part); + // eslint-disable-next-line no-await-in-loop + await fs.chmod(current, PROVISIONED_DIRECTORY_MODE); } - }); - return ids; + // eslint-disable-next-line no-await-in-loop + await fs.chmod(path.join(directory, file.name), PROVISIONED_FILE_MODE); + } + await fs.chmod(path.join(directory, MODEL_LOCK_FILE), PROVISIONED_FILE_MODE); +} + +async function loadModelAndTokenizer(modelDir, model) { + const [{ Tokenizer }, { InferenceSession }] = await Promise.all([ + loadTokenizerPackage(), + import('./InferenceSession.js') + ]); + const modelPath = path.join(modelDir, fileForRole(model, 'model').name); + const tokenizerPath = path.join(modelDir, fileForRole(model, 'tokenizer').name); + const tokenizerConfigPath = path.join(modelDir, fileForRole(model, 'tokenizerConfig').name); + const [tokenizerJson, tokenizerConfig] = await Promise.all([ + fs.readFile(tokenizerPath, 'utf8').then(JSON.parse), + fs.readFile(tokenizerConfigPath, 'utf8').then(JSON.parse) + ]); + const tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig); + + return { + session: await InferenceSession.create(modelPath), + tokenizer + }; +} + +async function loadTokenizerPackage(importModule = (specifier) => import(specifier)) { + try { + return await importModule('@huggingface/tokenizers'); + } catch (error) { + if ( + error?.code === 'ERR_MODULE_NOT_FOUND' && + /Cannot find package ['"]@huggingface\/tokenizers['"]/.test(error.message) + ) { + throw new Error( + "Using ai-sqlite embeddings requires @huggingface/tokenizers@0.1.3. Install it with 'npm add @huggingface/tokenizers@0.1.3'.", + { cause: error } + ); + } + throw error; + } } export { + MODEL_LOCK_FILE, + MODEL_PROVISIONING_IN_PROGRESS, + assertSafeRepository, downloadFile, downloadModelIfNeeded, + fileForRole, + getModelDirectory, + getModelRoot, isValidFile, loadModelAndTokenizer, - preTokenize, - wordPieceTokenize, - validateTokenIds + loadTokenizerPackage, + modelDescriptorDigest, + provisionModel, + readModelLock, + verifyModelDirectory, + validateModelDescriptor }; diff --git a/package.json b/package.json index 7fe7fa6..dd49bae 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,14 @@ "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", "main": "cds-plugin.js", + "bin": { + "cds-ai": "bin/cds-ai.js" + }, "scripts": { "lint": "npx -y eslint@10 .", + "test:model:provision": "node bin/cds-ai.js install-model Xenova/all-MiniLM-L6-v2", + "pretest": "npm run test:model:provision", + "pretest:hybrid": "npm run test:model:provision", "test": "node --test tests/*.test.js", "test:hybrid": "cds bind --exec -- node --test tests/*.test.js tests/integration/*.test.js", "format": "npx -y prettier@3 . --write && format-cds -f", @@ -17,6 +23,7 @@ }, "files": [ "CHANGELOG.md", + "bin", "lib", "srv" ], @@ -24,11 +31,13 @@ "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", "@cap-js/sqlite": ">=2", + "@huggingface/tokenizers": "0.1.3", "onnxruntime-node": "1.20.1", "oxigraph": "^0.5.9" }, "peerDependencies": { "@cap-js/sqlite": ">=2", + "@huggingface/tokenizers": "0.1.3", "@sap/cds": ">=9", "onnxruntime-node": "1.20.1", "oxigraph": "^0.5.9" @@ -37,6 +46,9 @@ "@cap-js/sqlite": { "optional": true }, + "@huggingface/tokenizers": { + "optional": true + }, "onnxruntime-node": { "optional": true }, diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index 72eceac..52b84cb 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -14,6 +14,7 @@ describe('ai-sqlite knowledge graph', () => { before(async () => { db = await cds.connect.to('knowledge-graph-db', { kind: 'ai-sqlite', + embedding: { model: 'Xenova/all-MiniLM-L6-v2' }, credentials: { url: ':memory:' } }); }); diff --git a/tests/model-discovery.test.js b/tests/model-discovery.test.js new file mode 100644 index 0000000..e6020e2 --- /dev/null +++ b/tests/model-discovery.test.js @@ -0,0 +1,326 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, test } from 'node:test'; + +import { discoverModel } from '../lib/vector_embedding/model-discovery.js'; + +const REVISION = '1'.repeat(40); +const BASE_REVISION = '2'.repeat(40); +const REPOSITORY = 'example/embedding-model'; +const BASE_REPOSITORY = 'sentence-transformers/base-model'; + +describe('Hugging Face model discovery', () => { + test('creates a validated descriptor from exact artifacts and Sentence Transformers metadata', async () => { + const routes = modelRoutes({ + tokenizer: { truncation: { max_length: 96 } }, + tokenizerConfig: { model_max_length: 128 }, + config: { hidden_size: 384, max_position_embeddings: 512 }, + sentenceConfig: { max_seq_length: 256 }, + normalize: true + }); + + const descriptor = await discoverModel(REPOSITORY, { fetchImpl: createFetch(routes) }); + + assert.equal(descriptor.repository, REPOSITORY); + assert.equal(descriptor.revision, REVISION); + assert.equal(descriptor.dimensions, 384); + assert.equal(descriptor.maxLength, 96); + assert.deepEqual(descriptor.output, { + name: 'last_hidden_state', + pooling: 'mean', + normalize: true + }); + assert.deepEqual( + descriptor.files.map(({ role, name, path }) => ({ role, name, path })), + [ + { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx' }, + { role: 'tokenizer', name: 'tokenizer.json', path: 'tokenizer.json' }, + { + role: 'tokenizerConfig', + name: 'tokenizer_config.json', + path: 'tokenizer_config.json' + }, + { role: 'auxiliary', name: 'config.json', path: 'config.json' } + ] + ); + const tokenizerFile = descriptor.files.find(({ role }) => role === 'tokenizer'); + assert.equal( + tokenizerFile.sha256, + digest(routes[fileUrl(REPOSITORY, REVISION, 'tokenizer.json')]) + ); + assert.equal( + tokenizerFile.size, + routes[fileUrl(REPOSITORY, REVISION, 'tokenizer.json')].length + ); + }); + + test('follows a pinned base_model for semantics and Sentence Transformers max length', async () => { + const routes = modelRoutes({ + tokenizer: { truncation: null }, + tokenizerConfig: { model_max_length: 128 }, + config: { hidden_size: 768, max_position_embeddings: 512 }, + modules: false, + baseModel: BASE_REPOSITORY + }); + addBaseModelRoutes(routes, { + sentenceConfig: { max_seq_length: 64 }, + pooling: 'cls', + normalize: false + }); + + const requested = []; + const descriptor = await discoverModel(REPOSITORY, { + fetchImpl: createFetch(routes, requested) + }); + + assert.equal(descriptor.maxLength, 64); + assert.equal(descriptor.output.pooling, 'cls'); + assert.equal(descriptor.output.normalize, false); + assert.ok( + requested.includes(apiUrl(BASE_REPOSITORY)), + 'the base repository is resolved through the model API' + ); + assert.ok( + requested.some((url) => url.includes(`/resolve/${BASE_REVISION}/modules.json`)), + 'base-model metadata is read from its immutable revision' + ); + }); + + test('falls back from tokenizer configuration to model configuration for max length', async () => { + const tokenizerRoutes = modelRoutes({ + tokenizer: { truncation: null }, + tokenizerConfig: { model_max_length: 192 }, + config: { hidden_size: 32, max_position_embeddings: 256 }, + sentenceConfig: undefined + }); + assert.equal( + (await discoverModel(REPOSITORY, { fetchImpl: createFetch(tokenizerRoutes) })).maxLength, + 192 + ); + + const configRoutes = modelRoutes({ + tokenizer: { truncation: null }, + tokenizerConfig: { model_max_length: 1e30 }, + config: { hidden_size: 32, max_position_embeddings: 256 }, + sentenceConfig: undefined + }); + assert.equal( + (await discoverModel(REPOSITORY, { fetchImpl: createFetch(configRoutes) })).maxLength, + 256 + ); + }); + + test('uses the lowest declared tokenizer and model input limit', async () => { + const routes = modelRoutes({ + tokenizer: { truncation: null }, + tokenizerConfig: { max_length: 128, model_max_length: 512 }, + config: { hidden_size: 32, max_position_embeddings: 256 }, + sentenceConfig: { max_seq_length: 384 } + }); + + assert.equal( + (await discoverModel(REPOSITORY, { fetchImpl: createFetch(routes) })).maxLength, + 128 + ); + }); + + test('includes external ONNX data files', async () => { + const routes = modelRoutes({ externalData: true }); + const descriptor = await discoverModel(REPOSITORY, { fetchImpl: createFetch(routes) }); + + assert.deepEqual( + descriptor.files.find(({ path }) => path === 'onnx/model.onnx_data'), + { + role: 'auxiliary', + name: 'model.onnx_data', + path: 'onnx/model.onnx_data', + size: routes[fileUrl(REPOSITORY, REVISION, 'onnx/model.onnx_data')].length, + sha256: digest(routes[fileUrl(REPOSITORY, REVISION, 'onnx/model.onnx_data')]) + } + ); + }); + + test('rejects missing exact artifacts and ambiguous runtime semantics', async () => { + const missing = modelRoutes({}); + const info = JSON.parse(missing[apiUrl(REPOSITORY)].toString()); + info.siblings = info.siblings.filter(({ rfilename }) => rfilename !== 'onnx/model.onnx'); + missing[apiUrl(REPOSITORY)] = json(info); + await assert.rejects( + discoverModel(REPOSITORY, { fetchImpl: createFetch(missing) }), + /exact file 'onnx\/model\.onnx'/ + ); + + const ambiguous = modelRoutes({ pooling: ['mean', 'cls'] }); + await assert.rejects( + discoverModel(REPOSITORY, { fetchImpl: createFetch(ambiguous) }), + /Unsupported or ambiguous Sentence Transformers pooling/ + ); + + const invalidOrder = modelRoutes({ moduleOrder: ['Pooling', 'Transformer'] }); + await assert.rejects( + discoverModel(REPOSITORY, { fetchImpl: createFetch(invalidOrder) }), + /unambiguous pooling pipeline/ + ); + + const conflicting = modelRoutes({ pooling: 'mean', poolingMode: 'cls' }); + await assert.rejects( + discoverModel(REPOSITORY, { fetchImpl: createFetch(conflicting) }), + /Unsupported or ambiguous Sentence Transformers pooling/ + ); + }); + + test('downloads an artifact to derive integrity when Hugging Face has no LFS metadata', async () => { + const routes = modelRoutes({ modelMetadata: false }); + const descriptor = await discoverModel(REPOSITORY, createFetch(routes)); + const model = descriptor.files.find(({ role }) => role === 'model'); + const contents = routes[fileUrl(REPOSITORY, REVISION, 'onnx/model.onnx')]; + assert.equal(model.size, contents.length); + assert.equal(model.sha256, digest(contents)); + }); +}); + +function modelRoutes(options = {}) { + const tokenizer = json(options.tokenizer ?? { truncation: { max_length: 96 } }); + const tokenizerConfig = json(options.tokenizerConfig ?? { model_max_length: 128 }); + const configValue = options.config ?? { hidden_size: 384, max_position_embeddings: 512 }; + if (options.externalData) { + configValue['transformers.js_config'] = { + use_external_data_format: { 'model.onnx': 1 } + }; + } + const config = json(configValue); + const model = Buffer.from('fake onnx model'); + const moduleTypes = options.moduleOrder ?? [ + 'Transformer', + 'Pooling', + ...(options.normalize === false ? [] : ['Normalize']) + ]; + const modules = json( + moduleTypes.map((type, index) => ({ + idx: index, + name: String(index), + path: type === 'Pooling' ? '1_Pooling' : '', + type: `sentence_transformers.models.${type}` + })) + ); + const pooling = json(poolingConfig(options.pooling ?? 'mean', options.poolingMode)); + const files = { + 'onnx/model.onnx': model, + 'tokenizer.json': tokenizer, + 'tokenizer_config.json': tokenizerConfig, + 'config.json': config + }; + if (options.externalData) files['onnx/model.onnx_data'] = Buffer.from('external weights'); + if (options.modules !== false) { + files['modules.json'] = modules; + files['1_Pooling/config.json'] = pooling; + if (options.sentenceConfig !== undefined) { + files['sentence_bert_config.json'] = json(options.sentenceConfig); + } else if (!Object.hasOwn(options, 'sentenceConfig')) { + files['sentence_bert_config.json'] = json({ max_seq_length: 256 }); + } + } + const siblings = Object.entries(files).map(([rfilename, contents]) => ({ + rfilename, + ...(rfilename === 'onnx/model.onnx' && options.modelMetadata !== false + ? { size: contents.length, lfs: { size: contents.length, sha256: digest(contents) } } + : {}) + })); + return { + [apiUrl(REPOSITORY)]: json({ + sha: REVISION, + siblings, + ...(options.baseModel ? { cardData: { base_model: options.baseModel } } : {}) + }), + ...Object.fromEntries( + Object.entries(files).map(([name, contents]) => [ + fileUrl(REPOSITORY, REVISION, name), + contents + ]) + ) + }; +} + +function addBaseModelRoutes(routes, options = {}) { + const modules = json([ + { + idx: 0, + path: '', + type: 'sentence_transformers.models.Transformer' + }, + { + idx: 1, + path: '1_Pooling', + type: 'sentence_transformers.models.Pooling' + }, + ...(options.normalize + ? [{ idx: 2, path: '2_Normalize', type: 'sentence_transformers.models.Normalize' }] + : []) + ]); + const pooling = json(poolingConfig(options.pooling ?? 'mean')); + const sentenceConfig = json(options.sentenceConfig ?? { max_seq_length: 128 }); + const files = { + 'modules.json': modules, + '1_Pooling/config.json': pooling, + 'sentence_bert_config.json': sentenceConfig + }; + routes[apiUrl(BASE_REPOSITORY)] = json({ + sha: BASE_REVISION, + siblings: Object.keys(files).map((rfilename) => ({ rfilename })) + }); + for (const [name, contents] of Object.entries(files)) { + routes[fileUrl(BASE_REPOSITORY, BASE_REVISION, name)] = contents; + } +} + +function poolingConfig(pooling, poolingMode) { + const enabled = Array.isArray(pooling) ? pooling : [pooling]; + return { + ...(poolingMode ? { pooling_mode: poolingMode } : {}), + pooling_mode_cls_token: enabled.includes('cls'), + pooling_mode_mean_tokens: enabled.includes('mean'), + pooling_mode_max_tokens: enabled.includes('max'), + pooling_mode_mean_sqrt_len_tokens: false, + pooling_mode_weightedmean_tokens: false, + pooling_mode_lasttoken: false + }; +} + +function createFetch(routes, requested = []) { + return async (input) => { + const url = String(input); + requested.push(url); + const contents = routes[url]; + if (!contents) return response(Buffer.alloc(0), 404); + return response(contents, 200); + }; +} + +function response(contents, status) { + return { + ok: status >= 200 && status < 300, + status, + async json() { + return JSON.parse(contents.toString()); + }, + async arrayBuffer() { + return contents; + } + }; +} + +function apiUrl(repository) { + return `https://huggingface.co/api/models/${repository}?blobs=true`; +} + +function fileUrl(repository, revision, name) { + return `https://huggingface.co/${repository}/resolve/${revision}/${name}`; +} + +function json(value) { + return Buffer.from(JSON.stringify(value)); +} + +function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/tests/model-provisioning.test.js b/tests/model-provisioning.test.js new file mode 100644 index 0000000..01c7d0d --- /dev/null +++ b/tests/model-provisioning.test.js @@ -0,0 +1,598 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, test } from 'node:test'; +import { runModelCommand } from '../lib/vector_embedding/cli.js'; +import { resolveEmbeddingModel } from '../lib/vector_embedding/embedding.js'; +import { + MODEL_LOCK_FILE, + getModelDirectory, + getModelRoot, + provisionModel, + readModelLock, + verifyModelDirectory +} from '../lib/vector_embedding/model-utils.js'; + +const temporaryDirectories = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })) + ); +}); + +describe('runtime model configuration', () => { + test('accepts only model and directory in runtime configuration', async () => { + const model = fixtureModel(Buffer.from('configuration fixture')); + await assert.rejects( + resolveEmbeddingModel(), + /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ + ); + await assert.rejects( + resolveEmbeddingModel({}), + /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ + ); + await assert.rejects( + resolveEmbeddingModel(model.repository), + /embedding must be an object with model and optional directory/ + ); + await assert.rejects( + resolveEmbeddingModel({ ...model }), + /Only model and directory are supported/ + ); + await assert.rejects( + resolveEmbeddingModel({ model: model.repository, directory: '' }), + /embedding\.directory must be a non-empty string/ + ); + }); +}); + +describe('explicit model provisioning', () => { + test('downloads, verifies, and locks a model idempotently', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('verified model fixture'); + const model = fixtureModel(content); + const requestedUrls = []; + const fetchImpl = createFetch(content, requestedUrls); + + await provisionModel(model, { directory, fetchImpl }); + await provisionModel(model, { directory, fetchImpl }); + + assert.deepEqual( + requestedUrls, + model.files.map( + (file) => `https://huggingface.co/example/model/resolve/${model.revision}/${file.path}` + ) + ); + assert.deepEqual(await readModelLock(directory), model); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, MODEL_LOCK_FILE), 'utf8')), { + ...model, + formatVersion: 1 + }); + assert.deepEqual((await fs.readdir(directory)).sort(), [ + MODEL_LOCK_FILE, + 'model.onnx', + 'tokenizer.json', + 'tokenizer_config.json' + ]); + const modes = await Promise.all( + [MODEL_LOCK_FILE, ...model.files.map(({ name }) => name)].map(async (file) => + fs.stat(path.join(directory, file)).then(({ mode }) => mode & 0o777) + ) + ); + assert.deepEqual(modes, new Array(modes.length).fill(0o644)); + }); + + test('restores readable permissions on already valid artifacts', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('readable model fixture'); + const model = fixtureModel(content); + const modelPath = path.join(directory, model.files[0].name); + await provisionModel(model, { directory, fetchImpl: createFetch(content) }); + await fs.chmod(modelPath, 0o600); + + await provisionModel(model, { directory, fetchImpl: createFetch(content) }); + + assert.equal((await fs.stat(modelPath)).mode & 0o777, 0o644); + }); + + test('makes newly provisioned directories traversable across runtime users', async () => { + const parent = await createTemporaryDirectory(); + const modelsDirectory = path.join(parent, 'models'); + const directory = path.join(modelsDirectory, 'custom'); + const content = Buffer.from('directory mode fixture'); + const baseModel = fixtureModel(content); + const model = { + ...baseModel, + files: baseModel.files.map((file, index) => + index === 0 ? { ...file, name: 'onnx/model.onnx' } : file + ) + }; + const originalUmask = process.umask(0o077); + try { + await provisionModel(model, { directory, fetchImpl: createFetch(content) }); + } finally { + process.umask(originalUmask); + } + + assert.equal((await fs.stat(modelsDirectory)).mode & 0o777, 0o755); + assert.equal((await fs.stat(directory)).mode & 0o777, 0o755); + assert.equal((await fs.stat(path.join(directory, 'onnx'))).mode & 0o777, 0o755); + assert.equal((await fs.stat(path.join(directory, 'onnx/model.onnx'))).mode & 0o777, 0o644); + assert.equal((await fs.stat(path.join(directory, MODEL_LOCK_FILE))).mode & 0o777, 0o644); + }); + + test('does not repurpose a directory locked to a different descriptor', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('locked model fixture'); + const model = fixtureModel(content); + await provisionModel(model, { directory, fetchImpl: createFetch(content) }); + + await assert.rejects( + provisionModel({ ...model, repository: 'example/other-model' }, { directory }), + /locked to a different model descriptor/ + ); + await assert.rejects( + provisionModel({ ...model, dimensions: model.dimensions + 1 }, { directory }), + /locked to a different model descriptor/ + ); + assert.deepEqual(await readModelLock(directory), model); + }); + + test('serializes provisioning attempts for the same directory', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('concurrent model fixture'); + const model = fixtureModel(content); + let releaseDownload; + let signalDownloadStarted; + const downloadStarted = new Promise((resolve) => { + signalDownloadStarted = resolve; + }); + const waitForRelease = new Promise((resolve) => { + releaseDownload = resolve; + }); + let firstRequest = true; + const fetchImpl = async () => { + if (firstRequest) { + firstRequest = false; + signalDownloadStarted(); + await waitForRelease; + } + return new Response(content); + }; + + const first = provisionModel(model, { directory, fetchImpl }); + await downloadStarted; + await assert.rejects( + provisionModel(model, { directory, fetchImpl }), + /already being provisioned/ + ); + releaseDownload(); + await first; + }); + + test('recovers a stale install lock owned by a terminated local process', async () => { + const parent = await createTemporaryDirectory(); + const directory = path.join(parent, 'model'); + const installLock = path.join(parent, '.model.embedding.install.lock'); + const content = Buffer.from('stale lock fixture'); + const model = fixtureModel(content); + await fs.writeFile( + installLock, + JSON.stringify({ + formatVersion: 1, + pid: 99999999, + hostname: os.hostname(), + createdAt: '2000-01-01T00:00:00.000Z', + token: 'stale-owner' + }) + ); + + await provisionModel(model, { directory, fetchImpl: createFetch(content) }); + + await assert.rejects(fs.access(installLock)); + assert.deepEqual(await readModelLock(directory), model); + }); + + test('recovers a stale install lock truncated by an interrupted write', async () => { + const parent = await createTemporaryDirectory(); + const directory = path.join(parent, 'model'); + const installLock = path.join(parent, '.model.embedding.install.lock'); + const content = Buffer.from('truncated lock fixture'); + const model = fixtureModel(content); + await fs.writeFile(installLock, '{'); + const staleTime = new Date('2000-01-01T00:00:00.000Z'); + await fs.utimes(installLock, staleTime, staleTime); + + await provisionModel(model, { directory, fetchImpl: createFetch(content) }); + + await assert.rejects(fs.access(installLock)); + assert.deepEqual(await readModelLock(directory), model); + }); + + test('rejects symlinked artifact path components', async () => { + const directory = await createTemporaryDirectory(); + const outside = await createTemporaryDirectory(); + const content = Buffer.from('symlink model fixture'); + const baseModel = fixtureModel(content); + const model = { + ...baseModel, + files: baseModel.files.map((file, index) => + index === 0 ? { ...file, name: 'nested/model.onnx' } : file + ) + }; + await fs.symlink(outside, path.join(directory, 'nested'), 'dir'); + + await assert.rejects( + provisionModel(model, { directory, fetchImpl: createFetch(content) }), + /must not contain symbolic links/ + ); + assert.deepEqual(await fs.readdir(outside), []); + }); + + test('rejects a symlinked or replaced model directory', async () => { + const parent = await createTemporaryDirectory(); + const directory = path.join(parent, 'model'); + const outside = await createTemporaryDirectory(); + const content = Buffer.from('root symlink fixture'); + const model = fixtureModel(content); + await fs.symlink(outside, directory, 'dir'); + + await assert.rejects( + provisionModel(model, { directory, fetchImpl: createFetch(content) }), + /model directory must not be a symbolic link/ + ); + assert.deepEqual(await fs.readdir(outside), []); + }); + + test('canonicalizes symlinked ancestor directories before provisioning', async () => { + const parent = await createTemporaryDirectory(); + const outside = await createTemporaryDirectory(); + const modelsDirectory = path.join(parent, 'models'); + const requestedDirectory = path.join(modelsDirectory, 'custom'); + const content = Buffer.from('ancestor symlink fixture'); + const model = fixtureModel(content); + await fs.symlink(outside, modelsDirectory, 'dir'); + + const directory = await provisionModel(model, { + directory: requestedDirectory, + fetchImpl: createFetch(content) + }); + + assert.equal(directory, path.join(await fs.realpath(outside), 'custom')); + assert.deepEqual(await readModelLock(directory), model); + }); + + test('publishes from staging and detects replacement of the target during download', async () => { + const parent = await createTemporaryDirectory(); + const directory = path.join(parent, 'model'); + const outside = await createTemporaryDirectory(); + const content = Buffer.from('target replacement fixture'); + const model = fixtureModel(content); + let replaced = false; + const fetchImpl = async () => { + if (!replaced) { + replaced = true; + await fs.symlink(outside, directory, 'dir'); + } + return new Response(content); + }; + + await assert.rejects( + provisionModel(model, { directory, fetchImpl }), + /model directory must not be a symbolic link/ + ); + assert.deepEqual(await fs.readdir(outside), []); + }); + + test('writes the lock only after every artifact passes verification', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('expected model fixture'); + const model = fixtureModel(content); + + await assert.rejects( + provisionModel(model, { + directory, + fetchImpl: async () => new Response(Buffer.from('invalid')) + }), + /Invalid size|Invalid SHA-256/ + ); + await assert.rejects(fs.access(path.join(directory, MODEL_LOCK_FILE))); + }); + + test('validates the runtime before publishing a newly installed model', async () => { + const parent = await createTemporaryDirectory(); + const directory = path.join(parent, 'model'); + const content = Buffer.from('runtime validation fixture'); + const model = fixtureModel(content); + let stagedDirectory; + + await assert.rejects( + provisionModel(model, { + directory, + fetchImpl: createFetch(content), + validate(candidate) { + stagedDirectory = candidate; + throw new Error('incompatible ONNX runtime'); + } + }), + /incompatible ONNX runtime/ + ); + + assert.notEqual(stagedDirectory, directory); + await assert.rejects(fs.access(directory)); + }); + + test('fails verification instead of downloading missing runtime files', async () => { + const directory = await createTemporaryDirectory(); + const model = fixtureModel(Buffer.from('fixture')); + + await assert.rejects( + verifyModelDirectory(directory, model), + new RegExp(`Embedding model is not provisioned.*${escapeRegExp(directory)}`, 's') + ); + assert.deepEqual(await fs.readdir(directory), []); + }); + + test('downloads a missing model into the project-local default directory and reuses it', async () => { + const root = await createTemporaryDirectory(); + const content = Buffer.from('lazy download fixture'); + const model = fixtureModel(content); + const requestedUrls = []; + const warnings = []; + let discoveries = 0; + const options = { + root, + fetchImpl: createFetch(content, requestedUrls), + discover(name) { + discoveries++; + assert.equal(name, model.repository); + return model; + }, + validate: async () => {}, + warn: (message) => warnings.push(message) + }; + + const first = await resolveEmbeddingModel({ model: model.repository }, options); + const expectedDirectory = getModelDirectory(getModelRoot(undefined, root), model.repository); + + assert.equal(first.model, model); + assert.equal(first.modelDir, expectedDirectory); + assert.equal(discoveries, 1); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Downloading it now; application startup may be delayed/); + assert.equal(requestedUrls.length, model.files.length); + assert.deepEqual(await readModelLock(expectedDirectory), model); + + const second = await resolveEmbeddingModel({ model: model.repository }, options); + assert.equal(second.modelDir, expectedDirectory); + assert.equal(discoveries, 1); + assert.equal(warnings.length, 1); + assert.equal(requestedUrls.length, model.files.length); + }); + + test('waits for concurrent ad-hoc provisioning and reuses the completed download', async () => { + const root = await createTemporaryDirectory(); + const content = Buffer.from('concurrent lazy download fixture'); + const model = fixtureModel(content); + const requestedUrls = []; + const warnings = []; + let releaseDownload; + let signalDownloadStarted; + let firstRequest = true; + const downloadStarted = new Promise((resolve) => { + signalDownloadStarted = resolve; + }); + const waitForRelease = new Promise((resolve) => { + releaseDownload = resolve; + }); + const fetchImpl = async (url) => { + requestedUrls.push(url); + if (firstRequest) { + firstRequest = false; + signalDownloadStarted(); + await waitForRelease; + } + return new Response(content, { + headers: { 'content-length': String(content.length) } + }); + }; + const options = { + root, + fetchImpl, + discover: () => model, + validate: async () => {}, + warn: (message) => warnings.push(message), + provisionRetryMs: 5, + provisionTimeoutMs: 1000 + }; + + const first = resolveEmbeddingModel({ model: model.repository }, options); + await downloadStarted; + const second = resolveEmbeddingModel({ model: model.repository }, options); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseDownload(); + + const resolved = await Promise.all([first, second]); + assert.equal(resolved[0].modelDir, resolved[1].modelDir); + assert.equal(warnings.length, 2); + assert.equal(requestedUrls.length, model.files.length); + }); + + test('keeps explicitly configured directories offline', async () => { + const root = await createTemporaryDirectory(); + let fetched = false; + await assert.rejects( + resolveEmbeddingModel( + { + model: 'example/model', + directory: './models/minilm' + }, + { + root, + fetchImpl: () => { + fetched = true; + throw new Error('explicit directories must not fetch'); + } + } + ), + /@cap-js\/ai install-model example\/model --directory \.\/models/ + ); + assert.equal(fetched, false); + }); + + test('resolves relative directories from cds.root and preserves absolute directories', async () => { + const root = await createTemporaryDirectory(); + const content = Buffer.from('directory resolution fixture'); + const model = fixtureModel(content); + const modelRoot = path.join(root, 'models'); + const modelDir = getModelDirectory(modelRoot, model.repository); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + const relative = await resolveEmbeddingModel( + { model: model.repository, directory: './models' }, + { root } + ); + const absolute = await resolveEmbeddingModel( + { model: model.repository, directory: modelRoot }, + { root: await createTemporaryDirectory() } + ); + + assert.equal(relative.modelDir, modelDir); + assert.equal(absolute.modelDir, modelDir); + }); + + test('rejects a configured model name that does not match the provisioned lock', async () => { + const modelRoot = await createTemporaryDirectory(); + const content = Buffer.from('repository mismatch fixture'); + const model = fixtureModel(content); + const modelDir = getModelDirectory(modelRoot, 'example/other-model'); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + await assert.rejects( + resolveEmbeddingModel({ model: 'example/other-model', directory: modelRoot }), + /contains example\/model, not example\/other-model/ + ); + }); + + test('gives models a model-name provisioning command', async () => { + const root = await createTemporaryDirectory(); + + await assert.rejects( + resolveEmbeddingModel({ model: 'example/custom', directory: './models/custom' }, { root }), + /@cap-js\/ai install-model example\/custom --directory \.\/models\/custom/ + ); + }); + + test('requires explicit lock recovery before reinstalling', async () => { + const modelRoot = await createTemporaryDirectory(); + const modelDir = getModelDirectory(modelRoot, 'example/model'); + await fs.mkdir(modelDir, { recursive: true }); + await fs.writeFile(path.join(modelDir, MODEL_LOCK_FILE), '{}'); + + await assert.rejects( + resolveEmbeddingModel({ model: 'example/model', directory: modelRoot }), + /Remove or replace the invalid lock explicitly, then run 'npx @cap-js\/ai install-model/ + ); + }); + + test('installs a model by name through the command API', async () => { + const root = await createTemporaryDirectory(); + const modelRoot = path.join(root, 'models'); + const content = Buffer.from('command fixture'); + const model = fixtureModel(content); + const output = []; + + await runModelCommand(['install-model', model.repository, '--directory', modelRoot], { + cwd: root, + discover: () => model, + fetchImpl: createFetch(content), + validate: async () => {}, + stdout: { write: (value) => output.push(value) } + }); + + const modelDir = getModelDirectory(modelRoot, model.repository); + assert.deepEqual(await readModelLock(modelDir), model); + assert.match(output.join(''), /Installed example\/model/); + assert.match(output.join(''), new RegExp(escapeRegExp(modelDir))); + }); + + test('installs into the project-local model cache when no directory is provided', async () => { + const root = await createTemporaryDirectory(); + const content = Buffer.from('default command fixture'); + const model = fixtureModel(content); + + await runModelCommand(['install-model', model.repository], { + cwd: root, + discover: () => model, + fetchImpl: createFetch(content), + validate: async () => {}, + stdout: { write() {} } + }); + + const modelDir = path.join(root, '.cds', 'models', 'example', 'model'); + assert.deepEqual(await readModelLock(modelDir), model); + }); + + test('requires a model name and accepts an optional cache root', async () => { + await assert.rejects( + runModelCommand(['install-model', '--directory', './models/custom']), + /Specify a model name/ + ); + await assert.rejects( + runModelCommand(['install-model', 'example/model', 'example/other']), + /Unexpected argument 'example\/other'/ + ); + }); +}); + +function createFetch(content, requestedUrls = []) { + return async (url) => { + requestedUrls.push(url); + return new Response(content, { + headers: { 'content-length': String(content.length) } + }); + }; +} + +async function createTemporaryDirectory() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'cap-ai-provision-')); + temporaryDirectories.push(directory); + return directory; +} + +function fixtureModel(content) { + const sha256 = createHash('sha256').update(content).digest('hex'); + return { + repository: 'example/model', + revision: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + dimensions: 2, + maxLength: 8, + files: [ + { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx', size: content.length, sha256 }, + { + role: 'tokenizer', + name: 'tokenizer.json', + path: 'tokenizer.json', + size: content.length, + sha256 + }, + { + role: 'tokenizerConfig', + name: 'tokenizer_config.json', + path: 'tokenizer_config.json', + size: content.length, + sha256 + } + ], + output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + }; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index a8df931..1593930 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -4,11 +4,52 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, test } from 'node:test'; -import { wordPieceTokenizer } from '../lib/vector_embedding/embedding.js'; -import { downloadModelIfNeeded } from '../lib/vector_embedding/model-utils.js'; +import { InferenceSession } from '../lib/vector_embedding/InferenceSession.js'; +import { + createFeeds, + createTokenizerState, + poolOutput, + tokenizeToWindow +} from '../lib/vector_embedding/embedding.js'; +import { + downloadFile, + downloadModelIfNeeded, + getModelDirectory, + getModelRoot, + loadTokenizerPackage, + validateModelDescriptor +} from '../lib/vector_embedding/model-utils.js'; const temporaryDirectories = []; +test('disposes inference sessions at most once', async () => { + let disposals = 0; + const session = new InferenceSession({ + dispose() { + disposals++; + } + }); + + await session.dispose(); + await session.dispose(); + + assert.equal(disposals, 1); +}); + +test('explains how to install the optional tokenizer peer dependency', async () => { + const missing = Object.assign( + new Error("Cannot find package '@huggingface/tokenizers' imported from model-utils.js"), + { code: 'ERR_MODULE_NOT_FOUND' } + ); + + await assert.rejects( + loadTokenizerPackage(async () => { + throw missing; + }), + /npm add @huggingface\/tokenizers@0\.1\.3/ + ); +}); + afterEach(async () => { await Promise.all( temporaryDirectories @@ -17,47 +58,157 @@ afterEach(async () => { ); }); -describe('BERT tokenizer', () => { +describe('tokenizer input window', () => { const tokenizer = { - maxLength: 128, - normalizer: { - clean_text: true, - handle_chinese_chars: true, - lowercase: true, - strip_accents: null - }, - vocab: new Map([ - ['[UNK]', 100], - ['[CLS]', 101], - ['[SEP]', 102], - ['hello', 200], - [',', 201], - ['cafe', 202], - ['中', 203], - ['文', 204], - ['token', 205] - ]) + encode(text, { add_special_tokens: addSpecialTokens }) { + const ids = text + .split(/\s+/u) + .filter(Boolean) + .map((_, index) => index + 10); + const attention_mask = ids.map((_, index) => index % 2); + const token_type_ids = ids.map((_, index) => index + 20); + return addSpecialTokens + ? { + ids: [101, ...ids, 102], + attention_mask: [1, ...attention_mask, 1], + token_type_ids: [9, ...token_type_ids, 10] + } + : { ids, attention_mask, token_type_ids }; + } }; - test('applies BERT accent, punctuation, and Chinese character normalization', () => { - const chunk = wordPieceTokenizer('Héllo, café中文', tokenizer); + test('derives special-token boundaries and keeps one complete model window', () => { + const state = createTokenizerState(tokenizer, 5); + const input = tokenizeToWindow('one two three four five six seven', tokenizer, state); + + assert.deepEqual(input.ids, [101, 10, 11, 12, 102]); + assert.deepEqual(input.attention_mask, [1, 0, 1, 0, 1]); + assert.deepEqual(input.token_type_ids, [9, 20, 21, 22, 10]); + }); + + test('truncates content without relying on tokenizer-side truncation', () => { + const state = createTokenizerState(tokenizer, 4); + const input = tokenizeToWindow(new Array(9).fill('token').join(' '), tokenizer, state); + + assert.deepEqual(input.ids, [101, 10, 11, 102]); + }); +}); + +describe('model compatibility', () => { + test('filters standard int64 feeds by the model input names', () => { + const feeds = createFeeds( + { + ids: [101, 200, 102], + attention_mask: [1, 0, 1], + token_type_ids: [0, 1, 1] + }, + ['input_ids', 'attention_mask', 'token_type_ids'] + ); + + assert.deepEqual(Object.keys(feeds), ['input_ids', 'attention_mask', 'token_type_ids']); + assert.deepEqual(feeds.input_ids.dims, [1, 3]); + assert.deepEqual(Array.from(feeds.input_ids.data), [101n, 200n, 102n]); + assert.deepEqual(Array.from(feeds.attention_mask.data), [1n, 0n, 1n]); + assert.deepEqual(Array.from(feeds.token_type_ids.data), [0n, 1n, 1n]); + }); + + test('supports mean, CLS, and already-pooled outputs', () => { + const sequence = { + type: 'float32', + data: new Float32Array([1, 2, 3, 4]), + dims: [1, 2, 2] + }; + const pooled = { type: 'float64', data: new Float64Array([5, 6]), dims: [1, 2] }; + + assert.deepEqual(Array.from(poolOutput(sequence, 'mean')), [2, 3]); + assert.deepEqual(Array.from(poolOutput(sequence, 'cls')), [1, 2]); + assert.deepEqual(Array.from(poolOutput(pooled, 'none')), [5, 6]); + }); + + test('rejects non-floating-point model outputs', () => { + assert.throws( + () => poolOutput({ type: 'int64', data: new BigInt64Array([1n]), dims: [1] }, 'none'), + /must be float32 or float64/ + ); + }); + + test('requires immutable revisions, checksums, and traversal-safe paths', () => { + const model = fixtureModel(Buffer.from('fixture')); + assert.equal(validateModelDescriptor(model), model); + + assert.throws( + () => validateModelDescriptor({ ...model, revision: 'main' }), + /immutable 40-64 character commit hash/ + ); + assert.throws( + () => + validateModelDescriptor({ + ...model, + files: model.files.map((file, index) => + index === 0 ? { ...file, name: '../model.onnx' } : file + ) + }), + /safe relative path/ + ); + assert.throws( + () => + validateModelDescriptor({ + ...model, + files: model.files.map((file, index) => + index === 0 ? { ...file, name: 'embedding.lock.json' } : file + ) + }), + /conflicts with provisioning metadata/ + ); + assert.throws( + () => + validateModelDescriptor({ + ...model, + files: model.files.map((file, index) => + index === 0 ? { ...file, name: 'EMBEDDING.LOCK.JSON' } : file + ) + }), + /conflicts with provisioning metadata/ + ); + assert.throws( + () => + validateModelDescriptor({ + ...model, + files: model.files.map((file, index) => { + if (index === 0) return { ...file, name: 'nested' }; + if (index === 1) return { ...file, name: 'nested/tokenizer.json' }; + return file; + }) + }), + /conflicts with another embedding file/ + ); + }); +}); + +describe('model directories', () => { + test('uses a project-local default and appends the model repository', () => { + const project = path.join(path.sep, 'project'); + const root = getModelRoot(undefined, project); - assert.deepEqual(chunk.tokens, ['[CLS]', 'hello', ',', 'cafe', '中', '文', '[SEP]']); - assert.deepEqual(chunk.ids, [101, 200, 201, 202, 203, 204, 102]); + assert.equal(root, path.join(project, '.cds', 'models')); + assert.equal(getModelDirectory(root, 'foo/bar'), path.join(root, 'foo', 'bar')); }); - test('truncates input to one model window without an off-by-one', () => { - const firstWindow = wordPieceTokenizer(new Array(126).fill('token').join(' '), tokenizer); - const longInput = wordPieceTokenizer(new Array(130).fill('token').join(' '), tokenizer); + test('resolves relative, absolute, and home-relative roots', () => { + const project = path.join(path.sep, 'project'); + const home = path.join(path.sep, 'home', 'user'); - assert.equal(longInput.ids.length, tokenizer.maxLength); - assert.deepEqual(longInput.ids, [101, ...new Array(126).fill(205), 102]); - assert.deepEqual(longInput, firstWindow); + assert.equal(getModelRoot('./models', project, home), path.join(project, 'models')); + assert.equal( + getModelRoot(path.join(path.sep, 'shared', 'models'), project, home), + path.join(path.sep, 'shared', 'models') + ); + assert.equal(getModelRoot('~/.cds/models', project, home), path.join(home, '.cds', 'models')); }); }); describe('model download', () => { - test('uses a pinned revision and atomically caches a verified file', async () => { + test('uses a pinned revision and atomically caches verified files', async () => { const directory = await createTemporaryDirectory(); const content = Buffer.from('verified model fixture'); const model = fixtureModel(content); @@ -79,21 +230,29 @@ describe('model download', () => { await downloadModelIfNeeded(directory, model, { fetchImpl }); await downloadModelIfNeeded(directory, model, { fetchImpl }); - assert.deepEqual(requestedUrls, [ - 'https://huggingface.co/example/model/resolve/deadbeef/model.onnx' - ]); + assert.deepEqual( + requestedUrls, + model.files.map( + (file) => `https://huggingface.co/example/model/resolve/${model.revision}/${file.path}` + ) + ); assert.deepEqual(await fs.readFile(path.join(directory, 'model.onnx')), content); - assert.deepEqual(await fs.readdir(directory), ['model.onnx']); + assert.deepEqual((await fs.readdir(directory)).sort(), [ + 'model.onnx', + 'tokenizer.json', + 'tokenizer_config.json' + ]); }); test('rejects oversized content without exposing a partial cache file', async () => { const directory = await createTemporaryDirectory(); const content = Buffer.from('expected'); - const model = fixtureModel(content); + const file = fixtureModel(content).files[0]; + const outputPath = path.join(directory, file.name); const fetchImpl = async () => new Response(Buffer.concat([content, Buffer.from('extra')])); await assert.rejects( - downloadModelIfNeeded(directory, model, { fetchImpl }), + downloadFile('https://example.test/model', outputPath, file, { fetchImpl }), /exceeds the expected 8 bytes/ ); assert.deepEqual(await fs.readdir(directory), []); @@ -102,10 +261,14 @@ describe('model download', () => { test('rejects content that does not match the pinned checksum', async () => { const directory = await createTemporaryDirectory(); const content = Buffer.from('expected'); - const model = fixtureModel(content); + const file = fixtureModel(content).files[0]; + const outputPath = path.join(directory, file.name); const fetchImpl = async () => new Response(Buffer.from('tampered')); - await assert.rejects(downloadModelIfNeeded(directory, model, { fetchImpl }), /Invalid SHA-256/); + await assert.rejects( + downloadFile('https://example.test/model', outputPath, file, { fetchImpl }), + /Invalid SHA-256/ + ); assert.deepEqual(await fs.readdir(directory), []); }); }); @@ -117,16 +280,29 @@ async function createTemporaryDirectory() { } function fixtureModel(content) { + const sha256 = createHash('sha256').update(content).digest('hex'); return { repository: 'example/model', - revision: 'deadbeef', + revision: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + dimensions: 2, + maxLength: 8, files: [ + { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx', size: content.length, sha256 }, + { + role: 'tokenizer', + name: 'tokenizer.json', + path: 'tokenizer.json', + size: content.length, + sha256 + }, { - name: 'model.onnx', - path: 'model.onnx', + role: 'tokenizerConfig', + name: 'tokenizer_config.json', + path: 'tokenizer_config.json', size: content.length, - sha256: createHash('sha256').update(content).digest('hex') + sha256 } - ] + ], + output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } }; } diff --git a/tests/vector.test.js b/tests/vector.test.js index f3b0a24..c19e3ab 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -3,7 +3,13 @@ import assert from 'node:assert'; import cds from '@sap/cds'; import { initializeEmbedding, vector_embedding } from '../lib/vector_embedding/index.js'; -before(initializeEmbedding); +const MINILM_MODEL = 'Xenova/all-MiniLM-L6-v2'; + +let embeddingModule; + +before(async () => { + embeddingModule = await initializeEmbedding({ model: MINILM_MODEL }); +}); describe('Vector embedding function (standalone)', () => { describe('vector_embedding', () => { @@ -98,7 +104,17 @@ describe('Vector embedding function (standalone)', () => { ); }); - test('uses correct dimensions for different models', async () => { + test('embeds text longer than the MiniLM token limit', () => { + const result = vector_embedding( + new Array(300).fill('semantic').join(' '), + 'DOCUMENT', + 'SAP_GXY.20250407' + ); + + assert.strictEqual(JSON.parse(result).length, 384); + }); + + test('uses the configured dimensions for compatibility model identifiers', async () => { const result1 = vector_embedding('test', 'DOCUMENT', 'SAP_GXY.20250407'); const embedding1 = JSON.parse(result1); assert.strictEqual(embedding1.length, 384, 'SAP_GXY.20250407 should have 384 dimensions'); @@ -109,7 +125,19 @@ describe('Vector embedding function (standalone)', () => { const result3 = vector_embedding('test', 'DOCUMENT', 'unknown_model'); const embedding3 = JSON.parse(result3); - assert.strictEqual(embedding3.length, 384, 'Unknown model should default to 384 dimensions'); + assert.strictEqual( + embedding3.length, + 384, + 'Compatibility identifiers should use the configured model dimensions' + ); + }); + + test('retains the embedding module wrapper', () => { + const result = embeddingModule.embedding('Hello world'); + + assert.equal(result.content, 'Hello world'); + assert.equal(result.embedding.length, 384); + assert.deepEqual(Object.keys(result), ['content']); }); }); }); @@ -117,9 +145,20 @@ describe('Vector embedding function (standalone)', () => { describe('ai-sqlite integration', () => { let db; + test('requires an explicitly configured embedding model during startup', async () => { + await assert.rejects( + cds.connect.to('missing-embedding-model-db', { + kind: 'ai-sqlite', + credentials: { url: ':memory:' } + }), + /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ + ); + }); + before(async () => { db = await cds.connect.to('vector-db', { kind: 'ai-sqlite', + embedding: { model: MINILM_MODEL }, credentials: { url: ':memory:' } }); }); @@ -144,6 +183,17 @@ describe('ai-sqlite integration', () => { assert.strictEqual(row.embedding, null); }); + + test('rejects model descriptors supplied through the service options', async () => { + await assert.rejects( + cds.connect.to('invalid-vector-db', { + kind: 'ai-sqlite', + embedding: { model: MINILM_MODEL, revision: 'main' }, + credentials: { url: ':memory:' } + }), + /Only model and directory are supported/ + ); + }); }); // Helper function to calculate cosine similarity between two vectors From 1e0c358eeb267737c48acb208c668cb1b6b5026c Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:37:45 +0200 Subject: [PATCH 14/37] refactor: simplify synchronous inference session (#57) --- lib/vector_embedding/InferenceSession.js | 117 ------------------ .../SynchronousInferenceSession.js | 106 ++++++++++++++++ lib/vector_embedding/embedding.js | 7 +- lib/vector_embedding/model-utils.js | 6 +- tests/vector-unit.test.js | 28 +---- tests/vector.test.js | 10 ++ 6 files changed, 127 insertions(+), 147 deletions(-) delete mode 100644 lib/vector_embedding/InferenceSession.js create mode 100644 lib/vector_embedding/SynchronousInferenceSession.js diff --git a/lib/vector_embedding/InferenceSession.js b/lib/vector_embedding/InferenceSession.js deleted file mode 100644 index ecb61fd..0000000 --- a/lib/vector_embedding/InferenceSession.js +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// -// Synchronous counterpart to onnxruntime-node's session handler. SQLite user -// defined functions cannot await the public asynchronous InferenceSession API. -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); -const SUPPORTED_ONNX_RUNTIME_VERSION = '1.20.1'; -const runtimeVersion = require('onnxruntime-node/package.json').version; - -if (runtimeVersion !== SUPPORTED_ONNX_RUNTIME_VERSION) { - throw new Error( - `Unsupported onnxruntime-node version ${runtimeVersion}; @cap-js/ai requires ${SUPPORTED_ONNX_RUNTIME_VERSION} because its synchronous SQLite integration uses the runtime's private native API.` - ); -} - -const ort = require('onnxruntime-node'); -const binding = require('onnxruntime-node/dist/binding.js').binding; - -class InferenceSession { - constructor(handler) { - this.handler = handler; - } - - get inputNames() { - return this.handler.inputNames; - } - - get outputNames() { - return this.handler.outputNames; - } - - run(feeds) { - if ( - typeof feeds !== 'object' || - feeds === null || - feeds instanceof ort.Tensor || - Array.isArray(feeds) - ) { - throw new TypeError( - "'feeds' must be an object that uses input names as keys and tensors as values." - ); - } - - for (const name of this.handler.inputNames) { - if (feeds[name] === undefined) throw new Error(`input '${name}' is missing in 'feeds'.`); - } - - const fetches = Object.fromEntries(this.handler.outputNames.map((name) => [name, null])); - const results = this.handler.run(feeds, fetches, {}); - const output = {}; - - for (const key in results) { - const result = results[key]; - output[key] = - result instanceof ort.Tensor - ? result - : new ort.Tensor(result.type, result.data, result.dims); - } - - return output; - } - - dispose() { - const handler = this.handler; - if (!handler) return; - this.handler = undefined; - return handler.dispose(); - } - - static async create(pathOrBuffer) { - if (typeof pathOrBuffer !== 'string' && !(pathOrBuffer instanceof Uint8Array)) { - throw new TypeError('Expected an ONNX model path or Uint8Array'); - } - return new InferenceSession(new SynchronousSessionHandler(pathOrBuffer)); - } -} - -class SynchronousSessionHandler { - constructor(pathOrBuffer) { - this.session = new binding.InferenceSession(); - try { - if (typeof pathOrBuffer === 'string') { - this.session.loadModel(pathOrBuffer, {}); - } else { - this.session.loadModel( - pathOrBuffer.buffer, - pathOrBuffer.byteOffset, - pathOrBuffer.byteLength, - {} - ); - } - this.inputNames = this.session.inputNames; - this.outputNames = this.session.outputNames; - } catch (error) { - try { - this.session.dispose(); - } catch { - // Preserve the model loading error. - } - throw error; - } - } - - run(feeds, fetches, options) { - return this.session.run(feeds, fetches, options); - } - - dispose() { - this.session.dispose(); - } -} - -const { Tensor } = ort; - -export { InferenceSession, Tensor }; diff --git a/lib/vector_embedding/SynchronousInferenceSession.js b/lib/vector_embedding/SynchronousInferenceSession.js new file mode 100644 index 0000000..6bd86ed --- /dev/null +++ b/lib/vector_embedding/SynchronousInferenceSession.js @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Synchronous counterpart to onnxruntime-node's public InferenceSession. SQLite +// user-defined functions cannot await its asynchronous run API. +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const SUPPORTED_ONNX_RUNTIME_VERSION = '1.20.1'; +const runtimeVersion = require('onnxruntime-node/package.json').version; + +if (runtimeVersion !== SUPPORTED_ONNX_RUNTIME_VERSION) { + throw new Error( + `Unsupported onnxruntime-node version ${runtimeVersion}; @cap-js/ai requires ${SUPPORTED_ONNX_RUNTIME_VERSION} because its synchronous SQLite integration uses the runtime's private native API.` + ); +} + +const ort = require('onnxruntime-node'); +const binding = require('onnxruntime-node/dist/binding.js').binding; + +class SynchronousInferenceSession { + #session; + #inputNames; + #outputNames; + + constructor(pathOrBuffer) { + if (typeof pathOrBuffer !== 'string' && !(pathOrBuffer instanceof Uint8Array)) { + throw new TypeError('Expected an ONNX model path or Uint8Array'); + } + + const session = new binding.InferenceSession(); + try { + if (typeof pathOrBuffer === 'string') { + session.loadModel(pathOrBuffer, {}); + } else { + session.loadModel( + pathOrBuffer.buffer, + pathOrBuffer.byteOffset, + pathOrBuffer.byteLength, + {} + ); + } + this.#inputNames = Object.freeze([...session.inputNames]); + this.#outputNames = Object.freeze([...session.outputNames]); + this.#session = session; + } catch (error) { + try { + session.dispose(); + } catch { + // Preserve the model loading error. + } + throw error; + } + } + + get inputNames() { + return this.#inputNames; + } + + get outputNames() { + return this.#outputNames; + } + + run(feeds) { + const session = this.#session; + if (!session) throw new Error('Inference session has been disposed'); + if (typeof feeds !== 'object' || feeds === null || Array.isArray(feeds)) { + throw new TypeError("'feeds' must be an object that uses input names as keys."); + } + + const nativeFeeds = Object.fromEntries( + this.#inputNames.map((name) => { + const feed = feeds[name]; + if (feed === undefined) throw new Error(`input '${name}' is missing in 'feeds'.`); + return [ + name, + feed instanceof ort.Tensor ? feed : new ort.Tensor(feed.type, feed.data, feed.dims) + ]; + }) + ); + const fetches = Object.fromEntries(this.#outputNames.map((name) => [name, null])); + const results = session.run(nativeFeeds, fetches, {}); + + return Object.fromEntries( + Object.entries(results).map(([name, result]) => [ + name, + result instanceof ort.Tensor + ? result + : new ort.Tensor(result.type, result.data, result.dims) + ]) + ); + } + + dispose() { + const session = this.#session; + if (!session) return; + this.#session = undefined; + session.dispose(); + } + + static async create(pathOrBuffer) { + return new SynchronousInferenceSession(pathOrBuffer); + } +} + +export { SynchronousInferenceSession }; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index b19d206..5e9c70f 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -1,4 +1,3 @@ -import { Tensor } from './InferenceSession.js'; import { installModel } from './model-install.js'; import { getModelDirectory, @@ -264,7 +263,7 @@ function validateSession(session, model) { } function processEmbedding(input, session, model) { - const results = session.run(createFeeds(input, session.inputNames)); + const results = session.run(createFeeds(input)); const output = results[model.output.name]; if (!output) { throw new Error( @@ -280,7 +279,7 @@ function processEmbedding(input, session, model) { return model.output.normalize ? normalizeEmbedding(embedding) : embedding; } -function createFeeds(encoding, inputNames) { +function createFeeds(encoding) { const { ids, attention_mask: attentionMask, @@ -293,7 +292,7 @@ function createFeeds(encoding, inputNames) { token_type_ids: new BigInt64Array(tokenTypeIds.map((value) => BigInt(value))) }; return Object.fromEntries( - inputNames.map((name) => [name, new Tensor('int64', values[name], dimensions)]) + Object.entries(values).map(([name, data]) => [name, { type: 'int64', data, dims: dimensions }]) ); } diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index 60c6771..6154abf 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -710,9 +710,9 @@ async function makeModelDirectoryReadable(directory, model) { } async function loadModelAndTokenizer(modelDir, model) { - const [{ Tokenizer }, { InferenceSession }] = await Promise.all([ + const [{ Tokenizer }, { SynchronousInferenceSession }] = await Promise.all([ loadTokenizerPackage(), - import('./InferenceSession.js') + import('./SynchronousInferenceSession.js') ]); const modelPath = path.join(modelDir, fileForRole(model, 'model').name); const tokenizerPath = path.join(modelDir, fileForRole(model, 'tokenizer').name); @@ -724,7 +724,7 @@ async function loadModelAndTokenizer(modelDir, model) { const tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig); return { - session: await InferenceSession.create(modelPath), + session: await SynchronousInferenceSession.create(modelPath), tokenizer }; } diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index 1593930..ba4927f 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -4,7 +4,6 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, test } from 'node:test'; -import { InferenceSession } from '../lib/vector_embedding/InferenceSession.js'; import { createFeeds, createTokenizerState, @@ -22,20 +21,6 @@ import { const temporaryDirectories = []; -test('disposes inference sessions at most once', async () => { - let disposals = 0; - const session = new InferenceSession({ - dispose() { - disposals++; - } - }); - - await session.dispose(); - await session.dispose(); - - assert.equal(disposals, 1); -}); - test('explains how to install the optional tokenizer peer dependency', async () => { const missing = Object.assign( new Error("Cannot find package '@huggingface/tokenizers' imported from model-utils.js"), @@ -96,14 +81,11 @@ describe('tokenizer input window', () => { describe('model compatibility', () => { test('filters standard int64 feeds by the model input names', () => { - const feeds = createFeeds( - { - ids: [101, 200, 102], - attention_mask: [1, 0, 1], - token_type_ids: [0, 1, 1] - }, - ['input_ids', 'attention_mask', 'token_type_ids'] - ); + const feeds = createFeeds({ + ids: [101, 200, 102], + attention_mask: [1, 0, 1], + token_type_ids: [0, 1, 1] + }); assert.deepEqual(Object.keys(feeds), ['input_ids', 'attention_mask', 'token_type_ids']); assert.deepEqual(feeds.input_ids.dims, [1, 3]); diff --git a/tests/vector.test.js b/tests/vector.test.js index c19e3ab..92aa5a9 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -2,6 +2,7 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert'; import cds from '@sap/cds'; import { initializeEmbedding, vector_embedding } from '../lib/vector_embedding/index.js'; +import { createEmbeddingRuntime } from '../lib/vector_embedding/embedding.js'; const MINILM_MODEL = 'Xenova/all-MiniLM-L6-v2'; @@ -139,6 +140,15 @@ describe('Vector embedding function (standalone)', () => { assert.equal(result.embedding.length, 384); assert.deepEqual(Object.keys(result), ['content']); }); + + test('disposes embedding runtimes safely', async () => { + const runtime = await createEmbeddingRuntime({ model: MINILM_MODEL }); + + await runtime.dispose(); + await runtime.dispose(); + + assert.throws(() => runtime.embedding('test'), /Inference session has been disposed/); + }); }); }); From 0e8141961eddd2c203880640fe43dd6478e89d04 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:39:38 +0200 Subject: [PATCH 15/37] Apply suggestion from @sjvans --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6653424..0d613fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions - Embeds one model input window; applications split long documents and store one vector per chunk -- Experimental!: Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency +- **Experimental!:** Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency ## Version 1.1.0 - 2026-07-20 From d3c11131dab86e364b1b37d08fc457f823962fac Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Thu, 27 Aug 2026 16:00:16 +0200 Subject: [PATCH 16/37] ai-sqlite:memory --- CHANGELOG.md | 5 +++-- README.md | 34 ++++++++++++++++++++++++++++------ package.json | 4 ++++ tests/knowledge-graph.test.js | 5 ++--- tests/vector.test.js | 13 +++++-------- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6653424..c4a6ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### Added -- **Beta:** Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using compatible ONNX encoder models +- **Beta:** Add the `ai-sqlite` and `ai-sqlite:memory` kinds with a `VECTOR_EMBEDDING` function using compatible ONNX encoder models + - Uses a file-based database for `ai-sqlite` and an in-memory database for `ai-sqlite:memory` - Requires `cds.env.requires.db.embedding.model`; automatically discovers model metadata and supports warned, on-demand provisioning into `.cds/models` - Adds `npx @cap-js/ai install-model ` with an optional shared model-cache root - Uses the optional `@huggingface/tokenizers` peer dependency and truncates long input to the first model input window @@ -17,7 +18,7 @@ - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions - Embeds one model input window; applications split long documents and store one vector per chunk -- Experimental!: Add local `SPARQL_EXECUTE` and `sparql_table` support to the `ai-sqlite` kind through the optional `oxigraph` peer dependency +- Experimental!: Add local `SPARQL_EXECUTE` and `sparql_table` support to both AI-enabled SQLite kinds through the optional `oxigraph` peer dependency ## Version 1.1.0 - 2026-07-20 diff --git a/README.md b/README.md index 5d994fa..0810e91 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,12 @@ resources: ### 3. Local Vector Embeddings with SQLite -The beta `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX encoder model. Configure the model explicitly for every service. +The beta AI-enabled SQLite database kinds extend `@cap-js/sqlite` with local semantic embeddings using an ONNX encoder model: + +- `ai-sqlite` uses a file-based SQLite database. +- `ai-sqlite:memory` uses an in-memory SQLite database. + +Configure the embedding model explicitly for every service. #### Usage @@ -217,7 +222,7 @@ Install the optional runtime dependencies: npm add @cap-js/sqlite @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 ``` -The `@huggingface/tokenizers` and `onnxruntime-node` packages are optional peer dependencies of `@cap-js/ai`, but are required when using `ai-sqlite`. `ai-sqlite` currently requires exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. +The `@huggingface/tokenizers` and `onnxruntime-node` packages are optional peer dependencies of `@cap-js/ai`, but are required when using either AI-enabled SQLite kind. Both kinds currently require exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. #### Model provisioning @@ -238,7 +243,24 @@ Runtime configuration is intentionally limited to a model name and an optional m } ``` -`embedding.model` is required. If it is absent, `ai-sqlite` fails during startup. No revision, dimensions, tokenizer, file, pooling, checksum, or descriptor settings are accepted in runtime configuration. +This configuration uses a file-based SQLite database. For an in-memory database, change the kind to `ai-sqlite:memory`; no `credentials.url` is required: + +```json +{ + "cds": { + "requires": { + "db": { + "kind": "ai-sqlite:memory", + "embedding": { + "model": "foo/bar" + } + } + } + } +} +``` + +`embedding.model` is required. If it is absent, either kind fails during startup. No revision, dimensions, tokenizer, file, pooling, checksum, or descriptor settings are accepted in runtime configuration. Without `directory`, the model is stored below the CAP project at `.cds/models/foo/bar`. Startup reuses a valid installation from there. If it is missing, startup logs a warning, discovers and downloads the model, generates `embedding.lock.json`, and reuses that installation on subsequent starts. @@ -302,7 +324,7 @@ SELECT.from('Books').columns` **Features:** -- **Initialization**: The ONNX model is loaded when the `ai-sqlite` service starts +- **Initialization**: The ONNX model is loaded when the AI-enabled SQLite service starts - **Automatic provisioning**: Use model-only configuration for warned, on-demand installation into `.cds/models` - **Explicit provisioning**: Preinstall local or shared models with `npx @cap-js/ai install-model` - **Verified artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums @@ -321,9 +343,9 @@ Provisioning canonicalizes symlinked parent directories and rejects a model dire **Error Handling:** -- Starting `ai-sqlite` fails if `cds.env.requires.db.embedding.model` is not set or the ONNX model cannot be initialized +- Starting either AI-enabled SQLite kind fails if `cds.env.requires.db.embedding.model` is not set or the ONNX model cannot be initialized - A missing model in the project-local `.cds/models` cache is installed after a startup warning -- Starting `ai-sqlite` fails with a provisioning command if a configured model directory is missing or fails integrity checks +- Starting either AI-enabled SQLite kind fails with a provisioning command if a configured model directory is missing or fails integrity checks - Provisioning downloads are time-limited and accepted only when their expected size and SHA-256 match - Throws if embedding generation fails diff --git a/package.json b/package.json index dd49bae..5e433d5 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,10 @@ } }, "ai-sqlite": { + "kind": "sqlite", + "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js" + }, + "ai-sqlite:memory": { "kind": "sqlite", "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", "credentials": { diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index 52b84cb..ff98670 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -13,9 +13,8 @@ describe('ai-sqlite knowledge graph', () => { before(async () => { db = await cds.connect.to('knowledge-graph-db', { - kind: 'ai-sqlite', - embedding: { model: 'Xenova/all-MiniLM-L6-v2' }, - credentials: { url: ':memory:' } + kind: 'ai-sqlite:memory', + embedding: { model: 'Xenova/all-MiniLM-L6-v2' } }); }); diff --git a/tests/vector.test.js b/tests/vector.test.js index c19e3ab..0bb3094 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -148,8 +148,7 @@ describe('ai-sqlite integration', () => { test('requires an explicitly configured embedding model during startup', async () => { await assert.rejects( cds.connect.to('missing-embedding-model-db', { - kind: 'ai-sqlite', - credentials: { url: ':memory:' } + kind: 'ai-sqlite:memory' }), /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ ); @@ -157,9 +156,8 @@ describe('ai-sqlite integration', () => { before(async () => { db = await cds.connect.to('vector-db', { - kind: 'ai-sqlite', - embedding: { model: MINILM_MODEL }, - credentials: { url: ':memory:' } + kind: 'ai-sqlite:memory', + embedding: { model: MINILM_MODEL } }); }); @@ -187,9 +185,8 @@ describe('ai-sqlite integration', () => { test('rejects model descriptors supplied through the service options', async () => { await assert.rejects( cds.connect.to('invalid-vector-db', { - kind: 'ai-sqlite', - embedding: { model: MINILM_MODEL, revision: 'main' }, - credentials: { url: ':memory:' } + kind: 'ai-sqlite:memory', + embedding: { model: MINILM_MODEL, revision: 'main' } }), /Only model and directory are supported/ ); From 5a646ef5b242621ab3d4eb6b91575dcdbeaac822 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Thu, 27 Aug 2026 16:09:37 +0200 Subject: [PATCH 17/37] fix: allow additional embedding configuration --- CHANGELOG.md | 2 +- README.md | 4 ++-- lib/vector_embedding/embedding.js | 11 +---------- tests/model-provisioning.test.js | 28 ++++++++++++++++++---------- tests/vector.test.js | 22 ++++++++++++++-------- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b07c900..b82c6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - Requires `cds.env.requires.db.embedding.model`; automatically discovers model metadata and supports warned, on-demand provisioning into `.cds/models` - Adds `npx @cap-js/ai install-model ` with an optional shared model-cache root - Uses the optional `@huggingface/tokenizers` peer dependency and truncates long input to the first model input window - - Configures embedding runtimes only through `model` and an optional relative, absolute, or home-relative `directory`; discovered metadata remains in the provisioned lock + - Requires `model`, supports an optional relative, absolute, or home-relative `directory`, and allows additional embedding properties for extensions; discovered metadata remains in the provisioned lock - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Synchronous execution suitable for SQLite user-defined functions diff --git a/README.md b/README.md index 0810e91..ef81526 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ The `@huggingface/tokenizers` and `onnxruntime-node` packages are optional peer #### Model provisioning -Runtime configuration is intentionally limited to a model name and an optional model-cache root: +The built-in embedding runtime reads a model name and an optional model-cache root: ```json { @@ -260,7 +260,7 @@ This configuration uses a file-based SQLite database. For an in-memory database, } ``` -`embedding.model` is required. If it is absent, either kind fails during startup. No revision, dimensions, tokenizer, file, pooling, checksum, or descriptor settings are accepted in runtime configuration. +`embedding.model` is required. If it is absent, either kind fails during startup. Additional properties are allowed so extensions can add configuration of their own; built-in model provisioning only reads `model` and `directory`. Without `directory`, the model is stored below the CAP project at `.cds/models/foo/bar`. Startup reuses a valid installation from there. If it is missing, startup logs a warning, discovers and downloads the model, generates `embedding.lock.json`, and reuses that installation on subsequent starts. diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 5e9c70f..00f70b5 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -133,16 +133,7 @@ function normalizeEmbeddingConfiguration(configuration) { throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); } if (typeof configuration !== 'object' || Array.isArray(configuration)) { - throw new TypeError('embedding must be an object with model and optional directory'); - } - - const unsupported = Object.keys(configuration).filter( - (name) => name !== 'model' && name !== 'directory' - ); - if (unsupported.length > 0) { - throw new Error( - `Unsupported embedding configuration: ${unsupported.join(', ')}. Only model and directory are supported.` - ); + throw new TypeError('embedding must be an object'); } if (typeof configuration.model !== 'string' || !configuration.model.trim()) { throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); diff --git a/tests/model-provisioning.test.js b/tests/model-provisioning.test.js index 01c7d0d..bb285ee 100644 --- a/tests/model-provisioning.test.js +++ b/tests/model-provisioning.test.js @@ -26,8 +26,9 @@ afterEach(async () => { }); describe('runtime model configuration', () => { - test('accepts only model and directory in runtime configuration', async () => { - const model = fixtureModel(Buffer.from('configuration fixture')); + test('validates model and directory while allowing additional properties', async () => { + const content = Buffer.from('configuration fixture'); + const model = fixtureModel(content); await assert.rejects( resolveEmbeddingModel(), /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ @@ -36,18 +37,25 @@ describe('runtime model configuration', () => { resolveEmbeddingModel({}), /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ ); - await assert.rejects( - resolveEmbeddingModel(model.repository), - /embedding must be an object with model and optional directory/ - ); - await assert.rejects( - resolveEmbeddingModel({ ...model }), - /Only model and directory are supported/ - ); + await assert.rejects(resolveEmbeddingModel(model.repository), /embedding must be an object/); await assert.rejects( resolveEmbeddingModel({ model: model.repository, directory: '' }), /embedding\.directory must be a non-empty string/ ); + + const directory = await createTemporaryDirectory(); + const modelDir = getModelDirectory(directory, model.repository); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + const resolved = await resolveEmbeddingModel({ + model: model.repository, + directory, + revision: 'main', + extension: { enabled: true } + }); + + assert.equal(resolved.modelDir, modelDir); + assert.deepEqual(resolved.model, model); }); }); diff --git a/tests/vector.test.js b/tests/vector.test.js index 550ab07..536b6b5 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -192,14 +192,20 @@ describe('ai-sqlite integration', () => { assert.strictEqual(row.embedding, null); }); - test('rejects model descriptors supplied through the service options', async () => { - await assert.rejects( - cds.connect.to('invalid-vector-db', { - kind: 'ai-sqlite:memory', - embedding: { model: MINILM_MODEL, revision: 'main' } - }), - /Only model and directory are supported/ - ); + test('allows additional embedding properties', async () => { + const configuredDb = await cds.connect.to('extended-vector-db', { + kind: 'ai-sqlite:memory', + embedding: { model: MINILM_MODEL, revision: 'main', extension: { enabled: true } } + }); + + try { + const [row] = await configuredDb.run( + `SELECT VECTOR_EMBEDDING('Hello world', 'DOCUMENT', 'SAP_GXY.20250407') AS embedding` + ); + assert.strictEqual(JSON.parse(row.embedding).length, 384); + } finally { + await configuredDb.disconnect(); + } }); }); From d619bccf12d19339298214373f9feffcd594af80 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Fri, 28 Aug 2026 11:51:49 +0200 Subject: [PATCH 18/37] address ai-sqlite review feedback --- CHANGELOG.md | 5 +- README.md | 32 ++++++++-- lib/sqlite/AISQLiteService.js | 4 +- lib/sqlite/load-sqlite.js | 23 ++++++++ .../SynchronousInferenceSession.js | 13 +--- lib/vector_embedding/cli.js | 29 +++++++-- lib/vector_embedding/embedding.js | 34 +---------- lib/vector_embedding/index.js | 30 ---------- lib/vector_embedding/load-onnx-runtime.js | 29 +++++++++ tests/knowledge-graph.test.js | 25 ++++++-- tests/model-provisioning.test.js | 39 +++++++++++- tests/vector-unit.test.js | 56 ++++++++++++++++++ tests/vector.test.js | 59 ++++++++----------- 13 files changed, 250 insertions(+), 128 deletions(-) create mode 100644 lib/sqlite/load-sqlite.js delete mode 100644 lib/vector_embedding/index.js create mode 100644 lib/vector_embedding/load-onnx-runtime.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b82c6e1..0a1f29a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ - Requires `model`, supports an optional relative, absolute, or home-relative `directory`, and allows additional embedding properties for extensions; discovered metadata remains in the provisioned lock - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - - Synchronous execution suitable for SQLite user-defined functions + - Runs synchronously as required by SQLite user-defined functions and therefore blocks the Node.js event loop during tokenization and inference - Embeds one model input window; applications split long documents and store one vector per chunk -- **Experimental!:** Add local `SPARQL_EXECUTE` and `sparql_table` support to both AI-enabled SQLite kinds through the optional `oxigraph` peer dependency + - Uses trust-on-first-use provisioning: the lock pins the first resolved Hugging Face revision and checksums for later integrity checks but does not authenticate the model publisher +- **Experimental!:** Add local `SPARQL_EXECUTE` and `sparql_table` support to both AI-enabled SQLite kinds through the optional `oxigraph` peer dependency; the process-local RDF store is ephemeral and not transactionally coupled to SQLite ## Version 1.1.0 - 2026-07-20 diff --git a/README.md b/README.md index ef81526..1bd5873 100644 --- a/README.md +++ b/README.md @@ -216,13 +216,15 @@ Configure the embedding model explicitly for every service. #### Usage -Install the optional runtime dependencies: +Install the optional runtime dependencies as development dependencies: ```sh -npm add @cap-js/sqlite @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 +npm add -D @cap-js/sqlite @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 oxigraph ``` -The `@huggingface/tokenizers` and `onnxruntime-node` packages are optional peer dependencies of `@cap-js/ai`, but are required when using either AI-enabled SQLite kind. Both kinds currently require exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. +These packages are optional peer dependencies of `@cap-js/ai` and are required only for the corresponding local SQLite capabilities. Both database kinds currently require exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. + +Tokenization, ONNX inference, pooling, and normalization run synchronously for each `VECTOR_EMBEDDING` call. SQLite user-defined functions cannot await, so inference blocks the Node.js event loop until it completes. The feature is intended for local development and low-volume use; server workloads should precompute or batch embeddings outside SQL. #### Model provisioning @@ -264,12 +266,16 @@ This configuration uses a file-based SQLite database. For an in-memory database, Without `directory`, the model is stored below the CAP project at `.cds/models/foo/bar`. Startup reuses a valid installation from there. If it is missing, startup logs a warning, discovers and downloads the model, generates `embedding.lock.json`, and reuses that installation on subsequent starts. +Only install models from repositories you trust. Provisioning is trust-on-first-use: the first download trusts the named repository and its Hugging Face metadata, then pins the resolved revision, sizes, and checksums in `embedding.lock.json`. Subsequent starts detect local corruption or repository drift, but the lock does not authenticate the publisher or make an untrusted model safe. Provisioning loads the downloaded tokenizer and ONNX graph into the native runtime and executes a startup probe in the current process. + To provision the project-local model before startup instead: ```sh npx @cap-js/ai install-model foo/bar ``` +The command locates the enclosing CAP project and uses its root for `.cds/models` and relative `--directory` values, even when invoked from a project subdirectory. + To share a model across projects, select another cache root: ```sh @@ -294,7 +300,7 @@ npx @cap-js/ai install-model foo/bar --directory ~/.cds/models `directory` always names the cache root; the model is stored below it using the repository path, for example `~/.cds/models/foo/bar`. Relative directories are resolved from `cds.root`, absolute directories are used unchanged, and `~/` is resolved from the user's home directory. -When `directory` is configured, startup treats it as a pre-installed shared cache: it verifies the model but does not download or modify it. This makes runtime deployment deterministic and allows the shared directory to be read-only. +When `directory` is configured, startup treats it as a pre-installed shared cache: it checks the pinned files but does not download or modify them. Provision models in a trusted build environment, retain the generated lock, and make the shared directory read-only at runtime. ##### Automatic model discovery @@ -310,7 +316,7 @@ SELECT.from('Books').columns` `; ``` -`VECTOR_EMBEDDING` embeds one model input window. Text beyond the tokenizer's input limit is truncated. For long-document retrieval, split documents before persistence and store one vector per chunk instead of combining chunk embeddings in this function. +`VECTOR_EMBEDDING` embeds one model input window. Text beyond the tokenizer's input limit is truncated. For long-document retrieval, split documents before persistence and store one vector per chunk instead of combining chunk embeddings in this function. Each invocation performs synchronous inference and blocks the Node.js event loop while it runs. **Parameters:** @@ -327,7 +333,7 @@ SELECT.from('Books').columns` - **Initialization**: The ONNX model is loaded when the AI-enabled SQLite service starts - **Automatic provisioning**: Use model-only configuration for warned, on-demand installation into `.cds/models` - **Explicit provisioning**: Preinstall local or shared models with `npx @cap-js/ai install-model` -- **Verified artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums +- **Pinned artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums for later local integrity checks - **Hugging Face tokenization**: Uses `@huggingface/tokenizers` and truncates text to the first model input window - **Deterministic**: Same input always produces same output - **Automatic output handling**: Pooling and normalization are derived from Sentence Transformers metadata @@ -349,6 +355,20 @@ Provisioning canonicalizes symlinked parent directories and rejects a model dire - Provisioning downloads are time-limited and accepted only when their expected size and SHA-256 match - Throws if embedding generation fails +#### Experimental local knowledge graph + +Both AI-enabled SQLite kinds expose a process-local Oxigraph store through `SPARQL_EXECUTE` and `sparql_table`. + +The supported procedure-compatible form is: + +```sql +CALL SPARQL_EXECUTE('', '', ?, ?) +``` + +The final two `?` tokens are required HANA-compatible output placeholders, not input bindings. The local implementation accepts literal SPARQL and header strings only. Query operations return an object with a serialized `RESPONSE`; `LOAD` returns no result. This is a compatibility shim rather than a general stored-procedure implementation. + +The Oxigraph store is in memory and tied to the service connection. Its triples are lost on disconnect or process restart, including when `ai-sqlite` uses a file-based SQLite database, and its updates are not transactionally coupled to SQLite. + ## Test the plugin locally In `tests/bookshop-app/` you can find a sample application that is used to demonstrate how to use the plugin and to run tests against it. diff --git a/lib/sqlite/AISQLiteService.js b/lib/sqlite/AISQLiteService.js index b20e3f6..f8bda8b 100644 --- a/lib/sqlite/AISQLiteService.js +++ b/lib/sqlite/AISQLiteService.js @@ -1,7 +1,9 @@ import cds from '@sap/cds'; -import SQLiteService from '@cap-js/sqlite'; import { createEmbeddingRuntime } from '../vector_embedding/embedding.js'; import TripleStore from '../knowledge-graph/triplestore.js'; +import { loadSQLiteService } from './load-sqlite.js'; + +const SQLiteService = loadSQLiteService(); const LOG = cds.log('@cap-js/ai'); const $tripleStore = Symbol('tripleStore'); diff --git a/lib/sqlite/load-sqlite.js b/lib/sqlite/load-sqlite.js new file mode 100644 index 0000000..63df444 --- /dev/null +++ b/lib/sqlite/load-sqlite.js @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +function loadSQLiteService(requireModule = require) { + try { + const module = requireModule('@cap-js/sqlite'); + return module.default ?? module; + } catch (error) { + if ( + (error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'MODULE_NOT_FOUND') && + /['"]@cap-js\/sqlite['"]/.test(error.message) + ) { + throw new Error( + "Using ai-sqlite requires @cap-js/sqlite. Install it with 'npm add -D @cap-js/sqlite'.", + { cause: error } + ); + } + throw error; + } +} + +export { loadSQLiteService }; diff --git a/lib/vector_embedding/SynchronousInferenceSession.js b/lib/vector_embedding/SynchronousInferenceSession.js index 6bd86ed..ad2c5ce 100644 --- a/lib/vector_embedding/SynchronousInferenceSession.js +++ b/lib/vector_embedding/SynchronousInferenceSession.js @@ -4,19 +4,10 @@ // Synchronous counterpart to onnxruntime-node's public InferenceSession. SQLite // user-defined functions cannot await its asynchronous run API. import { createRequire } from 'module'; +import { loadOnnxRuntime } from './load-onnx-runtime.js'; const require = createRequire(import.meta.url); -const SUPPORTED_ONNX_RUNTIME_VERSION = '1.20.1'; -const runtimeVersion = require('onnxruntime-node/package.json').version; - -if (runtimeVersion !== SUPPORTED_ONNX_RUNTIME_VERSION) { - throw new Error( - `Unsupported onnxruntime-node version ${runtimeVersion}; @cap-js/ai requires ${SUPPORTED_ONNX_RUNTIME_VERSION} because its synchronous SQLite integration uses the runtime's private native API.` - ); -} - -const ort = require('onnxruntime-node'); -const binding = require('onnxruntime-node/dist/binding.js').binding; +const { ort, binding } = loadOnnxRuntime(require); class SynchronousInferenceSession { #session; diff --git a/lib/vector_embedding/cli.js b/lib/vector_embedding/cli.js index 956c0ed..99665d7 100644 --- a/lib/vector_embedding/cli.js +++ b/lib/vector_embedding/cli.js @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { installModel } from './model-install.js'; import { validateEmbeddingModel } from './embedding.js'; @@ -5,12 +8,14 @@ const HELP = `Usage: npx @cap-js/ai install-model [--directory ] Options: - --directory Use this model-cache root instead of .cds/models + --directory Use this model-cache root (relative to the CAP project root) --help Show this help `; async function runModelCommand(argv, options = {}) { - const { cwd = process.cwd(), stdout = process.stdout } = options; + const cwd = options.cwd ?? process.cwd(); + const root = options.root ?? findProjectRoot(cwd); + const { stdout = process.stdout } = options; const command = parseArguments(argv); if (command.help) { stdout.write(HELP); @@ -18,7 +23,7 @@ async function runModelCommand(argv, options = {}) { } const { modelDir } = await installModel(command.model, { - root: cwd, + root, directory: command.directory, home: options.home, fetchImpl: options.fetchImpl, @@ -30,6 +35,22 @@ async function runModelCommand(argv, options = {}) { stdout.write(`Installed ${command.model} in ${modelDir}\n`); } +function findProjectRoot(start = process.cwd()) { + let directory = path.resolve(start); + while (true) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); + const dependencies = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }; + if (pkg.cds !== undefined || dependencies['@sap/cds']) return directory; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const parent = path.dirname(directory); + if (parent === directory) return path.resolve(start); + directory = parent; + } +} + function parseArguments(argv) { if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) return { help: true }; if (argv[0] !== 'install-model') { @@ -55,4 +76,4 @@ function parseArguments(argv) { return { directory, model }; } -export { HELP, parseArguments, runModelCommand }; +export { HELP, findProjectRoot, parseArguments, runModelCommand }; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 00f70b5..92b322c 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -116,7 +116,7 @@ async function resolveEmbeddingModel(configuration, options = {}) { async function installModelOnDemand(modelName, modelDir, options, warn) { warn( - `Embedding model '${modelName}' is not available in '${modelDir}'. Downloading it now; application startup may be delayed. ${modelInstallHint(modelName)}` + `Embedding model '${modelName}' is not available in '${modelDir}'. Downloading it now; application startup may be delayed. Only use models from repositories you trust. ${modelInstallHint(modelName)}` ); try { return await installModel(modelName, options); @@ -345,45 +345,13 @@ function validateAttentionMask(mask) { return mask; } -let sessionRuntime; -let sessionInitialization; - -async function createSession(configuration, options) { - sessionInitialization ??= createEmbeddingRuntime(configuration, options) - .then((runtime) => (sessionRuntime = runtime)) - .catch((error) => { - sessionInitialization = undefined; - throw error; - }); - return sessionInitialization; -} - -function embedding(text) { - if (!sessionRuntime) { - throw new Error( - 'Embedding session not initialized. Call createSession() before using embedding().' - ); - } - const chunk = { content: text }; - return Object.defineProperty(chunk, 'embedding', { - value: sessionRuntime.embedding(text), - writable: true, - configurable: true, - enumerable: false - }); -} - export { - createSession, createEmbeddingRuntime, createEmbeddingRuntimeFromModel, createFeeds, createTokenizerState, - embedding, poolOutput, resolveEmbeddingModel, tokenizeToWindow, validateEmbeddingModel }; - -export default embedding; diff --git a/lib/vector_embedding/index.js b/lib/vector_embedding/index.js deleted file mode 100644 index aad5cca..0000000 --- a/lib/vector_embedding/index.js +++ /dev/null @@ -1,30 +0,0 @@ -import cds from '@sap/cds'; -import * as embeddingModule from './embedding.js'; - -const LOG = cds.log('@cap-js/ai'); -let loggedInitialization = false; -let dimensions; - -async function initializeEmbedding(configuration, options) { - const runtime = await embeddingModule.createSession(configuration, options); - dimensions = runtime.dimensions; - if (!loggedInitialization) { - LOG.info('Vector embedding ONNX model initialized'); - loggedInitialization = true; - } - return embeddingModule; -} - -function vector_embedding(text, text_type, model_and_version) { - void text_type; // Retained for HANA-compatible function arity. - void model_and_version; // The configured embedding model determines vector dimensions. - if (text) return JSON.stringify(Array.from(embeddingModule.embedding(text).embedding)); - if (!dimensions) { - throw new Error( - 'Embedding session not initialized. Call initializeEmbedding() with an embedding configuration before using vector_embedding().' - ); - } - return JSON.stringify(new Array(dimensions).fill(0)); -} - -export { initializeEmbedding, vector_embedding }; diff --git a/lib/vector_embedding/load-onnx-runtime.js b/lib/vector_embedding/load-onnx-runtime.js new file mode 100644 index 0000000..e3b69e0 --- /dev/null +++ b/lib/vector_embedding/load-onnx-runtime.js @@ -0,0 +1,29 @@ +const SUPPORTED_ONNX_RUNTIME_VERSION = '1.20.1'; + +function loadOnnxRuntime(requireModule) { + try { + const runtimeVersion = requireModule('onnxruntime-node/package.json').version; + if (runtimeVersion !== SUPPORTED_ONNX_RUNTIME_VERSION) { + throw new Error( + `Unsupported onnxruntime-node version ${runtimeVersion}; @cap-js/ai requires ${SUPPORTED_ONNX_RUNTIME_VERSION} because its synchronous SQLite integration uses the runtime's private native API.` + ); + } + return { + ort: requireModule('onnxruntime-node'), + binding: requireModule('onnxruntime-node/dist/binding.js').binding + }; + } catch (error) { + if ( + (error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'MODULE_NOT_FOUND') && + /['"]onnxruntime-node(?:\/[^'"]*)?['"]/.test(error.message) + ) { + throw new Error( + "Using ai-sqlite embeddings requires onnxruntime-node@1.20.1. Install it with 'npm add -D onnxruntime-node@1.20.1'.", + { cause: error } + ); + } + throw error; + } +} + +export { SUPPORTED_ONNX_RUNTIME_VERSION, loadOnnxRuntime }; diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index ff98670..b6fc076 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -27,7 +27,7 @@ describe('ai-sqlite knowledge graph', () => { }); test('loads Turtle data', async () => { - await load(data); + assert.strictEqual(await load(data), undefined); assert.strictEqual((await triples()).length, 13); }); @@ -37,10 +37,27 @@ describe('ai-sqlite knowledge graph', () => { }); test('rejects malformed SPARQL_EXECUTE calls', async () => { - await assert.rejects( - db.run(`CALL SPARQL_EXECUTE('SELECT * WHERE { ?s ?p ?o }')`), - /Unsupported SPARQL_EXECUTE syntax/ + for (const query of [ + `CALL SPARQL_EXECUTE('SELECT * WHERE { ?s ?p ?o }')`, + `CALL SPARQL_EXECUTE('SELECT * WHERE { ?s ?p ?o }','', ?)`, + `CALL SPARQL_EXECUTE('SELECT * WHERE { ?s ?p ?o }','', NULL, NULL)`, + `CALL SPARQL_EXECUTE(?, '', ?, ?)` + ]) { + // eslint-disable-next-line no-await-in-loop + await assert.rejects(db.run(query), /Unsupported SPARQL_EXECUTE syntax/); + } + }); + + test('returns query results through the RESPONSE output', async () => { + await load(data); + const result = await db.run( + `CALL SPARQL_EXECUTE('SELECT ?subject WHERE { ?subject ?predicate ?object }','accept:application/sparql-results+json', ?, ?)` ); + + assert.deepStrictEqual(Object.keys(result), ['RESPONSE']); + const response = JSON.parse(result.RESPONSE); + assert.deepStrictEqual(response.head.vars, ['subject']); + assert.ok(response.results.bindings.length > 0); }); test('rejects RDF files outside the project', async () => { diff --git a/tests/model-provisioning.test.js b/tests/model-provisioning.test.js index bb285ee..303cc30 100644 --- a/tests/model-provisioning.test.js +++ b/tests/model-provisioning.test.js @@ -373,6 +373,7 @@ describe('explicit model provisioning', () => { assert.equal(discoveries, 1); assert.equal(warnings.length, 1); assert.match(warnings[0], /Downloading it now; application startup may be delayed/); + assert.match(warnings[0], /repositories you trust/); assert.equal(requestedUrls.length, model.files.length); assert.deepEqual(await readModelLock(expectedDirectory), model); @@ -516,7 +517,7 @@ describe('explicit model provisioning', () => { const output = []; await runModelCommand(['install-model', model.repository, '--directory', modelRoot], { - cwd: root, + root, discover: () => model, fetchImpl: createFetch(content), validate: async () => {}, @@ -535,7 +536,7 @@ describe('explicit model provisioning', () => { const model = fixtureModel(content); await runModelCommand(['install-model', model.repository], { - cwd: root, + root, discover: () => model, fetchImpl: createFetch(content), validate: async () => {}, @@ -546,6 +547,40 @@ describe('explicit model provisioning', () => { assert.deepEqual(await readModelLock(modelDir), model); }); + test('resolves default and relative command directories from the CAP project root', async () => { + const root = await createTemporaryDirectory(); + const subdirectory = path.join(root, 'srv', 'nested'); + const content = Buffer.from('project root command fixture'); + const model = fixtureModel(content); + const options = { + cwd: subdirectory, + discover: () => model, + fetchImpl: createFetch(content), + validate: async () => {}, + stdout: { write() {} } + }; + await fs.mkdir(subdirectory, { recursive: true }); + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ dependencies: { '@sap/cds': '^9' } }) + ); + + await runModelCommand(['install-model', model.repository], options); + assert.deepEqual( + await readModelLock(path.join(root, '.cds', 'models', 'example', 'model')), + model + ); + + await runModelCommand( + ['install-model', model.repository, '--directory', './shared-models'], + options + ); + assert.deepEqual( + await readModelLock(path.join(root, 'shared-models', 'example', 'model')), + model + ); + }); + test('requires a model name and accepts an optional cache root', async () => { await assert.rejects( runModelCommand(['install-model', '--directory', './models/custom']), diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index ba4927f..ac08cf7 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -18,6 +18,8 @@ import { loadTokenizerPackage, validateModelDescriptor } from '../lib/vector_embedding/model-utils.js'; +import { loadOnnxRuntime } from '../lib/vector_embedding/load-onnx-runtime.js'; +import { loadSQLiteService } from '../lib/sqlite/load-sqlite.js'; const temporaryDirectories = []; @@ -35,6 +37,60 @@ test('explains how to install the optional tokenizer peer dependency', async () ); }); +test('explains how to install the optional SQLite peer dependency', () => { + const missing = Object.assign( + new Error("Cannot find module '@cap-js/sqlite' required by load-sqlite.js"), + { code: 'MODULE_NOT_FOUND' } + ); + + assert.throws( + () => + loadSQLiteService(() => { + throw missing; + }), + /npm add -D @cap-js\/sqlite/ + ); +}); + +test('explains how to install the pinned ONNX Runtime peer dependency', () => { + const missing = Object.assign(new Error("Cannot find module 'onnxruntime-node/package.json'"), { + code: 'MODULE_NOT_FOUND' + }); + + assert.throws( + () => + loadOnnxRuntime(() => { + throw missing; + }), + /npm add -D onnxruntime-node@1\.20\.1/ + ); +}); + +test('does not mask unrelated optional-peer loading errors', () => { + const sqliteError = Object.assign(new Error('SQLite native binding failed'), { + code: 'ERR_DLOPEN_FAILED' + }); + const runtimeError = Object.assign(new Error('ONNX native binding failed'), { + code: 'ERR_DLOPEN_FAILED' + }); + + assert.throws( + () => + loadSQLiteService(() => { + throw sqliteError; + }), + sqliteError + ); + assert.throws( + () => + loadOnnxRuntime((specifier) => { + if (specifier.endsWith('package.json')) return { version: '1.20.1' }; + throw runtimeError; + }), + runtimeError + ); +}); + afterEach(async () => { await Promise.all( temporaryDirectories diff --git a/tests/vector.test.js b/tests/vector.test.js index 536b6b5..b106c92 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -1,21 +1,24 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert'; import cds from '@sap/cds'; -import { initializeEmbedding, vector_embedding } from '../lib/vector_embedding/index.js'; import { createEmbeddingRuntime } from '../lib/vector_embedding/embedding.js'; const MINILM_MODEL = 'Xenova/all-MiniLM-L6-v2'; -let embeddingModule; +let runtime; before(async () => { - embeddingModule = await initializeEmbedding({ model: MINILM_MODEL }); + runtime = await createEmbeddingRuntime({ model: MINILM_MODEL }); +}); + +after(async () => { + await runtime?.dispose(); }); describe('Vector embedding function (standalone)', () => { describe('vector_embedding', () => { test('computes embedding with ONNX model', async () => { - const result = vector_embedding('Hello world', 'DOCUMENT', 'SAP_GXY.20250407'); + const result = runtime.vectorEmbedding('Hello world'); const embedding = JSON.parse(result); assert.ok(Array.isArray(embedding), 'Embedding should be an array'); @@ -28,34 +31,32 @@ describe('Vector embedding function (standalone)', () => { }); test('deterministic - same input produces same output', async () => { - const e1 = vector_embedding('test text', 'DOCUMENT', 'SAP_GXY.20250407'); - const e2 = vector_embedding('test text', 'DOCUMENT', 'SAP_GXY.20250407'); + const e1 = runtime.vectorEmbedding('test text'); + const e2 = runtime.vectorEmbedding('test text'); assert.strictEqual(e1, e2, 'Same input should produce identical embeddings'); }); test('ignores text beyond the first model input window', () => { const firstWindow = new Array(126).fill('token').join(' '); - const truncated = vector_embedding(firstWindow, 'DOCUMENT', 'SAP_GXY.20250407'); - const withAdditionalText = vector_embedding( - `${firstWindow} this text must not affect the embedding`, - 'DOCUMENT', - 'SAP_GXY.20250407' + const truncated = runtime.vectorEmbedding(firstWindow); + const withAdditionalText = runtime.vectorEmbedding( + `${firstWindow} this text must not affect the embedding` ); assert.strictEqual(withAdditionalText, truncated); }); test('different inputs produce different outputs', async () => { - const e1 = vector_embedding('hello world', 'DOCUMENT', 'SAP_GXY.20250407'); - const e2 = vector_embedding('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407'); + const e1 = runtime.vectorEmbedding('hello world'); + const e2 = runtime.vectorEmbedding('goodbye world'); assert.notStrictEqual(e1, e2, 'Different inputs should produce different embeddings'); }); test('semantically similar sentences produce similar vectors', async () => { - const e1 = vector_embedding('I love programming', 'DOCUMENT', 'SAP_GXY.20250407'); - const e2 = vector_embedding('I enjoy coding', 'DOCUMENT', 'SAP_GXY.20250407'); + const e1 = runtime.vectorEmbedding('I love programming'); + const e2 = runtime.vectorEmbedding('I enjoy coding'); const v1 = JSON.parse(e1); const v2 = JSON.parse(e2); @@ -68,8 +69,8 @@ describe('Vector embedding function (standalone)', () => { }); test('semantically different sentences are far apart in vector space', async () => { - const e1 = vector_embedding('The cat sat on the mat', 'DOCUMENT', 'SAP_GXY.20250407'); - const e2 = vector_embedding('Quantum physics is fascinating', 'DOCUMENT', 'SAP_GXY.20250407'); + const e1 = runtime.vectorEmbedding('The cat sat on the mat'); + const e2 = runtime.vectorEmbedding('Quantum physics is fascinating'); const v1 = JSON.parse(e1); const v2 = JSON.parse(e2); @@ -82,7 +83,7 @@ describe('Vector embedding function (standalone)', () => { }); test('handles empty text', async () => { - const result = vector_embedding('', 'DOCUMENT', 'SAP_GXY.20250407'); + const result = runtime.vectorEmbedding(''); const embedding = JSON.parse(result); assert.ok(Array.isArray(embedding), 'Empty text should return zero vector'); @@ -94,7 +95,7 @@ describe('Vector embedding function (standalone)', () => { }); test('handles null text', async () => { - const result = vector_embedding(null, 'DOCUMENT', 'SAP_GXY.20250407'); + const result = runtime.vectorEmbedding(null); const embedding = JSON.parse(result); assert.ok(Array.isArray(embedding), 'Null text should return zero vector'); @@ -106,25 +107,21 @@ describe('Vector embedding function (standalone)', () => { }); test('embeds text longer than the MiniLM token limit', () => { - const result = vector_embedding( - new Array(300).fill('semantic').join(' '), - 'DOCUMENT', - 'SAP_GXY.20250407' - ); + const result = runtime.vectorEmbedding(new Array(300).fill('semantic').join(' ')); assert.strictEqual(JSON.parse(result).length, 384); }); test('uses the configured dimensions for compatibility model identifiers', async () => { - const result1 = vector_embedding('test', 'DOCUMENT', 'SAP_GXY.20250407'); + const result1 = runtime.vectorEmbedding('test'); const embedding1 = JSON.parse(result1); assert.strictEqual(embedding1.length, 384, 'SAP_GXY.20250407 should have 384 dimensions'); - const result2 = vector_embedding('test', 'DOCUMENT', 'SAP_GXY.20240715'); + const result2 = runtime.vectorEmbedding('test'); const embedding2 = JSON.parse(result2); assert.strictEqual(embedding2.length, 384, 'SAP_GXY.20240715 should have 384 dimensions'); - const result3 = vector_embedding('test', 'DOCUMENT', 'unknown_model'); + const result3 = runtime.vectorEmbedding('test'); const embedding3 = JSON.parse(result3); assert.strictEqual( embedding3.length, @@ -133,14 +130,6 @@ describe('Vector embedding function (standalone)', () => { ); }); - test('retains the embedding module wrapper', () => { - const result = embeddingModule.embedding('Hello world'); - - assert.equal(result.content, 'Hello world'); - assert.equal(result.embedding.length, 384); - assert.deepEqual(Object.keys(result), ['content']); - }); - test('disposes embedding runtimes safely', async () => { const runtime = await createEmbeddingRuntime({ model: MINILM_MODEL }); From 549df5bba9735b6f5ce6485f21d5284f0f23dd86 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:11:12 +0200 Subject: [PATCH 19/37] feat: discover compatible Hugging Face embedding models (#61) * feat: discover Hugging Face embedding models * feat: add embedding model compatibility check * docs: clarify local SQLite model setup * refactor: validate embedding models with ONNX Runtime * refactor: remove Hugging Face token support * address model discovery review feedback --- CHANGELOG.md | 5 + README.md | 29 +- lib/vector_embedding/cli.js | 43 ++- lib/vector_embedding/embedding.js | 12 +- lib/vector_embedding/huggingface-hub.js | 198 ++++++++++ lib/vector_embedding/model-discovery.js | 388 +++++++++++-------- lib/vector_embedding/model-install.js | 6 +- lib/vector_embedding/model-utils.js | 10 +- package.json | 5 + tests/huggingface-hub.test.js | 195 ++++++++++ tests/model-discovery.test.js | 483 ++++++++++++++---------- tests/model-provisioning.test.js | 34 ++ tests/vector-unit.test.js | 93 ++++- 13 files changed, 1130 insertions(+), 371 deletions(-) create mode 100644 lib/vector_embedding/huggingface-hub.js create mode 100644 tests/huggingface-hub.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1f29a..ebbc673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,13 @@ - Uses a file-based database for `ai-sqlite` and an in-memory database for `ai-sqlite:memory` - Requires `cds.env.requires.db.embedding.model`; automatically discovers model metadata and supports warned, on-demand provisioning into `.cds/models` - Adds `npx @cap-js/ai install-model ` with an optional shared model-cache root + - Adds metadata-only `npx @cap-js/ai check-model ` to report likely model compatibility before downloading model artifacts; installation remains the definitive runtime validation - Uses the optional `@huggingface/tokenizers` peer dependency and truncates long input to the first model input window - Requires `model`, supports an optional relative, absolute, or home-relative `directory`, and allows additional embedding properties for extensions; discovered metadata remains in the provisioned lock + - Discovers compatible Hugging Face ONNX encoder layouts through the optional `@huggingface/hub` peer, recognizes common Transformers configuration aliases, and loads and probes the downloaded model with ONNX Runtime before installation + - Bounds Hugging Face discovery requests with timeouts and retries transient network and server failures + - Prefers metadata adjacent to nested ONNX exports and supports conventional adjacent external-data sidecars + - Rejects incompatible decoder and masked-language-model tasks instead of guessing embedding semantics - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - Runs synchronously as required by SQLite user-defined functions and therefore blocks the Node.js event loop during tokenization and inference diff --git a/README.md b/README.md index 1bd5873..e0f1f57 100644 --- a/README.md +++ b/README.md @@ -216,13 +216,13 @@ Configure the embedding model explicitly for every service. #### Usage -Install the optional runtime dependencies as development dependencies: +Install the optional peer dependencies as development dependencies: ```sh -npm add -D @cap-js/sqlite @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 oxigraph +npm add -D @cap-js/sqlite @huggingface/hub@^2.15.0 @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 oxigraph ``` -These packages are optional peer dependencies of `@cap-js/ai` and are required only for the corresponding local SQLite capabilities. Both database kinds currently require exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. +These packages are optional peer dependencies of `@cap-js/ai` and are required only for the corresponding local SQLite capabilities. `@huggingface/hub` is required for explicit or ad-hoc model provisioning. Both database kinds currently require exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. Tokenization, ONNX inference, pooling, and normalization run synchronously for each `VECTOR_EMBEDDING` call. SQLite user-defined functions cannot await, so inference blocks the Node.js event loop until it completes. The feature is intended for local development and low-volume use; server workloads should precompute or batch embeddings outside SQL. @@ -276,6 +276,14 @@ npx @cap-js/ai install-model foo/bar The command locates the enclosing CAP project and uses its root for `.cds/models` and relative `--directory` values, even when invoked from a project subdirectory. +To check whether a Hugging Face repository is likely compatible before downloading it: + +```sh +npx @cap-js/ai check-model foo/bar +``` + +`check-model` only reads repository, configuration, and tokenizer metadata from the Hub. It does not download the ONNX artifact or write to the model cache. Its result is therefore a likely-compatibility check; `install-model` is definitive because it also loads and probes the downloaded model with ONNX Runtime. + To share a model across projects, select another cache root: ```sh @@ -304,9 +312,11 @@ When `directory` is configured, startup treats it as a pre-installed shared cach ##### Automatic model discovery -The installer resolves the model's current Hugging Face revision to an immutable commit, selects the conventional `onnx/model.onnx` and tokenizer/configuration files, calculates or obtains their checksums, and derives the dimensions, tokenizer limit, pooling, and normalization metadata. It then writes all resolved metadata to `embedding.lock.json` alongside the downloaded artifacts. +The installer uses the official Hugging Face Hub client to resolve the model's current revision to an immutable commit, enumerate its files, and retrieve discovery metadata. Selected artifacts are then streamed into the model cache with integrity checks. Model discovery and installation currently support public repositories only. The resolved lock contains the commit, artifact paths, sizes, checksums, dimensions, tokenizer limit, pooling, and normalization metadata; it is written to `embedding.lock.json` alongside the downloaded artifacts. Hub requests use bounded timeouts and retry transient network and server failures. + +Discovery is layout-aware rather than tied to one exporter. It prefers `onnx/model.onnx`, then `model.onnx`, a unique nested `model.onnx`, or a sole ONNX file. For a nested model it prefers adjacent tokenizer/configuration files and falls back to repository-root files. Common Transformers configuration names for dimensions (`hidden_size`, `n_embd`, `d_model`, and `dim`) and input length are recognized. -Discovery supports compatible Hugging Face ONNX Sentence Transformers models with machine-readable pooling semantics. Repositories with missing or ambiguous artifacts or semantics fail with a compatibility error instead of using guessed defaults. Once installed, startup uses the pinned lock and does not follow later changes to the model repository. +The downloaded ONNX model is loaded and probed with ONNX Runtime before it is accepted. This verifies that its inputs, output shape, element type, and dimensions work as an embedding model, so a decoder's logits graph cannot accidentally be installed as one. Discovery also rejects repositories explicitly tagged for incompatible tasks such as text generation or masked-language modeling. Once installed, startup uses the pinned lock and does not follow later changes to the model repository. The HANA-compatible SQL function can then be used in CQL: @@ -334,6 +344,7 @@ SELECT.from('Books').columns` - **Automatic provisioning**: Use model-only configuration for warned, on-demand installation into `.cds/models` - **Explicit provisioning**: Preinstall local or shared models with `npx @cap-js/ai install-model` - **Pinned artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums for later local integrity checks +- **Compatibility pre-check**: Use `npx @cap-js/ai check-model ` to inspect a repository without downloading model weights - **Hugging Face tokenization**: Uses `@huggingface/tokenizers` and truncates text to the first model input window - **Deterministic**: Same input always produces same output - **Automatic output handling**: Pooling and normalization are derived from Sentence Transformers metadata @@ -341,9 +352,13 @@ SELECT.from('Books').columns` #### Compatible encoder models -Compatible repositories must provide `onnx/model.onnx`, `tokenizer.json`, `tokenizer_config.json`, and `config.json`. The model must accept `input_ids` and may additionally accept `attention_mask` and `token_type_ids`, all as `int64` tensors, and expose a `last_hidden_state` float output. +The Hugging Face `onnx` library filter is a useful starting point, but it is not sufficient: it also includes decoder and masked-language-model exports, which do not produce sentence embeddings. A compatible repository needs a tokenizer JSON and configuration, a single discoverable ONNX encoder graph, and a usable embedding contract. + +For candidate discovery, start with the [trending Hugging Face sentence-similarity ONNX models](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&sort=trending) and run `npx @cap-js/ai check-model `. Adding the [`sentence-transformers` tag](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&other=sentence-transformers) narrows the list toward repositories with machine-readable pooling metadata. The Hub filters and the check command identify likely candidates only; always use `install-model` before deploying a model. + +The graph must accept `input_ids` and may additionally accept `attention_mask` and `token_type_ids`; all inputs must be rank-2 `int64` tensors. Token-level outputs used with pooling must be floating-point rank-3 tensors whose final dimension matches the model configuration. The runtime requires an unambiguous Sentence Transformers pooling pipeline. Pooling semantics are read from `modules.json` and its pooling configuration. Converted repositories such as `Xenova/*` can declare a single `base_model`; its immutable Sentence Transformers metadata is used to determine mean or CLS pooling and normalization. Unsupported module chains, ambiguous pooling modes, missing metadata, incompatible ONNX inputs or outputs, and explicitly incompatible Hub tasks fail with a compatibility error instead of using guessed defaults. -Pooling semantics are read from Sentence Transformers `modules.json` and its pooling configuration. Converted repositories such as `Xenova/*` can declare a single `base_model`; its immutable Sentence Transformers metadata is used to determine mean or CLS pooling and normalization. Unsupported module chains, ambiguous pooling modes, missing metadata, or incompatible ONNX inputs and outputs fail explicitly. +External ONNX tensor data is supported only for conventional files next to the selected graph: `.onnx_data`, `.onnx.data`, or numbered `.onnx.data.*` sidecars. Discovery does not parse arbitrary `external_data` references from the ONNX protobuf, so repositories using other sidecar names are rejected. Provisioning canonicalizes symlinked parent directories and rejects a model directory that is itself a symlink. Existing valid locks remain pinned and are reused rather than silently following changes to the repository's default branch. diff --git a/lib/vector_embedding/cli.js b/lib/vector_embedding/cli.js index 99665d7..2d25b3a 100644 --- a/lib/vector_embedding/cli.js +++ b/lib/vector_embedding/cli.js @@ -3,8 +3,10 @@ import path from 'node:path'; import { installModel } from './model-install.js'; import { validateEmbeddingModel } from './embedding.js'; +import { checkModel } from './model-discovery.js'; const HELP = `Usage: + npx @cap-js/ai check-model npx @cap-js/ai install-model [--directory ] Options: @@ -22,11 +24,24 @@ async function runModelCommand(argv, options = {}) { return; } - const { modelDir } = await installModel(command.model, { + if (command.name === 'check-model') { + const check = options.check ?? checkModel; + const result = await check(command.model, { + fetchImpl: options.fetchImpl, + hubClient: options.hubClient, + hubUrl: options.hubUrl + }); + stdout.write(formatModelCheck(result)); + return result; + } + + const install = options.install ?? installModel; + const { modelDir } = await install(command.model, { root, directory: command.directory, home: options.home, fetchImpl: options.fetchImpl, + hubUrl: options.hubUrl, discover: options.discover, validate: options.validate ?? validateEmbeddingModel, timeoutMs: options.timeoutMs, @@ -53,7 +68,8 @@ function findProjectRoot(start = process.cwd()) { function parseArguments(argv) { if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) return { help: true }; - if (argv[0] !== 'install-model') { + const name = argv[0]; + if (name !== 'check-model' && name !== 'install-model') { throw new Error(`Unsupported command.\n\n${HELP}`); } @@ -62,6 +78,9 @@ function parseArguments(argv) { for (let index = 1; index < argv.length; index++) { const argument = argv[index]; if (argument === '--directory') { + if (name !== 'install-model') { + throw new Error("Unknown option '--directory' for check-model"); + } const value = argv[++index]; if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`); directory = value; @@ -73,7 +92,23 @@ function parseArguments(argv) { } if (!model) throw new Error('Specify a model name'); - return { directory, model }; + return { name, directory, model }; +} + +function formatModelCheck(model) { + const modelFile = model.files.find(({ role }) => role === 'model'); + return `Likely compatible: ${model.repository} +Revision: ${model.revision} +Task: ${model.task ?? 'not declared'} +ONNX: ${modelFile.path} +Dimensions: ${model.dimensions} +Maximum input length: ${model.maxLength} +Expected ONNX output: ${model.output.name} +Pooling: ${model.output.pooling} +Normalization: ${model.output.normalize ? 'enabled' : 'disabled'} + +Run 'npx @cap-js/ai install-model ${model.repository}' for definitive ONNX Runtime validation. +`; } -export { HELP, findProjectRoot, parseArguments, runModelCommand }; +export { HELP, findProjectRoot, formatModelCheck, parseArguments, runModelCommand }; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 92b322c..e8338e6 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -75,6 +75,7 @@ async function resolveEmbeddingModel(configuration, options = {}) { directory: modelRoot, home: options.home, fetchImpl, + hubUrl: options.hubUrl, discover, validate: validate ?? validateEmbeddingModel, timeoutMs: options.provisionTimeoutMs, @@ -254,7 +255,7 @@ function validateSession(session, model) { } function processEmbedding(input, session, model) { - const results = session.run(createFeeds(input)); + const results = session.run(createFeeds(input, session.inputNames)); const output = results[model.output.name]; if (!output) { throw new Error( @@ -270,7 +271,7 @@ function processEmbedding(input, session, model) { return model.output.normalize ? normalizeEmbedding(embedding) : embedding; } -function createFeeds(encoding) { +function createFeeds(encoding, inputNames = STANDARD_INPUT_NAMES) { const { ids, attention_mask: attentionMask, @@ -282,8 +283,11 @@ function createFeeds(encoding) { attention_mask: new BigInt64Array(attentionMask.map((value) => BigInt(value))), token_type_ids: new BigInt64Array(tokenTypeIds.map((value) => BigInt(value))) }; + const supportedInputs = new Set(inputNames); return Object.fromEntries( - Object.entries(values).map(([name, data]) => [name, { type: 'int64', data, dims: dimensions }]) + Object.entries(values) + .filter(([name]) => supportedInputs.has(name)) + .map(([name, data]) => [name, { type: 'int64', data, dims: dimensions }]) ); } @@ -351,7 +355,9 @@ export { createFeeds, createTokenizerState, poolOutput, + processEmbedding, resolveEmbeddingModel, tokenizeToWindow, + validateSession, validateEmbeddingModel }; diff --git a/lib/vector_embedding/huggingface-hub.js b/lib/vector_embedding/huggingface-hub.js new file mode 100644 index 0000000..9c1170c --- /dev/null +++ b/lib/vector_embedding/huggingface-hub.js @@ -0,0 +1,198 @@ +import { setTimeout as delay } from 'node:timers/promises'; + +const MODEL_REPOSITORY = 'model'; +const HUB_REQUEST_TIMEOUT_MS = 30_000; +const HUB_REQUEST_RETRIES = 2; +const HUB_RETRY_DELAY_MS = 250; +const MODEL_INFO_FIELDS = [ + 'cardData', + 'config', + 'filePaths', + 'library_name', + 'sha', + 'tags', + 'transformersInfo' +]; + +function createHuggingFaceClient(options = {}) { + const { fetchImpl = globalThis.fetch, hubUrl, bindings } = options; + const requestOptions = { + timeoutMs: options.requestTimeoutMs ?? HUB_REQUEST_TIMEOUT_MS, + retries: options.requestRetries ?? HUB_REQUEST_RETRIES, + retryDelayMs: options.requestRetryMs ?? HUB_RETRY_DELAY_MS + }; + const loadedBindings = resolveBindings(bindings); + + return { + async getModelInfo(repository) { + const { modelInfo } = await loadedBindings; + return runHubOperation( + `reading model metadata for '${repository}'`, + (fetch) => + modelInfo({ + name: repository, + additionalFields: MODEL_INFO_FIELDS, + fetch, + hubUrl + }), + fetchImpl, + requestOptions + ); + }, + + async getFiles(repository, revision) { + const { listFiles } = await loadedBindings; + return runHubOperation( + `listing files for '${repository}'`, + async (fetch) => { + const files = []; + for await (const file of listFiles({ + repo: { type: MODEL_REPOSITORY, name: repository }, + revision, + recursive: true, + fetch, + hubUrl + })) { + if (file.type === 'file') files.push(file); + } + return files; + }, + fetchImpl, + requestOptions + ); + }, + + async getFile(repository, revision, remotePath) { + const { downloadFile } = await loadedBindings; + return runHubOperation( + `downloading '${repository}/${remotePath}'`, + async (fetch) => { + const file = await downloadFile({ + repo: { type: MODEL_REPOSITORY, name: repository }, + path: remotePath, + revision, + xet: false, + fetch, + hubUrl + }); + if (!file) { + throw new Error( + `Hugging Face model '${repository}' does not contain '${remotePath}' at ${revision}` + ); + } + return Buffer.from(await file.arrayBuffer()); + }, + fetchImpl, + requestOptions + ); + } + }; +} + +async function runHubOperation(description, operation, fetchImpl, options) { + const { timeoutMs, retries, retryDelayMs } = options; + return attemptOperation(0); + + async function attemptOperation(attempt) { + const controller = new AbortController(); + let timeout; + const timedOperation = Promise.race([ + operation(createOperationFetch(fetchImpl, controller.signal)), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new HubTimeoutError(description, timeoutMs)); + controller.abort(); + }, timeoutMs); + }) + ]); + + try { + return await timedOperation; + } catch (error) { + if (!isRetryable(error) || attempt >= retries) throw error; + await delay(retryDelayMs * 2 ** attempt); + return attemptOperation(attempt + 1); + } finally { + clearTimeout(timeout); + } + } +} + +function createOperationFetch(fetchImpl, signal) { + return async (input, init = {}) => { + try { + const response = await fetchImpl(input, { + ...init, + signal: init.signal ? AbortSignal.any([init.signal, signal]) : signal + }); + if (response.status === 408 || response.status === 429 || response.status >= 500) { + await response.body?.cancel().catch(() => {}); + throw new RetryableHubResponseError(response.status, input); + } + return response; + } catch (error) { + if (error instanceof RetryableHubResponseError) throw error; + throw new HubTransportError(input, { cause: error }); + } + }; +} + +function isRetryable(error) { + return ( + error instanceof HubTimeoutError || + error instanceof HubTransportError || + error instanceof RetryableHubResponseError + ); +} + +class HubTimeoutError extends Error { + constructor(description, timeoutMs) { + super(`Timed out after ${timeoutMs} ms while ${description}`); + this.name = 'HubTimeoutError'; + } +} + +class HubTransportError extends Error { + constructor(input, options) { + super(`Hugging Face request failed for ${String(input)}`, options); + this.name = 'HubTransportError'; + } +} + +class RetryableHubResponseError extends Error { + constructor(status, input) { + super(`Hugging Face request failed with status ${status} for ${String(input)}`); + this.name = 'RetryableHubResponseError'; + this.status = status; + } +} + +async function resolveBindings(bindings) { + if ( + typeof bindings?.modelInfo === 'function' && + typeof bindings?.listFiles === 'function' && + typeof bindings?.downloadFile === 'function' + ) { + return bindings; + } + return { ...(await loadHuggingFaceHub()), ...bindings }; +} + +async function loadHuggingFaceHub(importModule = (specifier) => import(specifier)) { + try { + return await importModule('@huggingface/hub'); + } catch (error) { + if ( + error?.code === 'ERR_MODULE_NOT_FOUND' && + /Cannot find package ['"]@huggingface\/hub['"]/.test(error.message) + ) { + throw new Error( + "Automatic Hugging Face model discovery requires @huggingface/hub. Install it with 'npm add -D @huggingface/hub'.", + { cause: error } + ); + } + throw error; + } +} + +export { HUB_REQUEST_RETRIES, HUB_REQUEST_TIMEOUT_MS, createHuggingFaceClient, loadHuggingFaceHub }; diff --git a/lib/vector_embedding/model-discovery.js b/lib/vector_embedding/model-discovery.js index b2a716f..02da9e1 100644 --- a/lib/vector_embedding/model-discovery.js +++ b/lib/vector_embedding/model-discovery.js @@ -1,20 +1,12 @@ -import { createHash } from 'crypto'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { createHuggingFaceClient } from './huggingface-hub.js'; import { assertSafeRepository, validateModelDescriptor } from './model-utils.js'; -const HUGGING_FACE_ORIGIN = 'https://huggingface.co'; -const REQUIRED_ARTIFACTS = [ - { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx' }, - { role: 'tokenizer', name: 'tokenizer.json', path: 'tokenizer.json' }, - { - role: 'tokenizerConfig', - name: 'tokenizer_config.json', - path: 'tokenizer_config.json' - }, - { role: 'auxiliary', name: 'config.json', path: 'config.json' } -]; const MODULES_FILE = 'modules.json'; const SENTENCE_CONFIG_FILE = 'sentence_bert_config.json'; +const EMBEDDING_TASKS = new Set(['feature-extraction', 'sentence-similarity']); const SUPPORTED_MODULES = new Set([ 'sentence_transformers.models.Transformer', 'sentence_transformers.models.Pooling', @@ -22,55 +14,69 @@ const SUPPORTED_MODULES = new Set([ ]); async function discoverModel(repository, options = {}) { + const { candidate, context, filesByPath, knownFiles } = await discoverModelMetadata( + repository, + options + ); + const descriptor = { ...candidate }; + delete descriptor.task; + const descriptorFiles = await Promise.all( + candidate.files.map(async (entry) => ({ + ...entry, + ...(await discoverFileIntegrity( + context, + repository, + candidate.revision, + filesByPath.get(entry.path), + knownFiles.get(entry.path) + )) + })) + ); + + return validateModelDescriptor({ ...descriptor, files: descriptorFiles }); +} + +async function checkModel(repository, options = {}) { + const { candidate } = await discoverModelMetadata(repository, options); + return candidate; +} + +async function discoverModelMetadata(repository, options) { assertSafeRepository(repository); - const fetchImpl = - typeof options === 'function' ? options : (options.fetchImpl ?? globalThis.fetch); - if (typeof fetchImpl !== 'function') throw new TypeError('A fetch implementation is required'); + const normalizedOptions = typeof options === 'function' ? { fetchImpl: options } : options; + const hubClient = normalizedOptions.hubClient ?? createHuggingFaceClient(normalizedOptions); + const context = { hubClient, fileLists: new Map(), jsonFiles: new Map() }; + const modelInfo = await hubClient.getModelInfo(repository); + rejectNonEmbeddingTask(modelInfo, repository); + const task = modelTask(modelInfo); - const context = createContext( - fetchImpl, - typeof options === 'function' ? undefined : options.origin - ); - const modelInfo = await fetchModelInfo(context, repository); const revision = immutableRevision(modelInfo, repository); - const siblings = siblingMap(modelInfo, repository); + const filesByPath = await repositoryFiles(context, repository, revision); + const modelPath = selectOnnxModel(filesByPath, repository); + const tokenizerPath = selectCompanionFile(filesByPath, modelPath, 'tokenizer.json', repository); + const tokenizerConfigPath = selectCompanionFile( + filesByPath, + modelPath, + 'tokenizer_config.json', + repository + ); + const configPath = selectCompanionFile(filesByPath, modelPath, 'config.json', repository); - const tokenizer = await fetchJsonFile(context, repository, revision, 'tokenizer.json'); - const tokenizerConfig = await fetchJsonFile( - context, + const [tokenizer, tokenizerConfig, config] = await Promise.all([ + fetchJsonFile(context, repository, revision, tokenizerPath), + fetchJsonFile(context, repository, revision, tokenizerConfigPath), + fetchJsonFile(context, repository, revision, configPath) + ]); + const dimensions = uniquePositiveInteger( + [config.value.hidden_size, config.value.n_embd, config.value.d_model, config.value.dim], repository, - revision, - 'tokenizer_config.json' + configPath ); - const config = await fetchJsonFile(context, repository, revision, 'config.json'); - const dimensions = positiveInteger(config.value.hidden_size); if (!dimensions) { - throw new Error(`Cannot determine embedding dimensions from '${repository}/config.json'`); - } - - const selected = REQUIRED_ARTIFACTS.map((artifact) => { - const sibling = siblings.get(artifact.path); - if (!sibling) { - throw new Error( - `Hugging Face model '${repository}' must contain the exact file '${artifact.path}'` - ); - } - return { ...artifact, sibling }; - }); - const externalData = [...siblings.values()] - .filter(({ rfilename }) => /^onnx\/model\.onnx_data(?:$|[._-])/u.test(rfilename)) - .map((sibling) => ({ - role: 'auxiliary', - name: sibling.rfilename.slice('onnx/'.length), - path: sibling.rfilename, - sibling - })); - if (usesExternalData(config.value) && externalData.length === 0) { throw new Error( - `Hugging Face model '${repository}' declares external ONNX data but does not contain 'onnx/model.onnx_data'` + `Cannot determine embedding dimensions from '${repository}/${configPath}' (expected hidden_size, n_embd, d_model, or dim)` ); } - selected.push(...externalData); const semantics = await discoverSentenceTransformerSemantics( context, @@ -83,86 +89,162 @@ async function discoverModel(repository, options = {}) { tokenizerConfig.value.max_length, semantics.maxLength, tokenizerConfig.value.model_max_length, - config.value.max_position_embeddings + config.value.max_position_embeddings, + config.value.n_positions, + config.value.n_ctx ]); if (!maxLength) { throw new Error(`Cannot determine the maximum input length for '${repository}'`); } + const selected = [ + artifact('model', modelPath, modelPath), + artifact('tokenizer', tokenizerPath, modelPath), + artifact('tokenizerConfig', tokenizerConfigPath, modelPath), + artifact('auxiliary', configPath, modelPath) + ]; + const externalData = [...filesByPath.values()] + .filter((file) => isExternalDataFile(file.path, modelPath)) + .map((file) => artifact('auxiliary', file.path, modelPath)); + if (usesExternalData(config.value, modelPath) && externalData.length === 0) { + throw new Error( + `Hugging Face model '${repository}' declares external ONNX data but no data file exists next to '${modelPath}'` + ); + } + selected.push(...externalData); + const knownFiles = new Map([ - ['tokenizer.json', tokenizer], - ['tokenizer_config.json', tokenizerConfig], - ['config.json', config] + [tokenizerPath, tokenizer], + [tokenizerConfigPath, tokenizerConfig], + [configPath, config] ]); - const files = await Promise.all( - selected.map(async ({ sibling, ...artifact }) => ({ - ...artifact, - ...(await discoverFileIntegrity( - context, - repository, - revision, - sibling, - knownFiles.get(artifact.path) - )) - })) - ); - - return validateModelDescriptor({ + const candidate = validateModelCandidate({ repository, revision, + task, dimensions, maxLength, - files, + files: selected, output: { name: 'last_hidden_state', pooling: semantics.pooling, normalize: semantics.normalize } }); + return { + context, + filesByPath, + knownFiles, + candidate + }; } -function createContext(fetchImpl, origin = HUGGING_FACE_ORIGIN) { - if (typeof origin !== 'string' || !origin.trim()) { - throw new TypeError('The Hugging Face origin must be a non-empty string'); - } - return { fetchImpl, origin: origin.replace(/\/$/, ''), jsonFiles: new Map() }; +function validateModelCandidate(candidate) { + validateModelDescriptor({ + ...candidate, + files: candidate.files.map((file) => ({ + ...file, + size: 1, + sha256: '0'.repeat(64) + })) + }); + return candidate; } -function usesExternalData(config) { - const value = config?.['transformers.js_config']?.use_external_data_format; - return value === true || value?.['model.onnx'] === 1 || value?.['model.onnx'] === true; +function rejectNonEmbeddingTask(modelInfo, repository) { + const task = modelTask(modelInfo); + if (typeof task === 'string' && task && !EMBEDDING_TASKS.has(task)) { + throw new Error( + `Hugging Face model '${repository}' declares task '${task}', not an embedding task` + ); + } } -async function fetchModelInfo(context, repository) { - const url = `${context.origin}/api/models/${repositoryPath(repository)}?blobs=true`; - const response = await checkedFetch(context, url); - const value = await readJsonResponse(response, url); - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`Invalid Hugging Face model information for '${repository}'`); - } - return value; +function modelTask(modelInfo) { + return modelInfo?.task; } function immutableRevision(modelInfo, repository) { - if (typeof modelInfo.sha !== 'string' || !/^[a-fA-F0-9]{40,64}$/.test(modelInfo.sha)) { + if (typeof modelInfo?.sha !== 'string' || !/^[a-fA-F0-9]{40,64}$/.test(modelInfo.sha)) { throw new Error(`Hugging Face did not return an immutable revision for '${repository}'`); } return modelInfo.sha.toLowerCase(); } -function siblingMap(modelInfo, repository) { - if (!Array.isArray(modelInfo.siblings)) { +function fileMap(files, repository) { + if (!Array.isArray(files)) { throw new Error(`Hugging Face did not return a file list for '${repository}'`); } - const siblings = new Map(); - for (const sibling of modelInfo.siblings) { - if (typeof sibling?.rfilename !== 'string') continue; - if (siblings.has(sibling.rfilename)) { - throw new Error(`Hugging Face returned duplicate file '${sibling.rfilename}'`); + const result = new Map(); + for (const file of files) { + const remotePath = file?.path ?? file?.rfilename; + if (typeof remotePath !== 'string') continue; + if (result.has(remotePath)) { + throw new Error(`Hugging Face returned duplicate file '${remotePath}'`); } - siblings.set(sibling.rfilename, sibling); + result.set(remotePath, { ...file, path: remotePath }); + } + return result; +} + +function selectOnnxModel(filesByPath, repository) { + const paths = [...filesByPath.keys()].filter((remotePath) => + remotePath.toLowerCase().endsWith('.onnx') + ); + if (paths.includes('onnx/model.onnx')) return 'onnx/model.onnx'; + if (paths.includes('model.onnx')) return 'model.onnx'; + + const conventional = paths.filter( + (remotePath) => path.posix.basename(remotePath).toLowerCase() === 'model.onnx' + ); + if (conventional.length === 1) return conventional[0]; + if (conventional.length > 1 || paths.length > 1) { + throw new Error( + `Hugging Face model '${repository}' contains ambiguous ONNX exports: ${paths.join(', ')}` + ); } - return siblings; + if (paths.length === 1) return paths[0]; + throw new Error(`Hugging Face model '${repository}' does not contain an ONNX model`); +} + +function selectCompanionFile(filesByPath, modelPath, filename, repository) { + const modelDirectory = path.posix.dirname(modelPath); + const adjacent = modelDirectory === '.' ? filename : `${modelDirectory}/${filename}`; + if (filesByPath.has(adjacent)) return adjacent; + if (filesByPath.has(filename)) return filename; + throw new Error( + `Hugging Face model '${repository}' must contain '${filename}' at the repository root or next to '${modelPath}'` + ); +} + +function artifact(role, remotePath, modelPath) { + const modelDirectory = path.posix.dirname(modelPath); + const name = + role === 'auxiliary' && isExternalDataFile(remotePath, modelPath) + ? path.posix.relative(modelDirectory, remotePath) + : path.posix.basename(remotePath); + return { role, name, path: remotePath }; +} + +function isExternalDataFile(remotePath, modelPath) { + if (path.posix.dirname(remotePath) !== path.posix.dirname(modelPath)) return false; + const name = path.posix.basename(remotePath); + const modelName = path.posix.basename(modelPath); + return ( + name.startsWith(`${modelName}_data`) || + name === `${modelName}.data` || + name.startsWith(`${modelName}.data.`) + ); +} + +function usesExternalData(config, modelPath) { + const value = config?.['transformers.js_config']?.use_external_data_format; + if (value === true) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const names = new Set([modelPath, path.posix.basename(modelPath)]); + return Object.entries(value).some( + ([name, enabled]) => names.has(name) && (enabled === true || enabled === 1) + ); } async function discoverSentenceTransformerSemantics(context, repository, modelInfo, visited) { @@ -172,9 +254,9 @@ async function discoverSentenceTransformerSemantics(context, repository, modelIn visited.add(repository); const revision = immutableRevision(modelInfo, repository); - const siblings = siblingMap(modelInfo, repository); - if (siblings.has(MODULES_FILE)) { - return readSentenceTransformerSemantics(context, repository, revision, siblings); + const filesByPath = await repositoryFiles(context, repository, revision); + if (filesByPath.has(MODULES_FILE)) { + return readSentenceTransformerSemantics(context, repository, revision, filesByPath); } const baseModel = baseModelRepository(modelInfo.cardData?.base_model); @@ -184,11 +266,11 @@ async function discoverSentenceTransformerSemantics(context, repository, modelIn ); } assertSafeRepository(baseModel); - const baseInfo = await fetchModelInfo(context, baseModel); + const baseInfo = await context.hubClient.getModelInfo(baseModel); return discoverSentenceTransformerSemantics(context, baseModel, baseInfo, visited); } -async function readSentenceTransformerSemantics(context, repository, revision, siblings) { +async function readSentenceTransformerSemantics(context, repository, revision, filesByPath) { const modules = (await fetchJsonFile(context, repository, revision, MODULES_FILE)).value; if (!Array.isArray(modules)) { throw new Error(`Invalid Sentence Transformers modules in '${repository}/${MODULES_FILE}'`); @@ -214,23 +296,19 @@ async function readSentenceTransformerSemantics(context, repository, revision, s throw new Error(`Cannot determine an unambiguous pooling pipeline for '${repository}'`); } - const poolingModule = modules[1]; - const poolingPath = moduleConfigPath(poolingModule, repository); - if (!siblings.has(poolingPath)) { + const poolingPath = moduleConfigPath(modules[1], repository); + if (!filesByPath.has(poolingPath)) { throw new Error(`Sentence Transformers pooling configuration '${poolingPath}' is missing`); } const poolingConfig = (await fetchJsonFile(context, repository, revision, poolingPath)).value; const pooling = determinePooling(poolingConfig, repository); let maxLength; - const transformer = modules[0]; const sentenceConfigPaths = [SENTENCE_CONFIG_FILE]; - if (transformer?.path) { - sentenceConfigPaths.unshift( - `${normalizedModulePath(transformer.path)}/${SENTENCE_CONFIG_FILE}` - ); + if (modules[0]?.path) { + sentenceConfigPaths.unshift(`${normalizedModulePath(modules[0].path)}/${SENTENCE_CONFIG_FILE}`); } - const sentenceConfigPath = sentenceConfigPaths.find((configPath) => siblings.has(configPath)); + const sentenceConfigPath = sentenceConfigPaths.find((candidate) => filesByPath.has(candidate)); if (sentenceConfigPath) { const sentenceConfig = await fetchJsonFile(context, repository, revision, sentenceConfigPath); maxLength = positiveInteger(sentenceConfig.value.max_seq_length); @@ -296,36 +374,51 @@ function baseModelRepository(value) { return undefined; } -async function discoverFileIntegrity(context, repository, revision, sibling, knownFile) { - const metadataChecksum = lfsChecksum(sibling); - const metadataSize = positiveInteger(sibling.size) ?? positiveInteger(sibling.lfs?.size); +async function discoverFileIntegrity(context, repository, revision, file, knownFile) { + const metadataChecksum = fileChecksum(file); + const metadataSize = positiveInteger(file?.size) ?? positiveInteger(file?.lfs?.size); if (metadataChecksum && metadataSize) { return { size: metadataSize, sha256: metadataChecksum }; } - const file = knownFile ?? (await fetchFile(context, repository, revision, sibling.rfilename)); + const downloaded = knownFile ?? (await fetchFile(context, repository, revision, file.path)); return { - size: file.bytes.byteLength, - sha256: createHash('sha256').update(file.bytes).digest('hex') + size: downloaded.bytes.byteLength, + sha256: createHash('sha256').update(downloaded.bytes).digest('hex') }; } -function lfsChecksum(sibling) { - const candidate = sibling.lfs?.sha256 ?? sibling.lfs?.oid; +function fileChecksum(file) { + const candidate = file?.lfs?.sha256 ?? file?.lfs?.oid; if (typeof candidate !== 'string') return undefined; const checksum = candidate.replace(/^sha256:/, '').toLowerCase(); return /^[a-f0-9]{64}$/.test(checksum) ? checksum : undefined; } +async function repositoryFiles(context, repository, revision) { + const key = `${repository}@${revision}`; + let files = context.fileLists.get(key); + if (!files) { + files = context.hubClient + .getFiles(repository, revision) + .then((value) => fileMap(value, repository)); + context.fileLists.set(key, files); + } + return files; +} + async function fetchJsonFile(context, repository, revision, remotePath) { const key = `${repository}@${revision}/${remotePath}`; let file = context.jsonFiles.get(key); if (!file) { - file = fetchFile(context, repository, revision, remotePath).then(({ bytes, url }) => { + file = fetchFile(context, repository, revision, remotePath).then(({ bytes }) => { try { return { bytes, value: JSON.parse(bytes.toString('utf8')) }; } catch (error) { - throw new Error(`Invalid JSON returned from ${url}: ${error.message}`, { cause: error }); + throw new Error( + `Invalid JSON returned for '${repository}/${remotePath}' at ${revision}: ${error.message}`, + { cause: error } + ); } }); context.jsonFiles.set(key, file); @@ -334,46 +427,31 @@ async function fetchJsonFile(context, repository, revision, remotePath) { } async function fetchFile(context, repository, revision, remotePath) { - const url = `${context.origin}/${repositoryPath(repository)}/resolve/${revision}/${remotePath - .split('/') - .map(encodeURIComponent) - .join('/')}`; - const response = await checkedFetch(context, url); - return { bytes: await readBytes(response), url }; -} - -async function checkedFetch(context, url) { - let response; try { - response = await context.fetchImpl(url); + const bytes = await context.hubClient.getFile(repository, revision, remotePath); + return { bytes: Buffer.from(bytes) }; } catch (error) { - throw new Error(`Cannot fetch ${url}: ${error.message}`, { cause: error }); - } - if (!response || response.ok !== true) { - throw new Error(`Cannot fetch ${url}: HTTP ${response?.status ?? 'unknown'}`); + throw new Error( + `Cannot fetch Hugging Face file '${repository}/${remotePath}' at ${revision}: ${error.message}`, + { cause: error } + ); } - return response; } -async function readJsonResponse(response, url) { - try { - if (typeof response.json === 'function') return await response.json(); - return JSON.parse((await readBytes(response)).toString('utf8')); - } catch (error) { - throw new Error(`Invalid JSON returned from ${url}: ${error.message}`, { cause: error }); - } +function positiveInteger(value) { + return Number.isSafeInteger(value) && value > 0 ? value : undefined; } -async function readBytes(response) { - if (typeof response.arrayBuffer === 'function') { - return Buffer.from(await response.arrayBuffer()); +function uniquePositiveInteger(values, repository, configPath) { + const candidates = [ + ...new Set(values.map(positiveInteger).filter((value) => value !== undefined)) + ]; + if (candidates.length > 1) { + throw new Error( + `Conflicting embedding dimensions in '${repository}/${configPath}': ${candidates.join(', ')}` + ); } - if (typeof response.text === 'function') return Buffer.from(await response.text()); - throw new Error('Fetch response does not expose arrayBuffer() or text()'); -} - -function positiveInteger(value) { - return Number.isSafeInteger(value) && value > 0 ? value : undefined; + return candidates[0]; } function minimumPositiveInteger(values) { @@ -381,10 +459,6 @@ function minimumPositiveInteger(values) { return candidates.length > 0 ? Math.min(...candidates) : undefined; } -function repositoryPath(repository) { - return repository.split('/').map(encodeURIComponent).join('/'); -} - const discoverModelDescriptor = discoverModel; -export { discoverModel, discoverModelDescriptor }; +export { checkModel, discoverModel, discoverModelDescriptor }; diff --git a/lib/vector_embedding/model-install.js b/lib/vector_embedding/model-install.js index 13f2ed3..7fee29d 100644 --- a/lib/vector_embedding/model-install.js +++ b/lib/vector_embedding/model-install.js @@ -24,7 +24,10 @@ async function installModel(repository, options = {}) { assertRepository(model, repository, modelDir); } catch (error) { if (!/Embedding model lock not found/.test(error.message)) throw error; - model = await discover(repository, { fetchImpl: options.fetchImpl }); + model = await discover(repository, { + fetchImpl: options.fetchImpl, + hubUrl: options.hubUrl + }); assertRepository(model, repository, modelDir); } @@ -36,6 +39,7 @@ async function installModel(repository, options = {}) { await provisionModel(model, { directory: modelDir, fetchImpl: options.fetchImpl, + hubUrl: options.hubUrl, validate: options.validate }); return { model, modelDir, modelRoot }; diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index 6154abf..982c7b0 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -282,6 +282,7 @@ async function downloadFile(url, outputPath, file, options = {}) { async function downloadModelIfNeeded(modelDir, model, options) { validateModelDescriptor(model); await ensureDirectory(modelDir); + const hubUrl = normalizeHubUrl(options.hubUrl); for (const file of model.files) { const filePath = path.join(modelDir, file.name); @@ -295,13 +296,20 @@ async function downloadModelIfNeeded(modelDir, model, options) { continue; } - const url = `https://huggingface.co/${model.repository}/resolve/${model.revision}/${file.path}`; + const url = `${hubUrl}/${model.repository}/resolve/${model.revision}/${file.path}`; // Files are downloaded serially to avoid multiplying startup bandwidth and memory usage. // eslint-disable-next-line no-await-in-loop await downloadFile(url, filePath, file, options); } } +function normalizeHubUrl(value = 'https://huggingface.co') { + if (typeof value !== 'string' || !value.trim()) { + throw new TypeError('The Hugging Face Hub URL must be a non-empty string'); + } + return value.replace(/\/+$/, ''); +} + async function prepareArtifactPath(modelDir, relativePath) { await assertNoSymlinkComponents(modelDir, relativePath); let current = modelDir; diff --git a/package.json b/package.json index 5e433d5..72ed612 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,14 @@ "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", "@cap-js/sqlite": ">=2", + "@huggingface/hub": "^2.15.0", "@huggingface/tokenizers": "0.1.3", "onnxruntime-node": "1.20.1", "oxigraph": "^0.5.9" }, "peerDependencies": { "@cap-js/sqlite": ">=2", + "@huggingface/hub": "^2.15.0", "@huggingface/tokenizers": "0.1.3", "@sap/cds": ">=9", "onnxruntime-node": "1.20.1", @@ -46,6 +48,9 @@ "@cap-js/sqlite": { "optional": true }, + "@huggingface/hub": { + "optional": true + }, "@huggingface/tokenizers": { "optional": true }, diff --git a/tests/huggingface-hub.test.js b/tests/huggingface-hub.test.js new file mode 100644 index 0000000..ed0b14b --- /dev/null +++ b/tests/huggingface-hub.test.js @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { + createHuggingFaceClient, + loadHuggingFaceHub +} from '../lib/vector_embedding/huggingface-hub.js'; + +describe('Hugging Face Hub adapter', () => { + test('pins all operations and forwards custom transport options', async () => { + const calls = []; + const fetchImpl = () => {}; + const bindings = { + async modelInfo(options) { + calls.push(['modelInfo', options]); + return { sha: '1'.repeat(40) }; + }, + async *listFiles(options) { + calls.push(['listFiles', options]); + yield { type: 'directory', path: 'onnx', size: 0 }; + yield { type: 'file', path: 'onnx/model.onnx', size: 42 }; + }, + async downloadFile(options) { + calls.push(['downloadFile', options]); + return new Blob(['contents']); + } + }; + const client = createHuggingFaceClient({ + fetchImpl, + hubUrl: 'https://hub.example.test', + bindings + }); + + assert.deepEqual(await client.getModelInfo('foo/bar'), { sha: '1'.repeat(40) }); + assert.deepEqual(await client.getFiles('foo/bar', '2'.repeat(40)), [ + { type: 'file', path: 'onnx/model.onnx', size: 42 } + ]); + assert.deepEqual( + await client.getFile('foo/bar', '2'.repeat(40), 'config.json'), + Buffer.from('contents') + ); + + for (const [, options] of calls) { + assert.equal(typeof options.fetch, 'function'); + assert.equal(options.hubUrl, 'https://hub.example.test'); + assert.equal('accessToken' in options, false); + } + assert.deepEqual(calls[1][1].repo, { type: 'model', name: 'foo/bar' }); + assert.equal(calls[1][1].revision, '2'.repeat(40)); + assert.equal(calls[2][1].xet, false); + }); + + test('matches the installed @huggingface/hub response contract', async () => { + const revision = '1'.repeat(40); + const contents = Buffer.from('{"hidden_size":384}'); + const requests = []; + const fetchImpl = async (input, init = {}) => { + const url = String(input); + const headers = new Headers(init.headers); + requests.push({ url, headers }); + + if (url.includes('/api/models/foo/bar/revision/HEAD?')) { + return Response.json({ + _id: 'model-id', + id: 'foo/bar', + private: false, + pipeline_tag: 'sentence-similarity', + downloads: 42, + gated: false, + likes: 7, + lastModified: '2026-01-01T00:00:00.000Z', + sha: revision, + siblings: [{ rfilename: 'config.json' }] + }); + } + if (url.includes(`/api/models/foo/bar/tree/${revision}?`)) { + return Response.json([ + { type: 'directory', path: 'onnx', size: 0 }, + { + type: 'file', + path: 'onnx/model.onnx', + size: contents.length, + lfs: { oid: '2'.repeat(64), size: contents.length, pointerSize: 128 } + } + ]); + } + if (url.endsWith(`/foo/bar/resolve/${revision}/config.json`)) { + if (headers.get('range')) { + return new Response(contents.subarray(0, 1), { + status: 206, + headers: { + 'content-range': `bytes 0-0/${contents.length}`, + 'content-type': 'application/json', + etag: '"config"' + } + }); + } + return new Response(contents, { headers: { 'content-type': 'application/json' } }); + } + throw new Error(`Unexpected request: ${url}`); + }; + const client = createHuggingFaceClient({ + fetchImpl, + hubUrl: 'https://hub.example.test', + requestRetries: 0 + }); + + const info = await client.getModelInfo('foo/bar'); + assert.equal(info.task, 'sentence-similarity'); + assert.equal(info.sha, revision); + assert.deepEqual(await client.getFiles('foo/bar', revision), [ + { + type: 'file', + path: 'onnx/model.onnx', + size: contents.length, + lfs: { oid: '2'.repeat(64), size: contents.length, pointerSize: 128 } + } + ]); + assert.deepEqual(await client.getFile('foo/bar', revision, 'config.json'), contents); + assert.ok(requests.every(({ headers }) => !headers.has('authorization'))); + }); + + test('retries transient Hub responses', async () => { + let attempts = 0; + const client = createHuggingFaceClient({ + fetchImpl: async () => { + attempts++; + return attempts === 1 ? new Response('unavailable', { status: 503 }) : Response.json({}); + }, + requestRetries: 1, + requestRetryMs: 0, + bindings: fetchOnlyBindings() + }); + + await client.getModelInfo('foo/bar'); + assert.equal(attempts, 2); + }); + + test('times out stalled Hub requests', async () => { + const client = createHuggingFaceClient({ + fetchImpl: (input, { signal }) => + new Promise((resolve, reject) => { + void input; + void resolve; + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + requestRetries: 0, + requestTimeoutMs: 10, + bindings: fetchOnlyBindings() + }); + + await assert.rejects(client.getModelInfo('foo/bar'), /Timed out after 10 ms/); + }); + + test('does not retry non-transient Hub responses', async () => { + let attempts = 0; + const client = createHuggingFaceClient({ + fetchImpl: async () => { + attempts++; + return new Response('not found', { status: 404 }); + }, + requestRetries: 2, + requestRetryMs: 0, + bindings: fetchOnlyBindings() + }); + + await assert.rejects(client.getModelInfo('foo/bar'), /status 404/); + assert.equal(attempts, 1); + }); + + test('explains how to install the missing optional discovery peer', async () => { + const missingHub = Object.assign( + new Error("Cannot find package '@huggingface/hub' imported from huggingface-hub.js"), + { code: 'ERR_MODULE_NOT_FOUND' } + ); + await assert.rejects( + loadHuggingFaceHub(async () => { + throw missingHub; + }), + /npm add -D @huggingface\/hub/ + ); + }); +}); + +function fetchOnlyBindings() { + return { + async modelInfo({ fetch }) { + const response = await fetch('https://hub.example.test/model'); + if (!response.ok) throw new Error(`status ${response.status}`); + return {}; + }, + async *listFiles() {}, + async downloadFile() {} + }; +} diff --git a/tests/model-discovery.test.js b/tests/model-discovery.test.js index e6020e2..e46a746 100644 --- a/tests/model-discovery.test.js +++ b/tests/model-discovery.test.js @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; +import path from 'node:path'; import { describe, test } from 'node:test'; -import { discoverModel } from '../lib/vector_embedding/model-discovery.js'; +import { checkModel, discoverModel } from '../lib/vector_embedding/model-discovery.js'; const REVISION = '1'.repeat(40); const BASE_REVISION = '2'.repeat(40); @@ -10,8 +11,48 @@ const REPOSITORY = 'example/embedding-model'; const BASE_REPOSITORY = 'sentence-transformers/base-model'; describe('Hugging Face model discovery', () => { - test('creates a validated descriptor from exact artifacts and Sentence Transformers metadata', async () => { - const routes = modelRoutes({ + test('allows a missing Hub task while checking metadata without downloading ONNX', async () => { + const hub = hubFor({ omitTask: true }); + + const result = await checkModel(REPOSITORY, { hubClient: hub.client }); + + assert.deepEqual( + { + repository: result.repository, + revision: result.revision, + task: result.task, + dimensions: result.dimensions, + maxLength: result.maxLength, + files: result.files.map(({ role, name, path }) => ({ role, name, path })), + output: result.output + }, + { + repository: REPOSITORY, + revision: REVISION, + task: undefined, + dimensions: 384, + maxLength: 96, + files: [ + { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx' }, + { role: 'tokenizer', name: 'tokenizer.json', path: 'tokenizer.json' }, + { + role: 'tokenizerConfig', + name: 'tokenizer_config.json', + path: 'tokenizer_config.json' + }, + { role: 'auxiliary', name: 'config.json', path: 'config.json' } + ], + output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + } + ); + assert.ok( + hub.fileRequests.every(([, , remotePath]) => !remotePath.toLowerCase().endsWith('.onnx')), + 'the metadata-only check must not fetch ONNX bytes' + ); + }); + + test('creates a descriptor from a conventional Sentence Transformers ONNX repository', async () => { + const hub = hubFor({ tokenizer: { truncation: { max_length: 96 } }, tokenizerConfig: { model_max_length: 128 }, config: { hidden_size: 384, max_position_embeddings: 512 }, @@ -19,7 +60,7 @@ describe('Hugging Face model discovery', () => { normalize: true }); - const descriptor = await discoverModel(REPOSITORY, { fetchImpl: createFetch(routes) }); + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); assert.equal(descriptor.repository, REPOSITORY); assert.equal(descriptor.revision, REVISION); @@ -44,89 +85,121 @@ describe('Hugging Face model discovery', () => { ] ); const tokenizerFile = descriptor.files.find(({ role }) => role === 'tokenizer'); - assert.equal( - tokenizerFile.sha256, - digest(routes[fileUrl(REPOSITORY, REVISION, 'tokenizer.json')]) - ); - assert.equal( - tokenizerFile.size, - routes[fileUrl(REPOSITORY, REVISION, 'tokenizer.json')].length - ); + assert.equal(tokenizerFile.sha256, digest(hub.files[REPOSITORY]['tokenizer.json'])); + assert.equal(tokenizerFile.size, hub.files[REPOSITORY]['tokenizer.json'].length); + assert.deepEqual(hub.fileLists, [[REPOSITORY, REVISION]]); }); - test('follows a pinned base_model for semantics and Sentence Transformers max length', async () => { - const routes = modelRoutes({ + test('selects a unique nested export and adjacent tokenizer/configuration files', async () => { + const hub = hubFor({ + modelPath: 'exports/encoder/model.onnx', + assetDirectory: 'exports/encoder', tokenizer: { truncation: null }, - tokenizerConfig: { model_max_length: 128 }, - config: { hidden_size: 768, max_position_embeddings: 512 }, - modules: false, - baseModel: BASE_REPOSITORY - }); - addBaseModelRoutes(routes, { - sentenceConfig: { max_seq_length: 64 }, - pooling: 'cls', - normalize: false + tokenizerConfig: { model_max_length: 1e30 }, + config: { d_model: 768, n_positions: 256 }, + sentenceConfig: undefined }); - const requested = []; - const descriptor = await discoverModel(REPOSITORY, { - fetchImpl: createFetch(routes, requested) - }); + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); - assert.equal(descriptor.maxLength, 64); - assert.equal(descriptor.output.pooling, 'cls'); - assert.equal(descriptor.output.normalize, false); - assert.ok( - requested.includes(apiUrl(BASE_REPOSITORY)), - 'the base repository is resolved through the model API' + assert.equal(descriptor.dimensions, 768); + assert.equal(descriptor.maxLength, 256); + assert.deepEqual( + descriptor.files.map(({ role, path }) => ({ role, path })), + [ + { role: 'model', path: 'exports/encoder/model.onnx' }, + { role: 'tokenizer', path: 'exports/encoder/tokenizer.json' }, + { role: 'tokenizerConfig', path: 'exports/encoder/tokenizer_config.json' }, + { role: 'auxiliary', path: 'exports/encoder/config.json' } + ] ); + }); + + test('prefers metadata next to a nested ONNX export over repository-root files', async () => { + const hub = hubFor({ + modelPath: 'exports/encoder/model.onnx', + assetDirectory: 'exports/encoder', + tokenizer: { truncation: { max_length: 96 } }, + config: { hidden_size: 768, max_position_embeddings: 256 }, + rootAssets: { + tokenizer: { truncation: { max_length: 16 } }, + tokenizerConfig: { model_max_length: 16 }, + config: { hidden_size: 16, max_position_embeddings: 16 } + } + }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.equal(descriptor.dimensions, 768); + assert.equal(descriptor.maxLength, 96); assert.ok( - requested.some((url) => url.includes(`/resolve/${BASE_REVISION}/modules.json`)), - 'base-model metadata is read from its immutable revision' + descriptor.files + .filter(({ role }) => role !== 'model') + .every(({ path }) => path.startsWith('exports/encoder/')) ); }); - test('falls back from tokenizer configuration to model configuration for max length', async () => { - const tokenizerRoutes = modelRoutes({ - tokenizer: { truncation: null }, - tokenizerConfig: { model_max_length: 192 }, - config: { hidden_size: 32, max_position_embeddings: 256 }, - sentenceConfig: undefined - }); - assert.equal( - (await discoverModel(REPOSITORY, { fetchImpl: createFetch(tokenizerRoutes) })).maxLength, - 192 + test('recognizes common Transformers dimension and sequence-limit aliases', async () => { + await Promise.all( + [ + [{ n_embd: 32, n_ctx: 1024 }, 32, 1024], + [{ d_model: 64, n_positions: 768 }, 64, 768], + [{ dim: 128, max_position_embeddings: 512 }, 128, 512] + ].map(async ([config, expectedDimensions, expectedLength]) => { + const hub = hubFor({ + tokenizer: { truncation: null }, + tokenizerConfig: { model_max_length: 1e30 }, + config, + sentenceConfig: undefined + }); + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + assert.equal(descriptor.dimensions, expectedDimensions); + assert.equal(descriptor.maxLength, expectedLength); + }) ); - const configRoutes = modelRoutes({ - tokenizer: { truncation: null }, - tokenizerConfig: { model_max_length: 1e30 }, - config: { hidden_size: 32, max_position_embeddings: 256 }, - sentenceConfig: undefined + const conflicting = hubFor({ + config: { hidden_size: 384, d_model: 768, max_position_embeddings: 512 } }); - assert.equal( - (await discoverModel(REPOSITORY, { fetchImpl: createFetch(configRoutes) })).maxLength, - 256 + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: conflicting.client }), + /Conflicting embedding dimensions/ ); }); - test('uses the lowest declared tokenizer and model input limit', async () => { - const routes = modelRoutes({ + test('follows a pinned base_model for Sentence Transformers semantics', async () => { + const hub = hubFor({ tokenizer: { truncation: null }, - tokenizerConfig: { max_length: 128, model_max_length: 512 }, - config: { hidden_size: 32, max_position_embeddings: 256 }, - sentenceConfig: { max_seq_length: 384 } + tokenizerConfig: { model_max_length: 128 }, + config: { hidden_size: 768, max_position_embeddings: 512 }, + modules: false, + baseModel: BASE_REPOSITORY + }); + hub.addBaseModel({ + sentenceConfig: { max_seq_length: 64 }, + pooling: 'cls', + normalize: false }); - assert.equal( - (await discoverModel(REPOSITORY, { fetchImpl: createFetch(routes) })).maxLength, - 128 + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.equal(descriptor.maxLength, 64); + assert.equal(descriptor.output.pooling, 'cls'); + assert.equal(descriptor.output.normalize, false); + assert.ok(hub.modelInfos.includes(BASE_REPOSITORY)); + assert.ok( + hub.fileRequests.some( + ([repository, revision, remotePath]) => + repository === BASE_REPOSITORY && + revision === BASE_REVISION && + remotePath === 'modules.json' + ) ); }); - test('includes external ONNX data files', async () => { - const routes = modelRoutes({ externalData: true }); - const descriptor = await discoverModel(REPOSITORY, { fetchImpl: createFetch(routes) }); + test('includes external ONNX data files next to the selected model', async () => { + const hub = hubFor({ externalData: true }); + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); assert.deepEqual( descriptor.files.find(({ path }) => path === 'onnx/model.onnx_data'), @@ -134,189 +207,205 @@ describe('Hugging Face model discovery', () => { role: 'auxiliary', name: 'model.onnx_data', path: 'onnx/model.onnx_data', - size: routes[fileUrl(REPOSITORY, REVISION, 'onnx/model.onnx_data')].length, - sha256: digest(routes[fileUrl(REPOSITORY, REVISION, 'onnx/model.onnx_data')]) + size: hub.files[REPOSITORY]['onnx/model.onnx_data'].length, + sha256: digest(hub.files[REPOSITORY]['onnx/model.onnx_data']) } ); }); - test('rejects missing exact artifacts and ambiguous runtime semantics', async () => { - const missing = modelRoutes({}); - const info = JSON.parse(missing[apiUrl(REPOSITORY)].toString()); - info.siblings = info.siblings.filter(({ rfilename }) => rfilename !== 'onnx/model.onnx'); - missing[apiUrl(REPOSITORY)] = json(info); + test('rejects arbitrary sidecars when external ONNX data is declared', async () => { + const hub = hubFor({ externalData: 'weights.bin' }); + await assert.rejects( - discoverModel(REPOSITORY, { fetchImpl: createFetch(missing) }), - /exact file 'onnx\/model\.onnx'/ + discoverModel(REPOSITORY, { hubClient: hub.client }), + /declares external ONNX data but no data file exists/ ); + }); - const ambiguous = modelRoutes({ pooling: ['mean', 'cls'] }); - await assert.rejects( - discoverModel(REPOSITORY, { fetchImpl: createFetch(ambiguous) }), - /Unsupported or ambiguous Sentence Transformers pooling/ + test('rejects incompatible Hugging Face tasks before downloading artifacts', async () => { + await Promise.all( + [ + ['text-generation', 'openai-community/gpt2'], + ['fill-mask', 'FacebookAI/xlm-roberta-base'] + ].map(async ([task, repository]) => { + const hub = createHub({ + [repository]: { info: { sha: REVISION, task }, files: {} } + }); + await assert.rejects( + discoverModel(repository, { hubClient: hub.client }), + new RegExp(`declares task '${task}', not an embedding task`) + ); + assert.deepEqual(hub.fileLists, []); + }) ); + }); - const invalidOrder = modelRoutes({ moduleOrder: ['Pooling', 'Transformer'] }); + test('rejects ambiguous ONNX exports and ambiguous Sentence Transformers semantics', async () => { + const ambiguousModel = hubFor({ + modelPath: 'exports/encoder.onnx', + assetDirectory: 'exports', + additionalOnnxPath: 'other/encoder.onnx' + }); await assert.rejects( - discoverModel(REPOSITORY, { fetchImpl: createFetch(invalidOrder) }), - /unambiguous pooling pipeline/ + discoverModel(REPOSITORY, { hubClient: ambiguousModel.client }), + /ambiguous ONNX exports/ ); - const conflicting = modelRoutes({ pooling: 'mean', poolingMode: 'cls' }); + const ambiguousPooling = hubFor({ pooling: ['mean', 'cls'] }); await assert.rejects( - discoverModel(REPOSITORY, { fetchImpl: createFetch(conflicting) }), + discoverModel(REPOSITORY, { hubClient: ambiguousPooling.client }), /Unsupported or ambiguous Sentence Transformers pooling/ ); + + const invalidOrder = hubFor({ moduleOrder: ['Pooling', 'Transformer'] }); + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: invalidOrder.client }), + /unambiguous pooling pipeline/ + ); }); - test('downloads an artifact to derive integrity when Hugging Face has no LFS metadata', async () => { - const routes = modelRoutes({ modelMetadata: false }); - const descriptor = await discoverModel(REPOSITORY, createFetch(routes)); + test('downloads an artifact to derive integrity when file metadata has no checksum', async () => { + const hub = hubFor({ modelMetadata: false }); + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); const model = descriptor.files.find(({ role }) => role === 'model'); - const contents = routes[fileUrl(REPOSITORY, REVISION, 'onnx/model.onnx')]; + const contents = hub.files[REPOSITORY]['onnx/model.onnx']; assert.equal(model.size, contents.length); assert.equal(model.sha256, digest(contents)); }); }); -function modelRoutes(options = {}) { - const tokenizer = json(options.tokenizer ?? { truncation: { max_length: 96 } }); - const tokenizerConfig = json(options.tokenizerConfig ?? { model_max_length: 128 }); - const configValue = options.config ?? { hidden_size: 384, max_position_embeddings: 512 }; - if (options.externalData) { - configValue['transformers.js_config'] = { - use_external_data_format: { 'model.onnx': 1 } - }; - } - const config = json(configValue); - const model = Buffer.from('fake onnx model'); - const moduleTypes = options.moduleOrder ?? [ - 'Transformer', - 'Pooling', - ...(options.normalize === false ? [] : ['Normalize']) - ]; - const modules = json( - moduleTypes.map((type, index) => ({ - idx: index, - name: String(index), - path: type === 'Pooling' ? '1_Pooling' : '', - type: `sentence_transformers.models.${type}` - })) - ); - const pooling = json(poolingConfig(options.pooling ?? 'mean', options.poolingMode)); +function hubFor(options = {}) { + const modelPath = options.modelPath ?? 'onnx/model.onnx'; + const assetDirectory = options.assetDirectory ?? ''; + const asset = (name) => (assetDirectory ? `${assetDirectory}/${name}` : name); const files = { - 'onnx/model.onnx': model, - 'tokenizer.json': tokenizer, - 'tokenizer_config.json': tokenizerConfig, - 'config.json': config + [modelPath]: Buffer.from('fake onnx model'), + [asset('tokenizer.json')]: json(options.tokenizer ?? { truncation: { max_length: 96 } }), + [asset('tokenizer_config.json')]: json(options.tokenizerConfig ?? { model_max_length: 128 }), + [asset('config.json')]: json( + options.config ?? { hidden_size: 384, max_position_embeddings: 512 } + ) }; - if (options.externalData) files['onnx/model.onnx_data'] = Buffer.from('external weights'); + if (options.rootAssets) { + files['tokenizer.json'] = json(options.rootAssets.tokenizer); + files['tokenizer_config.json'] = json(options.rootAssets.tokenizerConfig); + files['config.json'] = json(options.rootAssets.config); + } + if (options.externalData) { + const dataPath = + options.externalData === true + ? `${modelPath}_data` + : `${path.posix.dirname(modelPath)}/${options.externalData}`; + files[dataPath] = Buffer.from('external weights'); + const config = JSON.parse(files[asset('config.json')].toString()); + config['transformers.js_config'] = { use_external_data_format: { [modelPath]: 1 } }; + files[asset('config.json')] = json(config); + } + if (options.additionalOnnxPath) files[options.additionalOnnxPath] = Buffer.from('another model'); + if (options.modules !== false) { - files['modules.json'] = modules; - files['1_Pooling/config.json'] = pooling; + const moduleTypes = options.moduleOrder ?? [ + 'Transformer', + 'Pooling', + ...(options.normalize === false ? [] : ['Normalize']) + ]; + files['modules.json'] = json( + moduleTypes.map((type, index) => ({ + idx: index, + name: String(index), + path: type === 'Pooling' ? '1_Pooling' : '', + type: `sentence_transformers.models.${type}` + })) + ); + files['1_Pooling/config.json'] = json(poolingConfig(options.pooling ?? 'mean')); if (options.sentenceConfig !== undefined) { files['sentence_bert_config.json'] = json(options.sentenceConfig); } else if (!Object.hasOwn(options, 'sentenceConfig')) { files['sentence_bert_config.json'] = json({ max_seq_length: 256 }); } } - const siblings = Object.entries(files).map(([rfilename, contents]) => ({ - rfilename, - ...(rfilename === 'onnx/model.onnx' && options.modelMetadata !== false - ? { size: contents.length, lfs: { size: contents.length, sha256: digest(contents) } } - : {}) - })); - return { - [apiUrl(REPOSITORY)]: json({ - sha: REVISION, - siblings, - ...(options.baseModel ? { cardData: { base_model: options.baseModel } } : {}) - }), - ...Object.fromEntries( - Object.entries(files).map(([name, contents]) => [ - fileUrl(REPOSITORY, REVISION, name), - contents - ]) - ) + + const info = { + sha: REVISION, + ...(!options.omitTask ? { task: options.task ?? 'sentence-similarity' } : {}), + ...(options.baseModel ? { cardData: { base_model: options.baseModel } } : {}) }; + const hub = createHub({ + [REPOSITORY]: { info, files, modelMetadata: options.modelMetadata } + }); + hub.addBaseModel = (baseOptions = {}) => { + const baseFiles = { + 'modules.json': json([ + { idx: 0, path: '', type: 'sentence_transformers.models.Transformer' }, + { idx: 1, path: '1_Pooling', type: 'sentence_transformers.models.Pooling' }, + ...(baseOptions.normalize + ? [{ idx: 2, path: '2_Normalize', type: 'sentence_transformers.models.Normalize' }] + : []) + ]), + '1_Pooling/config.json': json(poolingConfig(baseOptions.pooling ?? 'mean')), + 'sentence_bert_config.json': json(baseOptions.sentenceConfig ?? { max_seq_length: 128 }) + }; + hub.add(BASE_REPOSITORY, { info: { sha: BASE_REVISION }, files: baseFiles }); + }; + return hub; } -function addBaseModelRoutes(routes, options = {}) { - const modules = json([ - { - idx: 0, - path: '', - type: 'sentence_transformers.models.Transformer' +function createHub(repositories) { + const modelInfos = []; + const fileLists = []; + const fileRequests = []; + const client = { + async getModelInfo(repository) { + modelInfos.push(repository); + const entry = repositories[repository]; + if (!entry) throw new Error(`Unknown test repository ${repository}`); + return entry.info; }, - { - idx: 1, - path: '1_Pooling', - type: 'sentence_transformers.models.Pooling' + async getFiles(repository, revision) { + fileLists.push([repository, revision]); + const entry = repositories[repository]; + if (!entry) throw new Error(`Unknown test repository ${repository}`); + return Object.entries(entry.files).map(([path, contents]) => ({ + path, + ...(path.endsWith('.onnx') && entry.modelMetadata !== false + ? { size: contents.length, lfs: { size: contents.length, sha256: digest(contents) } } + : {}) + })); }, - ...(options.normalize - ? [{ idx: 2, path: '2_Normalize', type: 'sentence_transformers.models.Normalize' }] - : []) - ]); - const pooling = json(poolingConfig(options.pooling ?? 'mean')); - const sentenceConfig = json(options.sentenceConfig ?? { max_seq_length: 128 }); - const files = { - 'modules.json': modules, - '1_Pooling/config.json': pooling, - 'sentence_bert_config.json': sentenceConfig + async getFile(repository, revision, remotePath) { + fileRequests.push([repository, revision, remotePath]); + const contents = repositories[repository]?.files[remotePath]; + if (!contents) throw new Error(`Unknown test artifact ${repository}/${remotePath}`); + return contents; + } + }; + return { + client, + files: Object.fromEntries( + Object.entries(repositories).map(([name, entry]) => [name, entry.files]) + ), + modelInfos, + fileLists, + fileRequests, + add(repository, entry) { + repositories[repository] = entry; + this.files[repository] = entry.files; + } }; - routes[apiUrl(BASE_REPOSITORY)] = json({ - sha: BASE_REVISION, - siblings: Object.keys(files).map((rfilename) => ({ rfilename })) - }); - for (const [name, contents] of Object.entries(files)) { - routes[fileUrl(BASE_REPOSITORY, BASE_REVISION, name)] = contents; - } } -function poolingConfig(pooling, poolingMode) { +function poolingConfig(pooling) { const enabled = Array.isArray(pooling) ? pooling : [pooling]; return { - ...(poolingMode ? { pooling_mode: poolingMode } : {}), pooling_mode_cls_token: enabled.includes('cls'), pooling_mode_mean_tokens: enabled.includes('mean'), - pooling_mode_max_tokens: enabled.includes('max'), + pooling_mode_max_tokens: false, pooling_mode_mean_sqrt_len_tokens: false, pooling_mode_weightedmean_tokens: false, pooling_mode_lasttoken: false }; } -function createFetch(routes, requested = []) { - return async (input) => { - const url = String(input); - requested.push(url); - const contents = routes[url]; - if (!contents) return response(Buffer.alloc(0), 404); - return response(contents, 200); - }; -} - -function response(contents, status) { - return { - ok: status >= 200 && status < 300, - status, - async json() { - return JSON.parse(contents.toString()); - }, - async arrayBuffer() { - return contents; - } - }; -} - -function apiUrl(repository) { - return `https://huggingface.co/api/models/${repository}?blobs=true`; -} - -function fileUrl(repository, revision, name) { - return `https://huggingface.co/${repository}/resolve/${revision}/${name}`; -} - function json(value) { return Buffer.from(JSON.stringify(value)); } diff --git a/tests/model-provisioning.test.js b/tests/model-provisioning.test.js index 303cc30..337d8e0 100644 --- a/tests/model-provisioning.test.js +++ b/tests/model-provisioning.test.js @@ -581,6 +581,36 @@ describe('explicit model provisioning', () => { ); }); + test('checks a model by name without provisioning it', async () => { + const root = await createTemporaryDirectory(); + const output = []; + const checked = { + repository: 'example/model', + revision: '1'.repeat(40), + task: 'sentence-similarity', + dimensions: 384, + maxLength: 128, + files: [ + { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx' }, + { role: 'tokenizer', name: 'tokenizer.json', path: 'tokenizer.json' } + ], + output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + }; + + await runModelCommand(['check-model', checked.repository], { + cwd: root, + check: async (repository) => { + assert.equal(repository, checked.repository); + return checked; + }, + stdout: { write: (value) => output.push(value) } + }); + + assert.match(output.join(''), /Likely compatible/i); + assert.match(output.join(''), /install-model.*definitive/i); + await assert.rejects(fs.access(path.join(root, '.cds', 'models')), /ENOENT/); + }); + test('requires a model name and accepts an optional cache root', async () => { await assert.rejects( runModelCommand(['install-model', '--directory', './models/custom']), @@ -590,6 +620,10 @@ describe('explicit model provisioning', () => { runModelCommand(['install-model', 'example/model', 'example/other']), /Unexpected argument 'example\/other'/ ); + await assert.rejects( + runModelCommand(['check-model', 'example/model', '--directory', './models']), + /Unknown option '--directory'/ + ); }); }); diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index ac08cf7..055e01d 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -8,7 +8,9 @@ import { createFeeds, createTokenizerState, poolOutput, - tokenizeToWindow + processEmbedding, + tokenizeToWindow, + validateSession } from '../lib/vector_embedding/embedding.js'; import { downloadFile, @@ -148,6 +150,16 @@ describe('model compatibility', () => { assert.deepEqual(Array.from(feeds.input_ids.data), [101n, 200n, 102n]); assert.deepEqual(Array.from(feeds.attention_mask.data), [1n, 0n, 1n]); assert.deepEqual(Array.from(feeds.token_type_ids.data), [0n, 1n, 1n]); + + const filtered = createFeeds( + { + ids: [101], + attention_mask: [1], + token_type_ids: [0] + }, + ['input_ids', 'attention_mask'] + ); + assert.deepEqual(Object.keys(filtered), ['input_ids', 'attention_mask']); }); test('supports mean, CLS, and already-pooled outputs', () => { @@ -170,6 +182,64 @@ describe('model compatibility', () => { ); }); + test('rejects sessions without the required input', () => { + assert.throws( + () => + validateSession( + { inputNames: ['attention_mask'], outputNames: ['last_hidden_state'] }, + { output: { name: 'last_hidden_state' } } + ), + /must expose the standard int64 input 'input_ids'/ + ); + }); + + test('rejects unsupported model inputs', () => { + assert.throws( + () => + validateSession( + { inputNames: ['input_ids', 'position_ids'], outputNames: ['last_hidden_state'] }, + { output: { name: 'last_hidden_state' } } + ), + /unsupported inputs: position_ids/ + ); + }); + + test('rejects a missing configured output', () => { + assert.throws( + () => + validateSession( + { inputNames: ['input_ids'], outputNames: ['logits'] }, + { output: { name: 'last_hidden_state' } } + ), + /output 'last_hidden_state' not found\. Available outputs: logits/ + ); + }); + + test('rejects runtime output dimensions that differ from the descriptor', () => { + const session = { + inputNames: ['input_ids'], + run() { + return { + last_hidden_state: { + type: 'float32', + data: new Float32Array([1, 2]), + dims: [1, 1, 2] + } + }; + } + }; + const model = { + dimensions: 3, + output: { name: 'last_hidden_state', pooling: 'mean', normalize: false } + }; + + assert.throws( + () => + processEmbedding({ ids: [101], attention_mask: [1], token_type_ids: [0] }, session, model), + /produced 2 dimensions; configured 3/ + ); + }); + test('requires immutable revisions, checksums, and traversal-safe paths', () => { const model = fixtureModel(Buffer.from('fixture')); assert.equal(validateModelDescriptor(model), model); @@ -282,6 +352,27 @@ describe('model download', () => { ]); }); + test('honors a custom Hub URL for model downloads', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('custom Hub model fixture'); + const model = fixtureModel(content); + const requests = []; + const fetchImpl = async (url, options) => { + requests.push([url, options]); + return new Response(content); + }; + + await downloadModelIfNeeded(directory, model, { + fetchImpl, + hubUrl: 'https://hub.example.test///' + }); + + assert.ok( + requests.every(([url]) => url.startsWith('https://hub.example.test/example/model/resolve/')) + ); + assert.ok(requests.every(([, options]) => !('headers' in options))); + }); + test('rejects oversized content without exposing a partial cache file', async () => { const directory = await createTemporaryDirectory(); const content = Buffer.from('expected'); From 6b401b97de25f3ab95af43bbd1c4dc19992b42c0 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Fri, 28 Aug 2026 14:28:12 +0200 Subject: [PATCH 20/37] fix: address ai-sqlite review feedback --- .github/actions/integration-tests/action.yml | 4 + .github/workflows/release.yml | 1 + .github/workflows/test.yml | 1 + lib/vector_embedding/huggingface-hub.js | 114 +++++++++++++++++-- lib/vector_embedding/model-discovery.js | 3 +- lib/vector_embedding/model-utils.js | 18 ++- package.json | 2 - tests/huggingface-hub.test.js | 84 ++++++++++++++ tests/model-discovery.test.js | 19 ++++ 9 files changed, 233 insertions(+), 13 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index 5c55965..b6ea525 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -83,6 +83,10 @@ runs: shell: bash run: npm install + - name: Provision embedding test model + shell: bash + run: npm run test:model:provision + - name: Set node env for HANA run: echo "NODE_VERSION_HANA=$(echo ${{ inputs.NODE_VERSION }} | tr . _)" >> $GITHUB_ENV shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f56e345..3e5b954 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ jobs: - name: Run Tests run: | npm install + npm run test:model:provision npm run lint npm run test - name: Integration tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c3bfa3..763b532 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,7 @@ jobs: npm pkg set --workspace=tests/bookshop "dependencies.@sap/cds=^9" - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} - run: npm i + - run: npm run test:model:provision - run: cd tests/bookshop && cds v -i - run: npm run test diff --git a/lib/vector_embedding/huggingface-hub.js b/lib/vector_embedding/huggingface-hub.js index 9c1170c..005c885 100644 --- a/lib/vector_embedding/huggingface-hub.js +++ b/lib/vector_embedding/huggingface-hub.js @@ -1,9 +1,13 @@ import { setTimeout as delay } from 'node:timers/promises'; +import { TransformStream } from 'node:stream/web'; + +import { normalizeHubUrl } from './model-utils.js'; const MODEL_REPOSITORY = 'model'; const HUB_REQUEST_TIMEOUT_MS = 30_000; const HUB_REQUEST_RETRIES = 2; const HUB_RETRY_DELAY_MS = 250; +const HUB_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; const MODEL_INFO_FIELDS = [ 'cardData', 'config', @@ -15,11 +19,16 @@ const MODEL_INFO_FIELDS = [ ]; function createHuggingFaceClient(options = {}) { - const { fetchImpl = globalThis.fetch, hubUrl, bindings } = options; + const { fetchImpl = globalThis.fetch, bindings } = options; + const hubUrl = options.hubUrl === undefined ? undefined : normalizeHubUrl(options.hubUrl); const requestOptions = { timeoutMs: options.requestTimeoutMs ?? HUB_REQUEST_TIMEOUT_MS, retries: options.requestRetries ?? HUB_REQUEST_RETRIES, - retryDelayMs: options.requestRetryMs ?? HUB_RETRY_DELAY_MS + retryDelayMs: options.requestRetryMs ?? HUB_RETRY_DELAY_MS, + maxResponseBytes: positiveByteLimit( + options.maxResponseBytes ?? HUB_RESPONSE_MAX_BYTES, + 'maxResponseBytes' + ) }; const loadedBindings = resolveBindings(bindings); @@ -80,7 +89,7 @@ function createHuggingFaceClient(options = {}) { `Hugging Face model '${repository}' does not contain '${remotePath}' at ${revision}` ); } - return Buffer.from(await file.arrayBuffer()); + return readBlob(file, requestOptions.maxResponseBytes, repository, remotePath); }, fetchImpl, requestOptions @@ -90,14 +99,14 @@ function createHuggingFaceClient(options = {}) { } async function runHubOperation(description, operation, fetchImpl, options) { - const { timeoutMs, retries, retryDelayMs } = options; + const { timeoutMs, retries, retryDelayMs, maxResponseBytes } = options; return attemptOperation(0); async function attemptOperation(attempt) { const controller = new AbortController(); let timeout; const timedOperation = Promise.race([ - operation(createOperationFetch(fetchImpl, controller.signal)), + operation(createOperationFetch(fetchImpl, controller.signal, maxResponseBytes)), new Promise((_, reject) => { timeout = setTimeout(() => { reject(new HubTimeoutError(description, timeoutMs)); @@ -118,7 +127,7 @@ async function runHubOperation(description, operation, fetchImpl, options) { } } -function createOperationFetch(fetchImpl, signal) { +function createOperationFetch(fetchImpl, signal, maxResponseBytes) { return async (input, init = {}) => { try { const response = await fetchImpl(input, { @@ -129,14 +138,88 @@ function createOperationFetch(fetchImpl, signal) { await response.body?.cancel().catch(() => {}); throw new RetryableHubResponseError(response.status, input); } - return response; + return await limitResponseSize(response, maxResponseBytes, input); } catch (error) { - if (error instanceof RetryableHubResponseError) throw error; + if (error instanceof RetryableHubResponseError || error instanceof HubResponseTooLargeError) { + throw error; + } throw new HubTransportError(input, { cause: error }); } }; } +async function limitResponseSize(response, maxBytes, input) { + const declaredSize = declaredResponseSize(response); + if (declaredSize !== undefined && declaredSize > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new HubResponseTooLargeError(input, maxBytes); + } + if (!response.body) return response; + + let received = 0; + const body = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + received += chunk.byteLength; + if (received > maxBytes) { + throw new HubResponseTooLargeError(input, maxBytes); + } + controller.enqueue(chunk); + } + }) + ); + const limited = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + Object.defineProperties(limited, { + redirected: { value: response.redirected }, + type: { value: response.type }, + url: { value: response.url } + }); + return limited; +} + +function declaredResponseSize(response) { + const contentLengthHeader = response.headers?.get?.('content-length'); + const contentLength = contentLengthHeader === null ? undefined : Number(contentLengthHeader); + const contentRange = response.headers?.get?.('content-range'); + const match = typeof contentRange === 'string' && /^bytes\s+\d+-\d+\/(\d+)$/iu.exec(contentRange); + const rangeTotal = match ? Number(match[1]) : undefined; + const candidates = [contentLength, rangeTotal].filter( + (value) => Number.isSafeInteger(value) && value >= 0 + ); + return candidates.length > 0 ? Math.max(...candidates) : undefined; +} + +async function readBlob(file, maxBytes, repository, remotePath) { + const description = `${repository}/${remotePath}`; + if (!Number.isSafeInteger(file.size) || file.size < 0 || typeof file.stream !== 'function') { + throw new Error(`Hugging Face returned an invalid file response for ${description}`); + } + if (file.size > maxBytes) { + throw new HubResponseTooLargeError(description, maxBytes); + } + + const chunks = []; + let received = 0; + for await (const value of file.stream()) { + const chunk = Buffer.from(value); + received += chunk.byteLength; + if (received > maxBytes) throw new HubResponseTooLargeError(description, maxBytes); + chunks.push(chunk); + } + return Buffer.concat(chunks, received); +} + +function positiveByteLimit(value, name) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError(`${name} must be a positive integer`); + } + return value; +} + function isRetryable(error) { return ( error instanceof HubTimeoutError || @@ -167,6 +250,13 @@ class RetryableHubResponseError extends Error { } } +class HubResponseTooLargeError extends Error { + constructor(input, maxBytes) { + super(`Refusing Hugging Face response for ${String(input)}: exceeds ${maxBytes} bytes`); + this.name = 'HubResponseTooLargeError'; + } +} + async function resolveBindings(bindings) { if ( typeof bindings?.modelInfo === 'function' && @@ -195,4 +285,10 @@ async function loadHuggingFaceHub(importModule = (specifier) => import(specifier } } -export { HUB_REQUEST_RETRIES, HUB_REQUEST_TIMEOUT_MS, createHuggingFaceClient, loadHuggingFaceHub }; +export { + HUB_REQUEST_RETRIES, + HUB_REQUEST_TIMEOUT_MS, + HUB_RESPONSE_MAX_BYTES, + createHuggingFaceClient, + loadHuggingFaceHub +}; diff --git a/lib/vector_embedding/model-discovery.js b/lib/vector_embedding/model-discovery.js index 02da9e1..798d911 100644 --- a/lib/vector_embedding/model-discovery.js +++ b/lib/vector_embedding/model-discovery.js @@ -86,7 +86,6 @@ async function discoverModelMetadata(repository, options) { ); const maxLength = minimumPositiveInteger([ tokenizer.value?.truncation?.max_length, - tokenizerConfig.value.max_length, semantics.maxLength, tokenizerConfig.value.model_max_length, config.value.max_position_embeddings, @@ -276,6 +275,8 @@ async function readSentenceTransformerSemantics(context, repository, revision, f throw new Error(`Invalid Sentence Transformers modules in '${repository}/${MODULES_FILE}'`); } + // modules.json defines the ordered execution pipeline. Accepting a module that this runtime + // does not execute would silently produce embeddings with different semantics from the model. for (const module of modules) { if (!module || typeof module.type !== 'string' || !SUPPORTED_MODULES.has(module.type)) { throw new Error( diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index 982c7b0..9786496 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -307,7 +307,22 @@ function normalizeHubUrl(value = 'https://huggingface.co') { if (typeof value !== 'string' || !value.trim()) { throw new TypeError('The Hugging Face Hub URL must be a non-empty string'); } - return value.replace(/\/+$/, ''); + let url; + try { + url = new URL(value.trim()); + } catch (error) { + throw new TypeError('The Hugging Face Hub URL must be a valid HTTPS URL', { cause: error }); + } + if (url.protocol !== 'https:') { + throw new TypeError('The Hugging Face Hub URL must use HTTPS'); + } + if (url.username || url.password) { + throw new TypeError('The Hugging Face Hub URL must not include credentials'); + } + if (url.search || url.hash) { + throw new TypeError('The Hugging Face Hub URL must not include a query or fragment'); + } + return url.href.replace(/\/+$/, ''); } async function prepareArtifactPath(modelDir, relativePath) { @@ -767,6 +782,7 @@ export { loadModelAndTokenizer, loadTokenizerPackage, modelDescriptorDigest, + normalizeHubUrl, provisionModel, readModelLock, verifyModelDirectory, diff --git a/package.json b/package.json index 72ed612..03b7eb4 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,6 @@ "scripts": { "lint": "npx -y eslint@10 .", "test:model:provision": "node bin/cds-ai.js install-model Xenova/all-MiniLM-L6-v2", - "pretest": "npm run test:model:provision", - "pretest:hybrid": "npm run test:model:provision", "test": "node --test tests/*.test.js", "test:hybrid": "cds bind --exec -- node --test tests/*.test.js tests/integration/*.test.js", "format": "npx -y prettier@3 . --write && format-cds -f", diff --git a/tests/huggingface-hub.test.js b/tests/huggingface-hub.test.js index ed0b14b..9dd934f 100644 --- a/tests/huggingface-hub.test.js +++ b/tests/huggingface-hub.test.js @@ -50,6 +50,22 @@ describe('Hugging Face Hub adapter', () => { assert.equal(calls[2][1].xet, false); }); + test('rejects unsafe Hub URLs', () => { + const bindings = downloadBindings(); + assert.throws( + () => createHuggingFaceClient({ hubUrl: 'http://hub.example.test', bindings }), + /must use HTTPS/ + ); + assert.throws( + () => createHuggingFaceClient({ hubUrl: 'https://user@hub.example.test', bindings }), + /must not include credentials/ + ); + assert.throws( + () => createHuggingFaceClient({ hubUrl: 'https://hub.example.test?mirror=1', bindings }), + /must not include a query or fragment/ + ); + }); + test('matches the installed @huggingface/hub response contract', async () => { const revision = '1'.repeat(40); const contents = Buffer.from('{"hidden_size":384}'); @@ -168,6 +184,64 @@ describe('Hugging Face Hub adapter', () => { assert.equal(attempts, 1); }); + test('rejects oversized Hub files from declared response metadata', async () => { + const client = createHuggingFaceClient({ + fetchImpl: async () => + new Response('oversized', { + headers: { 'content-length': '9' } + }), + requestRetries: 0, + maxResponseBytes: 8, + bindings: downloadBindings() + }); + + await assert.rejects( + client.getFile('foo/bar', '1'.repeat(40), 'config.json'), + /exceeds 8 bytes/ + ); + }); + + test('rejects oversized Hub files from a range probe before downloading them', async () => { + const client = createHuggingFaceClient({ + fetchImpl: async () => + new Response('x', { + status: 206, + headers: { 'content-length': '1', 'content-range': 'bytes 0-0/9' } + }), + requestRetries: 0, + maxResponseBytes: 8, + bindings: downloadBindings() + }); + + await assert.rejects( + client.getFile('foo/bar', '1'.repeat(40), 'config.json'), + /exceeds 8 bytes/ + ); + }); + + test('rejects streamed Hub files that exceed the response limit', async () => { + const client = createHuggingFaceClient({ + fetchImpl: async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from('four')); + controller.enqueue(Buffer.from('more')); + controller.close(); + } + }) + ), + requestRetries: 0, + maxResponseBytes: 4, + bindings: downloadBindings() + }); + + await assert.rejects( + client.getFile('foo/bar', '1'.repeat(40), 'config.json'), + /exceeds 4 bytes/ + ); + }); + test('explains how to install the missing optional discovery peer', async () => { const missingHub = Object.assign( new Error("Cannot find package '@huggingface/hub' imported from huggingface-hub.js"), @@ -193,3 +267,13 @@ function fetchOnlyBindings() { async downloadFile() {} }; } + +function downloadBindings() { + return { + async modelInfo() {}, + async *listFiles() {}, + async downloadFile({ fetch }) { + return (await fetch('https://hub.example.test/file')).blob(); + } + }; +} diff --git a/tests/model-discovery.test.js b/tests/model-discovery.test.js index e46a746..d31009a 100644 --- a/tests/model-discovery.test.js +++ b/tests/model-discovery.test.js @@ -167,6 +167,19 @@ describe('Hugging Face model discovery', () => { ); }); + test('ignores generic tokenizer max_length when determining the model input window', async () => { + const hub = hubFor({ + tokenizer: { truncation: null }, + tokenizerConfig: { max_length: 32, model_max_length: 128 }, + config: { hidden_size: 384, max_position_embeddings: 512 }, + sentenceConfig: undefined + }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.equal(descriptor.maxLength, 128); + }); + test('follows a pinned base_model for Sentence Transformers semantics', async () => { const hub = hubFor({ tokenizer: { truncation: null }, @@ -262,6 +275,12 @@ describe('Hugging Face model discovery', () => { discoverModel(REPOSITORY, { hubClient: invalidOrder.client }), /unambiguous pooling pipeline/ ); + + const unsupportedStage = hubFor({ moduleOrder: ['Transformer', 'Pooling', 'Dense'] }); + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: unsupportedStage.client }), + /Unsupported Sentence Transformers module 'sentence_transformers.models.Dense'/ + ); }); test('downloads an artifact to derive integrity when file metadata has no checksum', async () => { From 758e105af60d2978f095330f6e60219eb2be0333 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Fri, 28 Aug 2026 14:30:49 +0200 Subject: [PATCH 21/37] test: provision embedding model explicitly --- .github/actions/integration-tests/action.yml | 4 ---- .github/workflows/release.yml | 1 - .github/workflows/test.yml | 1 - package.json | 4 ++-- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/actions/integration-tests/action.yml b/.github/actions/integration-tests/action.yml index b6ea525..5c55965 100644 --- a/.github/actions/integration-tests/action.yml +++ b/.github/actions/integration-tests/action.yml @@ -83,10 +83,6 @@ runs: shell: bash run: npm install - - name: Provision embedding test model - shell: bash - run: npm run test:model:provision - - name: Set node env for HANA run: echo "NODE_VERSION_HANA=$(echo ${{ inputs.NODE_VERSION }} | tr . _)" >> $GITHUB_ENV shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e5b954..f56e345 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,6 @@ jobs: - name: Run Tests run: | npm install - npm run test:model:provision npm run lint npm run test - name: Integration tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 763b532..3c3bfa3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,6 @@ jobs: npm pkg set --workspace=tests/bookshop "dependencies.@sap/cds=^9" - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} - run: npm i - - run: npm run test:model:provision - run: cd tests/bookshop && cds v -i - run: npm run test diff --git a/package.json b/package.json index 03b7eb4..b6f43e4 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "scripts": { "lint": "npx -y eslint@10 .", "test:model:provision": "node bin/cds-ai.js install-model Xenova/all-MiniLM-L6-v2", - "test": "node --test tests/*.test.js", - "test:hybrid": "cds bind --exec -- node --test tests/*.test.js tests/integration/*.test.js", + "test": "npm run test:model:provision && node --test tests/*.test.js", + "test:hybrid": "npm run test:model:provision && cds bind --exec -- node --test tests/*.test.js tests/integration/*.test.js", "format": "npx -y prettier@3 . --write && format-cds -f", "format:check": "npx -y prettier@3 --check . && format-cds --check" }, From 69c20db45367a47e223782e5c41e561e714a45db Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Fri, 28 Aug 2026 14:35:59 +0200 Subject: [PATCH 22/37] fix: tolerate transient Hub rate limits --- lib/vector_embedding/huggingface-hub.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/vector_embedding/huggingface-hub.js b/lib/vector_embedding/huggingface-hub.js index 005c885..a46e85a 100644 --- a/lib/vector_embedding/huggingface-hub.js +++ b/lib/vector_embedding/huggingface-hub.js @@ -5,8 +5,8 @@ import { normalizeHubUrl } from './model-utils.js'; const MODEL_REPOSITORY = 'model'; const HUB_REQUEST_TIMEOUT_MS = 30_000; -const HUB_REQUEST_RETRIES = 2; -const HUB_RETRY_DELAY_MS = 250; +const HUB_REQUEST_RETRIES = 4; +const HUB_RETRY_DELAY_MS = 1_000; const HUB_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; const MODEL_INFO_FIELDS = [ 'cardData', From 39b72c5446d76cbd37cdd41a6ca7a4921e6985e4 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Fri, 28 Aug 2026 14:38:36 +0200 Subject: [PATCH 23/37] test: pin embedding model fixture --- lib/vector_embedding/huggingface-hub.js | 4 +- package.json | 2 +- .../all-MiniLM-L6-v2/embedding.lock.json | 42 +++++++++++++++++++ tests/provision-model.js | 16 +++++++ 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json create mode 100644 tests/provision-model.js diff --git a/lib/vector_embedding/huggingface-hub.js b/lib/vector_embedding/huggingface-hub.js index a46e85a..005c885 100644 --- a/lib/vector_embedding/huggingface-hub.js +++ b/lib/vector_embedding/huggingface-hub.js @@ -5,8 +5,8 @@ import { normalizeHubUrl } from './model-utils.js'; const MODEL_REPOSITORY = 'model'; const HUB_REQUEST_TIMEOUT_MS = 30_000; -const HUB_REQUEST_RETRIES = 4; -const HUB_RETRY_DELAY_MS = 1_000; +const HUB_REQUEST_RETRIES = 2; +const HUB_RETRY_DELAY_MS = 250; const HUB_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; const MODEL_INFO_FIELDS = [ 'cardData', diff --git a/package.json b/package.json index b6f43e4..3ebaa4e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "scripts": { "lint": "npx -y eslint@10 .", - "test:model:provision": "node bin/cds-ai.js install-model Xenova/all-MiniLM-L6-v2", + "test:model:provision": "node tests/provision-model.js", "test": "npm run test:model:provision && node --test tests/*.test.js", "test:hybrid": "npm run test:model:provision && cds bind --exec -- node --test tests/*.test.js tests/integration/*.test.js", "format": "npx -y prettier@3 . --write && format-cds -f", diff --git a/tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json b/tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json new file mode 100644 index 0000000..05b36c9 --- /dev/null +++ b/tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json @@ -0,0 +1,42 @@ +{ + "repository": "Xenova/all-MiniLM-L6-v2", + "revision": "751bff37182d3f1213fa05d7196b954e230abad9", + "dimensions": 384, + "maxLength": 128, + "files": [ + { + "role": "model", + "name": "model.onnx", + "path": "onnx/model.onnx", + "size": 90387606, + "sha256": "759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e" + }, + { + "role": "tokenizer", + "name": "tokenizer.json", + "path": "tokenizer.json", + "size": 711661, + "sha256": "da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0" + }, + { + "role": "tokenizerConfig", + "name": "tokenizer_config.json", + "path": "tokenizer_config.json", + "size": 366, + "sha256": "9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3" + }, + { + "role": "auxiliary", + "name": "config.json", + "path": "config.json", + "size": 650, + "sha256": "7135149f7cffa1a573466c6e4d8423ed73b62fd2332c575bf738a0d033f70df7" + } + ], + "output": { + "name": "last_hidden_state", + "pooling": "mean", + "normalize": true + }, + "formatVersion": 1 +} diff --git a/tests/provision-model.js b/tests/provision-model.js new file mode 100644 index 0000000..cf547a8 --- /dev/null +++ b/tests/provision-model.js @@ -0,0 +1,16 @@ +import fs from 'node:fs/promises'; + +import { validateEmbeddingModel } from '../lib/vector_embedding/embedding.js'; +import { + getModelDirectory, + getModelRoot, + provisionModel +} from '../lib/vector_embedding/model-utils.js'; + +const lockUrl = new URL('./fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json', import.meta.url); +const { formatVersion, ...model } = JSON.parse(await fs.readFile(lockUrl, 'utf8')); +if (formatVersion !== 1) throw new Error(`Unsupported test model lock version ${formatVersion}`); + +const modelDir = getModelDirectory(getModelRoot(undefined, process.cwd()), model.repository); +await provisionModel(model, { directory: modelDir, validate: validateEmbeddingModel }); +console.log(`Installed ${model.repository} in ${modelDir}`); From ed39a0788e7dff84221b7993ca5830c8f498e2aa Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Sun, 30 Aug 2026 19:15:29 +0200 Subject: [PATCH 24/37] docs: streamline AI plugin onboarding --- .docs/ai-core.md | 84 ++++ .docs/hana-vector-embeddings.md | 63 +++ .docs/knowledge-graph.md | 57 +++ .docs/model-selection.md | 49 ++ .docs/recommendations.md | 72 +++ .docs/vector-embeddings.md | 139 ++++++ CHANGELOG.md | 19 +- README.md | 444 ++++-------------- embeddings.md | 67 +-- package.json | 1 + tests/bookshop/package.json | 28 +- tests/bookshop/srv/cat-service.cds | 2 + tests/bookshop/srv/cat-service.js | 8 + .../all-MiniLM-L6-v2/embedding.lock.json | 20 +- tests/knowledge-graph.test.js | 2 +- tests/provision-model.js | 5 +- tests/recommendations.test.js | 14 + tests/vector.test.js | 2 +- 18 files changed, 629 insertions(+), 447 deletions(-) create mode 100644 .docs/ai-core.md create mode 100644 .docs/hana-vector-embeddings.md create mode 100644 .docs/knowledge-graph.md create mode 100644 .docs/model-selection.md create mode 100644 .docs/recommendations.md create mode 100644 .docs/vector-embeddings.md rename tests/fixtures/{Xenova => sentence-transformers}/all-MiniLM-L6-v2/embedding.lock.json (53%) diff --git a/.docs/ai-core.md b/.docs/ai-core.md new file mode 100644 index 0000000..9bcf939 --- /dev/null +++ b/.docs/ai-core.md @@ -0,0 +1,84 @@ +# SAP AI Core integration + +The plugin exposes SAP AI Core resource groups, deployments, and configurations through the `AICore` CAP service. It also manages the resource groups and SAP-RPT-1 deployments used by recommendations. + +> [!IMPORTANT] +> In multitenant applications with an MTX sidecar, include `@cap-js/ai` in the sidecar so tenant lifecycle events can manage SAP AI Core resources. + +## Service binding + +Production use requires an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. A Cloud Foundry deployment can declare it like this: + +```yaml +modules: + - name: incidents-srv + type: nodejs + path: gen/srv + requires: + - name: incidents-ai-core + +resources: + - name: incidents-ai-core + type: org.cloudfoundry.managed-service +``` + +Single-tenant applications use the `default` resource group unless configured otherwise: + +```json +{ + "cds": { + "requires": { + "AICore": { + "resourceGroup": "CUSTOM_RESOURCE_GROUP" + } + } + } +} +``` + +## Query API + +```js +const aiCore = await cds.connect.to('AICore'); +const { resourceGroups, deployments, configurations } = aiCore.entities; + +await aiCore.run(SELECT.from(resourceGroups)); +await aiCore.run(SELECT.from(resourceGroups).where({ tenantId: cds.context.tenant })); +await aiCore.run( + SELECT.from(deployments).where({ + 'resourceGroup.resourceGroupId': resourceGroups[0].resourceGroupId + }) +); +``` + +Supported `cds.ql` operations: + +| Operation | `resourceGroups` | `deployments` | `configurations` | +| ---------------------- | ---------------- | ------------- | ---------------- | +| `READ` list and single | yes | yes | yes | +| `CREATE` | yes | yes | yes | +| `UPDATE` | yes | yes | no | +| `UPSERT` | yes | yes | no | +| `DELETE` | yes | yes | no | +| `limit` | yes | yes | yes | +| `search` | no | no | yes | + +Filters are limited to simple equality checks: + +- `resourceGroups`: `tenantId`, `resourceGroupId` +- `deployments`: `id`, `resourceGroup.resourceGroupId` +- `configurations`: `resourceGroup.resourceGroupId` + +## Helper methods + +```js +const aiCore = await cds.connect.to('AICore'); +const { resourceGroups, deployments } = aiCore.entities; + +const resourceGroupId = await aiCore.resourceGroupForTenant(cds.context.tenant); +const predictions = await aiCore.predictRowColumns(/* SAP-RPT-1 payload */); +const deploymentId = await aiCore.rpt1DeploymentId(resourceGroups, { resourceGroupId }); +await aiCore.stop(deployments, { id: deploymentId }); +``` + +`rpt1DeploymentId` creates an SAP-RPT-1 deployment when the resource group does not already have one. diff --git a/.docs/hana-vector-embeddings.md b/.docs/hana-vector-embeddings.md new file mode 100644 index 0000000..0009940 --- /dev/null +++ b/.docs/hana-vector-embeddings.md @@ -0,0 +1,63 @@ +# SAP HANA vector embeddings + +SAP HANA Cloud provides `VECTOR_EMBEDDING` for generating embeddings with native models or models exposed through an SAP AI Core remote source. + +## Native models + +Add a calculated vector element to a CDS entity: + +```cds +entity Books { + key ID : Integer; + title : String(111); + descr : String(1111); + + @cds.api.ignore + embedding : Vector = (VECTOR_EMBEDDING(descr, 'DOCUMENT', 'SAP_GXY.20250407')) stored; +} +``` + +See the SAP HANA Cloud documentation for the [available models and their characteristics](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). + +## Models through SAP AI Core + +SAP HANA Cloud can also call embedding models exposed through an SAP AI Core remote source. Follow the [SAP HANA Cloud setup guide](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/creating-text-embeddings-with-sap-ai-core?locale=en-US) first. + +The fourth `VECTOR_EMBEDDING` parameter names the remote source. When it is omitted for an SAP AI Core model, the plugin uses `cds.env.ai.embeddings.remoteSource`, whose default is `AI_CORE`. + +The HDI container needs permission to reference the remote source. One setup is to create a grantor role and user: + +```sql +CREATE ROLEGROUP HDI_GRANTOR_GROUP; +CREATE ROLE HC_REMOTESOURCE_GRANTOR SET ROLEGROUP HDI_GRANTOR_GROUP; +GRANT EXECUTE ON REMOTE SOURCE + TO HC_REMOTESOURCE_GRANTOR WITH GRANT OPTION; + +ALTER USER HDI_GRANT_USER PASSWORD NO FORCE_FIRST_PASSWORD_CHANGE; +GRANT HC_REMOTESOURCE_GRANTOR TO HDI_GRANT_USER WITH GRANT OPTION; +``` + +Create a user-provided service containing those credentials: + +```sh +cf cups hana_ai -p '{"username":"HDI_GRANT_USER","password":"","tags":["hana"]}' +``` + +Then add an `.hdbgrants` file under `db/src`: + +```json +{ + "hana_ai": { + "object_owner": { + "roles": ["HC_REMOTESOURCE_GRANTOR"] + }, + "application_user": { + "roles": ["HC_REMOTESOURCE_GRANTOR"] + } + } +} +``` + +If every HDI container may access the remote source, SAP HANA Cloud also supports granting the permission to the shared HDI deployment infrastructure role. Review the linked setup guide before choosing that broader grant. + +In multitenant applications, create a remote source per tenant and grant access to the corresponding tenant binding so SAP AI Core resource groups remain isolated. diff --git a/.docs/knowledge-graph.md b/.docs/knowledge-graph.md new file mode 100644 index 0000000..f7bef86 --- /dev/null +++ b/.docs/knowledge-graph.md @@ -0,0 +1,57 @@ +# Local knowledge graph + +> [!WARNING] +> The local knowledge graph is experimental and intended only for local development. Its API and storage model may change incompatibly. + +Install the optional peer dependency: + +```sh +npm add -D oxigraph +``` + +Both `ai-sqlite` and `ai-sqlite:memory` expose a process-local Oxigraph store through `SPARQL_EXECUTE` and `sparql_table`. + +## Load RDF + +Use the HANA-compatible procedure shape: + +```sql +CALL SPARQL_EXECUTE( + 'LOAD INTO GRAPH ', + '', + ?, + ? +) +``` + +The final two `?` tokens are required output placeholders, not input bindings. The local implementation accepts literal SPARQL and header strings only. `LOAD` returns no result. + +Files must be inside the CAP project. Turtle and compressed Turtle inputs are supported; unsafe paths, symlinks escaping the project, malformed RDF, and unsupported formats are rejected. + +## Query RDF + +Procedure-style queries return a serialized result in `RESPONSE`: + +```sql +CALL SPARQL_EXECUTE( + 'SELECT ?subject WHERE { ?subject ?predicate ?object }', + 'accept:application/sparql-results+json', + ?, + ? +) +``` + +Use `sparql_table` from CQN when rows should be projected into a query result: + +```js +await db.run({ + SELECT: { + from: cds.ql.func( + 'sparql_table', + 'SELECT ?subject ?predicate WHERE { ?subject ?predicate ?object }' + ) + } +}); +``` + +The RDF store lives in memory and is tied to the database service connection. Its contents are lost on disconnect or process restart even with file-based `ai-sqlite`, and RDF updates are not transactionally coupled to SQLite changes. diff --git a/.docs/model-selection.md b/.docs/model-selection.md new file mode 100644 index 0000000..dd5dfe4 --- /dev/null +++ b/.docs/model-selection.md @@ -0,0 +1,49 @@ +# Choosing a local embedding model + +> [!WARNING] +> Local model execution and provisioning are experimental development features. A model that installs successfully is not automatically appropriate for an application's data, languages, or quality requirements. + +## Start with a focused list + +Use the [trending Apache-2.0 sentence-similarity models with ONNX artifacts](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&license=license:apache-2.0&sort=trending) as a discovery starting point: + +- `sentence-similarity` favors sentence-level semantic embeddings rather than text generation or token classification. +- `onnx` indicates that the repository advertises an ONNX export that can potentially run locally. +- `apache-2.0` narrows the list to a permissive license commonly suitable for experimentation. Always review the model card and license for your own use. +- `trending` makes active, commonly used candidates easier to find; it is not a quality ranking. + +The Hub filters are not a compatibility guarantee. Repositories can contain several ambiguous exports, unsupported processing stages, or incomplete metadata. + +## As big as necessary, as small as possible + +For local development, start with the smallest model that meets the application's language, domain, and retrieval-quality needs. Smaller models download and start faster, use less memory, and block SQLite for less time. Move to a larger model only when measurements on representative data show that the smaller one is insufficient. + +The Bookshop sample uses [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) because it is the most-downloaded candidate in the filtered list and is compact enough for a local sample. This explains the example choice; it is not a recommendation for a particular application or for production. + +Production and local models need not be equivalent. For reference, SAP HANA Cloud's [`SAP_GXY.20250407` is based on RoBERTa base](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). A local MiniLM vector has different dimensions and semantics and is not interchangeable with a HANA-generated vector. Evaluate and regenerate embeddings when changing models. + +## Check before installing + +```sh +npx @cap-js/ai check-model owner/model +npx @cap-js/ai install-model owner/model +``` + +`check-model` examines repository metadata and reports likely compatibility without downloading the model weights. `install-model` performs the definitive check by downloading, loading, and probing the selected ONNX graph. + +## Supported model contract + +Discovery currently requires: + +- a public Hugging Face repository whose declared task is absent, `sentence-similarity`, or `feature-extraction` +- an immutable repository revision +- an unambiguously selectable ONNX graph, preferring `onnx/model.onnx` and then `model.onnx` +- `tokenizer.json`, `tokenizer_config.json`, and `config.json` beside the graph or at repository root +- an embedding dimension in a common Transformers field such as `hidden_size`, `n_embd`, `d_model`, or `dim` +- a determinable input limit +- an unambiguous Sentence Transformers pipeline of Transformer, Pooling, and optional Normalize stages +- mean or CLS pooling + +The ONNX graph must accept rank-2 `int64` `input_ids`; it may also accept `attention_mask` and `token_type_ids`. A token-level output used for pooling must be a floating-point rank-3 tensor whose final dimension matches the discovered model dimension. + +Nested exports are supported when the model and its companion files are unambiguous. Conventional adjacent external-data names are supported. Other module chains, ambiguous pooling, decoder outputs, incompatible task tags, missing metadata, or arbitrary external-data paths are rejected instead of guessed. diff --git a/.docs/recommendations.md b/.docs/recommendations.md new file mode 100644 index 0000000..71806fb --- /dev/null +++ b/.docs/recommendations.md @@ -0,0 +1,72 @@ +# Recommendations + +`@cap-js/ai` uses [SAP-RPT-1](https://help.sap.com/docs/sap-ai-core/generative-ai/sap-rpt-1) through SAP AI Core to add recommendations to CAP draft entities. + +## Selecting fields + +Fields with `@Common.ValueList` or associations whose targets have `@cds.odata.valuelist` are included automatically. Disable recommendations for an individual field with `@UI.RecommendationState: 0`; dynamic expressions are supported as well. + +```cds +annotate Books with { + genre @UI.RecommendationState: (price > 200 ? 0 : 1); +} +``` + +Scalar fields without a value help can opt in with `@UI.RecommendationState`: + +```cds +entity CalibrationData : cuid { + measuringRangeMin : Decimal(16, 6) @UI.RecommendationState; + measuringRangeMax : Decimal(16, 6) @UI.RecommendationState; + description : String @UI.RecommendationState; +} +``` + +Numeric scalar fields without value helps use the `regression` task type. Other fields use `classification`. A numeric field with a value help remains a classification target. + +> [!NOTE] +> SAP Fiori Elements does not yet render recommendations for scalar fields without a value help. The backend provides them, but the client currently requests and displays recommendation fields only when they have `@Common.ValueList` or `@Common.ValueListWithFixedValues`. + +## Generated service shape + +For each draft-enabled entity with recommendable fields, the plugin adds: + +- `@UI.Recommendations: { '=': 'SAP_Recommendations' }` +- a virtual `_Recommendations` companion entity +- one recommendation array per included field + +Each recommendation contains `RecommendedFieldValue`, `RecommendedFieldDescription`, `RecommendedFieldScoreValue`, and `RecommendedFieldIsSuggestion`. Fiori Elements uses the first suggestion as the soft-fill default. + +Recommendations are calculated when a draft-entity `READ` expands `SAP_Recommendations`. Active-entity reads return no recommendations, and reads during `draftActivate` are skipped. + +## Prediction context and data handling + +The plugin sends up to 2,000 active rows of the same entity to SAP-RPT-1. Only rows for which every recommendation target is non-null are included. The active version of the current draft is replaced by the draft row containing `[PREDICT]` placeholders. + +The following elements are removed from the context: + +- `createdAt`, `createdBy`, `modifiedAt`, and `modifiedBy` +- `cds.LargeBinary` and `cds.Vector` elements +- fields excluded by `@UI.RecommendationState: 0` or a matching dynamic expression + +> [!IMPORTANT] +> All other selected columns are forwarded to SAP AI Core. Review the entity model and exclude sensitive fields explicitly. + +There is no sampling or `ORDER BY`; for entities with more than 2,000 qualifying rows, the database determines which rows are used. If `@Common.Text` is configured, the plugin performs an additional lookup to populate the recommendation description. + +## SAP-RPT-1 lifecycle + +The first prediction for a resource group creates an `sap-rpt-1-small` deployment in scenario `foundation-models` when none exists. The plugin waits for the deployment to reach `RUNNING` and reuses it afterward. + +Single-tenant applications use the configured `AICore.resourceGroup`, which defaults to `default`. Multitenant applications create a resource group per tenant during subscription and delete it during unsubscription. + +## Local development + +Without an SAP AI Core binding, the plugin uses `MockAICoreService`. It returns the first non-null value for each target column. This is useful for UI smoke tests but is not a quality signal. + +To use a real deployment locally, bind the application and start it with the `hybrid` profile: + +```sh +cds bind +cds watch --profile hybrid +``` diff --git a/.docs/vector-embeddings.md b/.docs/vector-embeddings.md new file mode 100644 index 0000000..c068560 --- /dev/null +++ b/.docs/vector-embeddings.md @@ -0,0 +1,139 @@ +# Local vector embeddings + +> [!WARNING] +> Local vector embeddings, the AI-enabled SQLite kinds, local model management, and their CLI tooling are experimental and intended to improve local development. Breaking changes are expected. For production vector search and embeddings, use SAP HANA's vector engine. + +## Database kinds and dependencies + +- `ai-sqlite` uses a file-based SQLite database. +- `ai-sqlite:memory` uses an in-memory SQLite database. + +Install the optional peers as development dependencies: + +```sh +npm add -D @cap-js/sqlite @huggingface/hub@^2.15.0 \ + @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 +``` + +`@huggingface/hub` is needed for model discovery and provisioning. `@huggingface/tokenizers` and `onnxruntime-node` are needed for inference. The exact ONNX Runtime version is currently required because synchronous SQLite functions use a version-specific native runtime interface. + +## Configuration + +Every AI-enabled SQLite service requires a model; there is no default: + +```json +{ + "cds": { + "requires": { + "db": { + "kind": "ai-sqlite", + "embedding": { + "model": "owner/model" + } + } + } + } +} +``` + +Startup fails if `cds.requires.db.embedding.model` is missing. The built-in runtime reads `model` and the optional `directory`; additional properties are allowed for extensions. + +Without `directory`, models are stored below `/.cds/models//`. If a valid installation is absent, startup prints a warning, downloads the model, generates `embedding.lock.json`, and reuses it on later starts. + +Use `ai-sqlite:memory` when the application data itself need not survive a restart: + +```json +{ + "cds": { + "requires": { + "db": { + "kind": "ai-sqlite:memory", + "embedding": { + "model": "owner/model" + } + } + } + } +} +``` + +## Explicit provisioning + +Provision the project-local model before startup: + +```sh +npx @cap-js/ai install-model owner/model +``` + +The command finds the enclosing CAP project even when run from a subdirectory. It installs into `.cds/models` at the project root. + +To reuse a model across projects, select a shared cache root: + +```sh +npx @cap-js/ai install-model owner/model --directory ~/.cds/models +``` + +```json +{ + "cds": { + "requires": { + "db": { + "kind": "ai-sqlite", + "embedding": { + "model": "owner/model", + "directory": "~/.cds/models" + } + } + } + } +} +``` + +`directory` is the cache root, so this example installs the artifacts below `~/.cds/models/owner/model`. Relative paths resolve from `cds.root`, absolute paths remain absolute, and `~/` resolves from the user's home directory. + +When `directory` is configured, startup treats it as a pre-installed cache. It validates the lock and files but does not download or modify them. Provision shared models in a controlled environment and consider making the directory read-only at runtime. + +## Compatibility check + +Inspect repository metadata without downloading model weights: + +```sh +npx @cap-js/ai check-model owner/model +``` + +This reports likely compatibility. `install-model` is definitive because it also downloads the artifacts, loads the ONNX model, verifies its inputs and output, and runs a probe inference. + +See [Choosing a model](model-selection.md) for discovery filters and the supported model contract. + +## SQL function + +Use the HANA-shaped function from CQL or SQL: + +```js +SELECT.from('Books').columns` + VECTOR_EMBEDDING(title, 'DOCUMENT', 'local') as embedding +`; +``` + +The service accepts both the three-argument form and a four-argument form with `remote_source`: + +```sql +VECTOR_EMBEDDING(text, text_type, model_and_version) +VECTOR_EMBEDDING(text, text_type, model_and_version, remote_source) +``` + +Only `text` affects local inference today. `text_type`, `model_and_version`, and `remote_source` preserve the SQL shape for development compatibility; `embedding.model` selects the actual local model. SQL `NULL` remains `NULL`, while empty text returns a zero vector. The result is a JSON string containing the model's vector dimensions. + +## Runtime behavior + +SQLite user-defined functions cannot await. Tokenization, inference, pooling, and normalization therefore run synchronously and block the Node.js event loop for each call. This tradeoff is acceptable only for local development and low-volume experiments. + +Each invocation embeds the first model input window. Longer input is truncated. Split long documents before persistence and store one vector per chunk when retrieval must cover the full text. + +## Provisioning and trust boundary + +Discovery resolves the repository to an immutable commit and generates an `embedding.lock.json` containing the selected artifacts, dimensions, input limit, pooling, normalization, sizes, and SHA-256 checksums. Downloads use bounded responses, timeouts, retries for transient failures, and size/checksum validation. Existing installations are checked for file changes before use. + +This is trust on first use, not publisher authentication. The initial installation trusts the selected public Hugging Face repository and its metadata. A self-consistent lock does not make an untrusted model safe. Installation loads the tokenizer and ONNX graph into native libraries and executes a probe in the current process. Use repositories you trust and prefer explicit provisioning in a controlled environment. + +Provisioning rejects symlinked model paths and unsafe artifact names. Conventional ONNX external-data sidecars adjacent to the selected graph are supported; arbitrary paths encoded in the ONNX protobuf are not. diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbc673..f9c1d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,23 +8,8 @@ ### Added -- **Beta:** Add the `ai-sqlite` and `ai-sqlite:memory` kinds with a `VECTOR_EMBEDDING` function using compatible ONNX encoder models - - Uses a file-based database for `ai-sqlite` and an in-memory database for `ai-sqlite:memory` - - Requires `cds.env.requires.db.embedding.model`; automatically discovers model metadata and supports warned, on-demand provisioning into `.cds/models` - - Adds `npx @cap-js/ai install-model ` with an optional shared model-cache root - - Adds metadata-only `npx @cap-js/ai check-model ` to report likely model compatibility before downloading model artifacts; installation remains the definitive runtime validation - - Uses the optional `@huggingface/tokenizers` peer dependency and truncates long input to the first model input window - - Requires `model`, supports an optional relative, absolute, or home-relative `directory`, and allows additional embedding properties for extensions; discovered metadata remains in the provisioned lock - - Discovers compatible Hugging Face ONNX encoder layouts through the optional `@huggingface/hub` peer, recognizes common Transformers configuration aliases, and loads and probes the downloaded model with ONNX Runtime before installation - - Bounds Hugging Face discovery requests with timeouts and retries transient network and server failures - - Prefers metadata adjacent to nested ONNX exports and supports conventional adjacent external-data sidecars - - Rejects incompatible decoder and masked-language-model tasks instead of guessing embedding semantics - - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source` - - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions - - Runs synchronously as required by SQLite user-defined functions and therefore blocks the Node.js event loop during tokenization and inference - - Embeds one model input window; applications split long documents and store one vector per chunk - - Uses trust-on-first-use provisioning: the lock pins the first resolved Hugging Face revision and checksums for later integrity checks but does not authenticate the model publisher -- **Experimental!:** Add local `SPARQL_EXECUTE` and `sparql_table` support to both AI-enabled SQLite kinds through the optional `oxigraph` peer dependency; the process-local RDF store is ephemeral and not transactionally coupled to SQLite +- **Experimental:** Add `ai-sqlite` and `ai-sqlite:memory` for local CAP development, including `VECTOR_EMBEDDING` with locally managed ONNX models and model provisioning tooling. +- **Experimental:** Add local `SPARQL_EXECUTE` and `sparql_table` support through the optional `oxigraph` peer dependency. ## Version 1.1.0 - 2026-07-20 diff --git a/README.md b/README.md index e0f1f57..0f4985e 100644 --- a/README.md +++ b/README.md @@ -1,422 +1,166 @@ [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/ai)](https://api.reuse.software/info/github.com/cap-js/ai) -# SAP Cloud Application Programming Model, AI plugin for Node.js +# CAP AI plugin for Node.js -## About this project +`@cap-js/ai` adds UI recommendations powered by SAP AI Core, simplified access to SAP AI Core resources, and vector embedding support for CAP applications. -The SAP Cloud Application Programming Model, AI plugin for Node.js bundles two AI capabilities to infuse into your CAP applications: -1. UI Recommendations -2. Simplified AI Core usage +## Recommendations -> [!IMPORTANT] -> In multi tenancy scenarios with a sidecar the plugin must be included in the sidecar for SAP AI Core handling. +The plugin adds SAP-RPT-1 recommendations to draft-enabled entities. Fields with a value help are included automatically: -### 1. Use case: Recommendations - -Recommendations are implemented leveraging [SAP-RPT-1](https://help.sap.com/docs/sap-ai-core/generative-ai/sap-rpt-1) and AI Core. This plugin generically hooks into any entity which has properties with a value help (detected via `@Common.ValueList` on the property or `@cds.odata.valuelist` on the association target). - -```cds +```cds +@odata.draft.enabled entity Books { - key ID : Integer; - title : String(111); - descr : String(1111); - genre : Association to one Genres; - status : Association to one Status; + key ID : Integer; + title : String; + genre : Association to Genres; + price : Decimal; } + annotate Genres with @cds.odata.valuelist; -annotate Books with { - status @Common.ValueList : { - CollectionPath : 'Status', - Parameters: [ - { - $Type: 'Common.ValueListParameterInOut' - ValueListProperty : 'code', - LocalDataProperty : status_code - } - ] - } -} ``` ![Recommendations as default values](./_assets/recommendation-default.png) -![Recommendation in Value Help](./_assets/recommendation-value-help.png) -![Accept recommendations](./_assets/accept-recommendations.png) - -The genre field on the UI now automatically has recommendations. If you do not want recommendations for a specific field, it can be annotated with `@UI.RecommendationState`. -```cds -annotate Books with { - genre @UI.RecommendationState : 0; -} -``` - -Dynamic expressions as values for `@UI.RecommendationState`, work as well! +Use `@UI.RecommendationState` to opt individual fields in or out: ```cds annotate Books with { - genre @UI.RecommendationState : (price > 200 ? 0 : 1); + genre @UI.RecommendationState: 0; + price @UI.RecommendationState; } ``` -#### Regression Recommendations on fields without a value help - -By default, the plugin only enhances fields that have a value help list since these columns are good prediction targets for classification. However, some fields are good targets but have no value list: free-form numerics like measurement ranges, calibration values, or planning estimates. Annotate these with `@UI.RecommendationState` to opt in: - -```cds -entity CalibrationData : cuid { - measuringRangeMin : Decimal(16, 6) @UI.RecommendationState; - measuringRangeMax : Decimal(16, 6) @UI.RecommendationState; - operatingPoint : Decimal(16, 6) @UI.RecommendationState; - description : String @UI.RecommendationState; -} -``` - -The annotation only takes effect on **scalar** elements (no associations / compositions / unmanaged elements; for those, attach a value help instead). Annotated fields are added to the entity's `_Recommendations` companion just like value-helped fields, and Fiori Elements' soft-fill placeholder renders the prediction in the empty input. - -`task_type` is chosen automatically per column: -- numeric scalar (`Integer*`, `Decimal`, `Double`) annotated with `@UI.RecommendationState` → **`regression`** so RPT-1 can interpolate continuous values, -- everything else → **`classification`**. - -> [!NOTE] -> Numeric fields that have a value help (e.g. a fixed price-point list) stay on classification — `@UI.RecommendationState` is only needed when there is *no* value help. Combining both is unnecessary. - -> [!WARNING] -> SAP Fiori Elements does not yet support rendering recommendations for scalar fields without a value help. The backend correctly provides predictions for these fields, but the Fiori Elements client currently only requests and displays recommendations for fields annotated with `@Common.ValueList` or `@Common.ValueListWithFixedValues`. - -
-How recommendations work under the hood - -A short FAQ for integrators, so you don't have to read the source. - - -**What does the plugin emit on the OData service?** -On every draft-enabled entity that has at least one value-helped field, it adds an entity-level annotation `@UI.Recommendations: { '=': 'SAP_Recommendations' }` plus a synthetic companion entity (`_Recommendations`, `@cds.persistence.skip`) with one virtual array per recommendable field. Each item carries `RecommendedFieldValue`, `RecommendedFieldDescription`, `RecommendedFieldScoreValue` and `RecommendedFieldIsSuggestion` — the shape Fiori Elements expects for `UI.RecommendationListType`. The first entry per field has `RecommendedFieldIsSuggestion: true` and is rendered as the soft-fill default. - -**When does it run?** -On READ requests to a draft entity that expand `SAP_Recommendations`. Reads against the active entity return nothing in that field. Reads during `draftActivate` are skipped. - -**What data is sent to RPT-1 as context?** -Up to 2000 rows from the **active** version of the same entity, restricted to rows where every recommendable field is non-null. The columns `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy` plus any `cds.LargeBinary` / `cds.Vector` elements are stripped. The active row corresponding to the draft (if any) is removed and replaced by the draft row carrying `[PREDICT]` placeholders in the columns to predict. There is no sampling or `ORDER BY` — for tables larger than 2000 rows, which rows make the cut is determined by the database. - -> [!IMPORTANT] -> Everything in the remaining columns is forwarded to AI Core. Annotate sensitive fields with `@UI.RecommendationState : 0` (or a dynamic expression) to keep them out of both the predictions and the context payload. - -**How are descriptions populated?** -For each predicted value, the plugin issues an extra SELECT against the field's `@Common.Text` association (if set) to fetch the human-readable label. Fields without `@Common.Text` get an empty `RecommendedFieldDescription`. - -**RPT-1 deployment lifecycle** -First prediction call against a resource group provisions an `sap-rpt-1-small` deployment in scenario `foundation-models` (executable `aicore-sap`) and polls up to 10× with exponential backoff until it reaches `RUNNING`. Subsequent calls reuse the cached deployment. Single-tenant uses the configured `resourceGroup` (default `'default'`); multi-tenant creates one resource group per tenant on subscribe (label `ext.ai.sap.com/CDS_TENANT_ID`) and deletes it on unsubscribe. - -**Local development** -Without an AI Core binding the plugin uses `MockAICoreService`, which returns the first non-null value of each target column from the context as the "prediction" — useful for UI smoke tests, useless as a quality signal. Run `cds bind ` and start with profile `hybrid` to talk to a real AI Core deployment locally. - -
- -### 2. Use case: Simplified AI Core usage - -The plugin introduces an `AICore` CAP service that automatically performs some administrative tasks and offers simplified access to AI Core. - -#### Automatic operations - -- The plugin automatically creates a new SAP AI Core resource group per tenant during tenant onboarding and deletes it during offboarding. -- The plugin automatically creates an RPT-1 deployment per resource group for the recommendations feature. - -#### Simplified AI Core API access - -```js -const aiCore = await cds.connect.to('AICore'); -const {resourceGroups, deployments, configurations} = aiCore.entities; -await aiCore.run(SELECT.from(resourceGroups)); -await aiCore.run(SELECT.from(resourceGroups).where({tenantId: cds.context.tenant})); -await aiCore.run(SELECT.from(deployments).where({'resourceGroup.resourceGroupId': resourceGroups[0].resourceGroupId})); -await aiCore.run(SELECT.from(configurations).where({'resourceGroup.resourceGroupId': resourceGroups[0].resourceGroupId})); -``` - -Currently, the following `cds.ql` operations are supported: +A production deployment requires an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. Without one, local development uses a mock implementation for UI smoke tests. See [Recommendations](.docs/recommendations.md) for regression targets, request behavior, data handling, and deployment lifecycle. -| Operation | resourceGroups | deployments | configurations | -|-----------|---------------|-------------|----------------| -| **READ (list)** | ✓ | ✓ | ✓ | -| - limit | ✓ | ✓ | ✓ | -| - where* | `tenantId`, `resourceGroupId` | `resourceGroup.resourceGroupId` | `resourceGroup.resourceGroupId` | -| - search | - | - | ✓ | -| **READ (single)** | ✓ | ✓ | ✓ | -| **CREATE** | ✓ | ✓ | ✓ | -| **UPDATE** | ✓ | ✓ | - | -| - where* | `tenantId`, `resourceGroupId` | `id`, `resourceGroup.resourceGroupId` | - | -| **UPSERT** | ✓ | ✓ | - | -| - where* | - | `id`, `resourceGroup.resourceGroupId` | - | -| **DELETE** | ✓ | ✓ | - | -| - where* | `tenantId`, `resourceGroupId` | `id`, `resourceGroup.resourceGroupId` | - | +## SAP AI Core -\* Only simple equality checks against the listed properties are supported - -Next to CRUD operations the following helper functions can be used: +The plugin provides an `AICore` CAP service for managing resource groups, deployments, and configurations: ```js const aiCore = await cds.connect.to('AICore'); -const {resourceGroups, deployments, configurations} = aiCore.entities; - -// Fetch a resource group for a CDS tenant ID -const resourceGroupId = await aiCore.resourceGroupForTenant(cds.context.tenant) +const { resourceGroups, deployments } = aiCore.entities; -// Call the RPT-1 API to fetch predictions - see AICoreService.cds for the schema -const predictions = await aiCore.predictRowColumns(/** RPT-1 payload */) - -/** - * Returns the deployment ID for RPT-1. If no RPT-1 deployment exists, creates one for the - * resource group -*/ -const rpt1DeploymentId = await aiCore.rpt1DeploymentId(resourceGroups, {resourceGroupId}) - -// Stops an AI Core deployment -await aiCore.stop(deployments, {id: ''}) +const groups = await aiCore.run(SELECT.from(resourceGroups)); +await aiCore.stop(deployments, { id: '' }); ``` -## Requirements and Setup - -To use the plugin in production scenarios you need an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding. The plugin will automatically create resource groups per tenant in multi-tenancy scenarios and create an RPT-1 deployment in each for the recommendations feature. In single-tenant setups the plugin uses the 'default' resource group and creates an RPT-1 deployment as well if none exists. - -For single-tenant deployments you can change the resource group as follows: - -```json -{ - "cds": { - "requires": { - "AICore": { - "resourceGroup": "CUSTOM_SINGLE_TENANT_RESOURCE_GROUP" - } - } - } -} -``` - -For Cloud Foundry apps an example config could look like this: - -```yaml -modules: - - name: incidents-srv - type: nodejs - path: gen/srv - requires: - - name: incidents-ai-core -resources: - - name: incidents-ai-core - type: org.cloudfoundry.managed-service -``` +See [SAP AI Core integration](.docs/ai-core.md) for setup, supported queries, helper methods, and multitenancy. -### 3. Local Vector Embeddings with SQLite +## SAP HANA vector embeddings -The beta AI-enabled SQLite database kinds extend `@cap-js/sqlite` with local semantic embeddings using an ONNX encoder model: +On SAP HANA Cloud, use `VECTOR_EMBEDDING` in calculated vector elements. The plugin also supports models exposed through an SAP AI Core remote source: -- `ai-sqlite` uses a file-based SQLite database. -- `ai-sqlite:memory` uses an in-memory SQLite database. - -Configure the embedding model explicitly for every service. - -#### Usage - -Install the optional peer dependencies as development dependencies: - -```sh -npm add -D @cap-js/sqlite @huggingface/hub@^2.15.0 @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 oxigraph -``` - -These packages are optional peer dependencies of `@cap-js/ai` and are required only for the corresponding local SQLite capabilities. `@huggingface/hub` is required for explicit or ad-hoc model provisioning. Both database kinds currently require exactly `onnxruntime-node` 1.20.1 because synchronous SQLite functions need a version-specific native runtime API. - -Tokenization, ONNX inference, pooling, and normalization run synchronously for each `VECTOR_EMBEDDING` call. SQLite user-defined functions cannot await, so inference blocks the Node.js event loop until it completes. The feature is intended for local development and low-volume use; server workloads should precompute or batch embeddings outside SQL. - -#### Model provisioning - -The built-in embedding runtime reads a model name and an optional model-cache root: - -```json -{ - "cds": { - "requires": { - "db": { - "kind": "ai-sqlite", - "embedding": { - "model": "foo/bar" - } - } - } - } -} -``` - -This configuration uses a file-based SQLite database. For an in-memory database, change the kind to `ai-sqlite:memory`; no `credentials.url` is required: - -```json -{ - "cds": { - "requires": { - "db": { - "kind": "ai-sqlite:memory", - "embedding": { - "model": "foo/bar" - } - } - } - } -} -``` - -`embedding.model` is required. If it is absent, either kind fails during startup. Additional properties are allowed so extensions can add configuration of their own; built-in model provisioning only reads `model` and `directory`. - -Without `directory`, the model is stored below the CAP project at `.cds/models/foo/bar`. Startup reuses a valid installation from there. If it is missing, startup logs a warning, discovers and downloads the model, generates `embedding.lock.json`, and reuses that installation on subsequent starts. - -Only install models from repositories you trust. Provisioning is trust-on-first-use: the first download trusts the named repository and its Hugging Face metadata, then pins the resolved revision, sizes, and checksums in `embedding.lock.json`. Subsequent starts detect local corruption or repository drift, but the lock does not authenticate the publisher or make an untrusted model safe. Provisioning loads the downloaded tokenizer and ONNX graph into the native runtime and executes a startup probe in the current process. - -To provision the project-local model before startup instead: - -```sh -npx @cap-js/ai install-model foo/bar -``` - -The command locates the enclosing CAP project and uses its root for `.cds/models` and relative `--directory` values, even when invoked from a project subdirectory. - -To check whether a Hugging Face repository is likely compatible before downloading it: - -```sh -npx @cap-js/ai check-model foo/bar -``` - -`check-model` only reads repository, configuration, and tokenizer metadata from the Hub. It does not download the ONNX artifact or write to the model cache. Its result is therefore a likely-compatibility check; `install-model` is definitive because it also loads and probes the downloaded model with ONNX Runtime. - -To share a model across projects, select another cache root: - -```sh -npx @cap-js/ai install-model foo/bar --directory ~/.cds/models -``` - -```json -{ - "cds": { - "requires": { - "db": { - "kind": "ai-sqlite", - "embedding": { - "model": "foo/bar", - "directory": "~/.cds/models" - } - } - } - } -} -``` - -`directory` always names the cache root; the model is stored below it using the repository path, for example `~/.cds/models/foo/bar`. Relative directories are resolved from `cds.root`, absolute directories are used unchanged, and `~/` is resolved from the user's home directory. - -When `directory` is configured, startup treats it as a pre-installed shared cache: it checks the pinned files but does not download or modify them. Provision models in a trusted build environment, retain the generated lock, and make the shared directory read-only at runtime. - -##### Automatic model discovery - -The installer uses the official Hugging Face Hub client to resolve the model's current revision to an immutable commit, enumerate its files, and retrieve discovery metadata. Selected artifacts are then streamed into the model cache with integrity checks. Model discovery and installation currently support public repositories only. The resolved lock contains the commit, artifact paths, sizes, checksums, dimensions, tokenizer limit, pooling, and normalization metadata; it is written to `embedding.lock.json` alongside the downloaded artifacts. Hub requests use bounded timeouts and retry transient network and server failures. - -Discovery is layout-aware rather than tied to one exporter. It prefers `onnx/model.onnx`, then `model.onnx`, a unique nested `model.onnx`, or a sole ONNX file. For a nested model it prefers adjacent tokenizer/configuration files and falls back to repository-root files. Common Transformers configuration names for dimensions (`hidden_size`, `n_embd`, `d_model`, and `dim`) and input length are recognized. - -The downloaded ONNX model is loaded and probed with ONNX Runtime before it is accepted. This verifies that its inputs, output shape, element type, and dimensions work as an embedding model, so a decoder's logits graph cannot accidentally be installed as one. Discovery also rejects repositories explicitly tagged for incompatible tasks such as text generation or masked-language modeling. Once installed, startup uses the pinned lock and does not follow later changes to the model repository. - -The HANA-compatible SQL function can then be used in CQL: - -```js -SELECT.from('Books').columns` - VECTOR_EMBEDDING(title, 'DOCUMENT', 'SAP_GXY.20250407') as embedding -`; +```cds +@cds.api.ignore +embedding : Vector = (VECTOR_EMBEDDING(descr, 'DOCUMENT', 'SAP_GXY.20250407')) stored; ``` -`VECTOR_EMBEDDING` embeds one model input window. Text beyond the tokenizer's input limit is truncated. For long-document retrieval, split documents before persistence and store one vector per chunk instead of combining chunk embeddings in this function. Each invocation performs synchronous inference and blocks the Node.js event loop while it runs. +See [SAP HANA vector embeddings](.docs/hana-vector-embeddings.md) for native models, remote sources, and HDI privileges. -**Parameters:** +## Local vector embeddings with SQLite (experimental) -- `text` - Text to embed (`NULL` remains `NULL`; empty text returns a zero vector) -- `text_type` - Type of text, e.g., `'DOCUMENT'` (currently informational) -- `model_and_version` - Compatibility model identifier, e.g., `'SAP_GXY.20250407'` or `'SAP_GXY.20240715'` (currently informational; the service's `embedding` option selects the local model) +> [!WARNING] +> `ai-sqlite`, local vector embeddings, local model management, and the related tooling are experimental facilities for local development. Breaking changes are expected, including changes caused by SQLite's synchronous function interface and by local model management. Use SAP HANA's vector engine for production vector workloads. -**Returns:** +Here is a complete Bookshop example. -- JSON stringified array of embedding values with the configured model's dimensions +1. Install the local-development dependencies: -**Features:** + ```sh + npm add -D @cap-js/ai @cap-js/sqlite @huggingface/hub@^2.15.0 \ + @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 + ``` -- **Initialization**: The ONNX model is loaded when the AI-enabled SQLite service starts -- **Automatic provisioning**: Use model-only configuration for warned, on-demand installation into `.cds/models` -- **Explicit provisioning**: Preinstall local or shared models with `npx @cap-js/ai install-model` -- **Pinned artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums for later local integrity checks -- **Compatibility pre-check**: Use `npx @cap-js/ai check-model ` to inspect a repository without downloading model weights -- **Hugging Face tokenization**: Uses `@huggingface/tokenizers` and truncates text to the first model input window -- **Deterministic**: Same input always produces same output -- **Automatic output handling**: Pooling and normalization are derived from Sentence Transformers metadata -- **Semantic similarity**: Embeddings capture text meaning for similarity search +2. Configure the database and model in `package.json`: -#### Compatible encoder models + ```json + { + "cds": { + "requires": { + "db": { + "kind": "ai-sqlite", + "embedding": { + "model": "sentence-transformers/all-MiniLM-L6-v2" + } + } + } + } + } + ``` -The Hugging Face `onnx` library filter is a useful starting point, but it is not sufficient: it also includes decoder and masked-language-model exports, which do not produce sentence embeddings. A compatible repository needs a tokenizer JSON and configuration, a single discoverable ONNX encoder graph, and a usable embedding contract. +3. Add an embedding preview to the Bookshop service: -For candidate discovery, start with the [trending Hugging Face sentence-similarity ONNX models](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&sort=trending) and run `npx @cap-js/ai check-model `. Adding the [`sentence-transformers` tag](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&other=sentence-transformers) narrows the list toward repositories with machine-readable pooling metadata. The Hub filters and the check command identify likely candidates only; always use `install-model` before deploying a model. + ```cds + function embedding(text : String) returns LargeString; + ``` -The graph must accept `input_ids` and may additionally accept `attention_mask` and `token_type_ids`; all inputs must be rank-2 `int64` tensors. Token-level outputs used with pooling must be floating-point rank-3 tensors whose final dimension matches the model configuration. The runtime requires an unambiguous Sentence Transformers pooling pipeline. Pooling semantics are read from `modules.json` and its pooling configuration. Converted repositories such as `Xenova/*` can declare a single `base_model`; its immutable Sentence Transformers metadata is used to determine mean or CLS pooling and normalization. Unsupported module chains, ambiguous pooling modes, missing metadata, incompatible ONNX inputs or outputs, and explicitly incompatible Hub tasks fail with a compatibility error instead of using guessed defaults. + ```js + this.on('embedding', async (req) => { + const [row] = await cds.db.run( + `SELECT VECTOR_EMBEDDING(?, 'DOCUMENT', 'local') AS embedding`, + [req.data.text] + ); + return row.embedding; + }); + ``` -External ONNX tensor data is supported only for conventional files next to the selected graph: `.onnx_data`, `.onnx.data`, or numbered `.onnx.data.*` sidecars. Discovery does not parse arbitrary `external_data` references from the ONNX protobuf, so repositories using other sidecar names are rejected. +4. Start the application and call the function: -Provisioning canonicalizes symlinked parent directories and rejects a model directory that is itself a symlink. Existing valid locks remain pinned and are reused rather than silently following changes to the repository's default branch. + ```sh + cds w + ``` -**Error Handling:** + In another terminal: -- Starting either AI-enabled SQLite kind fails if `cds.env.requires.db.embedding.model` is not set or the ONNX model cannot be initialized -- A missing model in the project-local `.cds/models` cache is installed after a startup warning -- Starting either AI-enabled SQLite kind fails with a provisioning command if a configured model directory is missing or fails integrity checks -- Provisioning downloads are time-limited and accepted only when their expected size and SHA-256 match -- Throws if embedding generation fails + ```sh + curl 'http://localhost:4004/odata/v4/catalog/embedding(text=%27A%20book%20about%20travel%27)' + ``` -#### Experimental local knowledge graph +The first start warns that the model is missing, downloads it to `.cds/models`, and then initializes it. The response's `value` contains a JSON-encoded vector with 384 numbers. Later starts reuse the installed model. -Both AI-enabled SQLite kinds expose a process-local Oxigraph store through `SPARQL_EXECUTE` and `sparql_table`. +This example uses [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) because it is the most-downloaded model in the filtered list below and is small enough for a local-development sample. It is an example, not a model recommendation. -The supported procedure-compatible form is: +Start model discovery with [trending Apache-2.0 sentence-similarity models that provide ONNX artifacts](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&license=license:apache-2.0&sort=trending). These filters select a relevant task, a locally runnable format, and a permissive license, but they do not guarantee compatibility. Choose a model as big as necessary and as small as possible, then validate it with the provided tooling. -```sql -CALL SPARQL_EXECUTE('', '', ?, ?) -``` +See [Choosing a model](.docs/model-selection.md) and [Local vector embeddings](.docs/vector-embeddings.md) for compatibility checks, explicit or shared provisioning, runtime behavior, and limitations. -The final two `?` tokens are required HANA-compatible output placeholders, not input bindings. The local implementation accepts literal SPARQL and header strings only. Query operations return an object with a serialized `RESPONSE`; `LOAD` returns no result. This is a compatibility shim rather than a general stored-procedure implementation. +## Advanced -The Oxigraph store is in memory and tied to the service connection. Its triples are lost on disconnect or process restart, including when `ai-sqlite` uses a file-based SQLite database, and its updates are not transactionally coupled to SQLite. +- [Recommendations](.docs/recommendations.md) — generated service shape, prediction context, regression targets, and lifecycle +- [SAP AI Core integration](.docs/ai-core.md) — bindings, multitenancy, supported operations, and helper methods +- [SAP HANA vector embeddings](.docs/hana-vector-embeddings.md) — native models and SAP AI Core remote sources +- [Local vector embeddings](.docs/vector-embeddings.md) — SQLite kinds, model provisioning, SQL function behavior, and trust boundaries +- [Choosing a model](.docs/model-selection.md) — Hugging Face filters, compatibility requirements, and size tradeoffs +- [Local knowledge graph](.docs/knowledge-graph.md) — experimental `SPARQL_EXECUTE` and `sparql_table` support ## Test the plugin locally -In `tests/bookshop-app/` you can find a sample application that is used to demonstrate how to use the plugin and to run tests against it. - -### Local Testing - -To execute local tests, simply run: +The sample application is in `tests/bookshop`. -```bash -npm run test +```sh +npm test ``` -For tests, the `cds-test` Plugin is used to spin up the application. More information about `cds-test` can be found [here](https://cap.cloud.sap/docs/node.js/cds-test). - -For integration tests you need an AI Core binding. +Integration tests require an SAP AI Core binding: -```bash +```sh cds bind ai-core -2 npm run test:hybrid ``` -## Support, Feedback, Contributing +## Support, feedback, and contributing -This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/ai/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). +This project welcomes feature requests, bug reports, and contributions through [GitHub issues](https://github.com/cap-js/ai/issues). See the [Contribution Guidelines](CONTRIBUTING.md) for development information. -## Security / Disclosure +## Security -If you find any bug that may be a security problem, please follow our instructions [in our security policy](https://github.com/cap-js/ai/security/policy) on how to report it. Please do not create GitHub issues for security-related doubts or problems. +Report potential security issues through the project's [security policy](https://github.com/cap-js/ai/security/policy), not through public issues. ## Code of Conduct -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](https://github.com/cap-js/.github/blob/main/CODE_OF_CONDUCT.md) at all times. +Participation in this project is governed by the [Code of Conduct](https://github.com/cap-js/.github/blob/main/CODE_OF_CONDUCT.md). ## Licensing -Copyright 2026 SAP SE or an SAP affiliate company and ai contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/ai). +Copyright 2026 SAP SE or an SAP affiliate company and ai contributors. See [LICENSE](LICENSE) and the [REUSE report](https://api.reuse.software/info/github.com/cap-js/ai). diff --git a/embeddings.md b/embeddings.md index 41c0b7b..f3110e9 100644 --- a/embeddings.md +++ b/embeddings.md @@ -1,66 +1,3 @@ -## Simplified embeddings +# SAP HANA vector embeddings -For natural language processing it is crucial to embed text data into a Vector. HANA Cloud offers a `VECTOR_EMBEDDING` function via which an embedding can be generated. The model which can be specified can either be an SAP model, when [HANA Cloud NLP](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/creating-text-embeddings-with-nlp-51eb170d038d4099a9bbb85c08fda888?locale=en-US) is enabled or a [model provided in SAP AI Core](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/creating-text-embeddings-with-sap-ai-core?locale=en-US), like the ones from OpenAI or AWS. - -You can add embeddings columns like: - -```cds -entity Books { - key ID : Integer; - title : String(111); - descr : String(1111); - @cds.api.ignore - embedding : Vector = (VECTOR_EMBEDDING(descr, 'DOCUMENT', 'amazon--titan-embed-text."1.2"')) stored; -} -``` - -HANA Cloud has native models for text embedding when their Natural Language Processing feature is enabled: `SAP_GXY.20250407` and `SAP_NEB.20240715`. However HANA Cloud can also be connected to AI Core via a remote source, and then embedding models from OpenAI and AWS can be used as well. The remote source defaults to 'AI_CORE' and can be customized via `cds.env.ai.embeddings.remoteSource`. - -> [!INFO] -> The fourth parameter is the remote source in HANA Cloud which is mandatory for models provided by SAP AI Core. The plugin will automatically fill it with the default remote source `cds.env.ai.embeddings.remoteSource` if the parameter is not provided. - -### Using non SAP models for embeddings with SAP HANA Cloud - -Currently the setup is not ideal when models provided by SAP AI Core shall be used. You have to complete the following steps to get it to work: - -1. Follow the [Creating Text Embeddings with SAP AI Core](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/creating-text-embeddings-with-sap-ai-core?locale=en-US) documentation in SAP Help. -2. After creating the PSE and the remote source in HANA Cloud, you need to grant the privileges for referencing the remote source to a user, which in turn can grant it to the HDI user for your CAP application. The following SQL creates a user group which can send requests to the remote source, creates a user and grants the user permissions to grant other users permissions to send requests to the remote source. - - ```sql - CREATE ROLEGROUP HDI_GRANTOR_GROUP; - - CREATE ROLE HC_REMOTESOURCE_GRANTOR SET ROLEGROUP HDI_GRANTOR_GROUP; - - GRANT EXECUTE ON REMOTE SOURCE TO HC_REMOTESOURCE_GRANTOR WITH GRANT OPTION; - - -- Choose a unique password - ALTER USER HDI_GRANT_USER PASSWORD NO FORCE_FIRST_PASSWORD_CHANGE; - GRANT HC_REMOTESOURCE_GRANTOR TO HDI_GRANT_USER WITH GRANT OPTION; - ``` - -> [!NOTE] -> If all HDI containers are allowed to access this remote source, you can run `GRANT EXECUTE ON REMOTE SOURCE TO _SYS_DI#BROKER_CG._SYS_DI_OO_DEFAULTS` instead of doing steps 2-4, because this grants the remote execute privileges to the role which is granted to all HDI containers. - -3. Create a user provided service on BTP with the credentials for the user: - - ```ssh - cf cups hana_ai -p '{"username":"HDI_GRANT_USER","password":"", "tags": ["hana"]}' - ``` - -4. Create an `.hdbgrants` file in `db/src`. HDI will pick this up during deployment and use the permissions of the user to grant its permissions to the HDI users. - - ```json - { - "hana_ai": { - "object_owner": { - "roles": ["HC_REMOTESOURCE_GRANTOR"] - }, - "application_user": { - "roles": ["HC_REMOTESOURCE_GRANTOR"] - } - } - } - ``` - -> [!WARNING] -> In Multi-Tenancy scenarios you would have to create a remote source per tenant and assign the reference privilege to the respective tenant binding. The remote source per tenant should be done because in AI Core each tenant should have a different resource group for isolation. +This documentation moved to [`.docs/hana-vector-embeddings.md`](.docs/hana-vector-embeddings.md). diff --git a/package.json b/package.json index 3ebaa4e..cfe1fd6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "format:check": "npx -y prettier@3 --check . && format-cds --check" }, "files": [ + ".docs", "CHANGELOG.md", "bin", "lib", diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json index 646bd66..bcd94fa 100644 --- a/tests/bookshop/package.json +++ b/tests/bookshop/package.json @@ -22,7 +22,10 @@ }, "devDependencies": { "@cap-js/cds-types": "^0.16.0", - "@cap-js/sqlite": ">=2" + "@cap-js/sqlite": ">=2", + "@huggingface/hub": "^2.15.0", + "@huggingface/tokenizers": "0.1.3", + "onnxruntime-node": "1.20.1" }, "engines": { "node": "^22.11.0" @@ -43,6 +46,24 @@ "version": "https://sapui5nightly.int.sap.eu2.hana.ondemand.com" }, "requires": { + "[development]": { + "db": { + "kind": "ai-sqlite:memory", + "embedding": { + "model": "sentence-transformers/all-MiniLM-L6-v2", + "directory": "../../.cds/models" + } + } + }, + "[test]": { + "db": { + "kind": "ai-sqlite:memory", + "embedding": { + "model": "sentence-transformers/all-MiniLM-L6-v2", + "directory": "../../.cds/models" + } + } + }, "[production]": { "auth": "xsuaa", "db": { @@ -55,7 +76,10 @@ } }, "[hybrid]": { - "db": "hana" + "db": { + "kind": "hana", + "embedding": null + } }, "[with-mtx]": { "multitenancy": true diff --git a/tests/bookshop/srv/cat-service.cds b/tests/bookshop/srv/cat-service.cds index e7a86e5..cd4ffa3 100644 --- a/tests/bookshop/srv/cat-service.cds +++ b/tests/bookshop/srv/cat-service.cds @@ -87,4 +87,6 @@ service CatalogService { @requires: 'authenticated-user' action callProcedure(); + + function embedding(text : String) returns LargeString; } diff --git a/tests/bookshop/srv/cat-service.js b/tests/bookshop/srv/cat-service.js index 893d5f6..ef69abb 100644 --- a/tests/bookshop/srv/cat-service.js +++ b/tests/bookshop/srv/cat-service.js @@ -20,6 +20,14 @@ export default class CatalogService extends cds.ApplicationService { } else return req.error(409, `${quantity} exceeds stock for book #${book}`); }); + this.on('embedding', async (req) => { + const [row] = await cds.db.run( + `SELECT VECTOR_EMBEDDING(?, 'DOCUMENT', 'local') AS embedding`, + [req.data.text] + ); + return row.embedding; + }); + this.before('UPDATE', Books.drafts, async (req) => { if (req.data.stock < 0) { req.warn({ diff --git a/tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json b/tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json similarity index 53% rename from tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json rename to tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json index 05b36c9..dbf839f 100644 --- a/tests/fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json +++ b/tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json @@ -1,6 +1,6 @@ { - "repository": "Xenova/all-MiniLM-L6-v2", - "revision": "751bff37182d3f1213fa05d7196b954e230abad9", + "repository": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "1110a243fdf4706b3f48f1d95db1a4f5529b4d41", "dimensions": 384, "maxLength": 128, "files": [ @@ -8,29 +8,29 @@ "role": "model", "name": "model.onnx", "path": "onnx/model.onnx", - "size": 90387606, - "sha256": "759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e" + "size": 90405214, + "sha256": "6fd5d72fe4589f189f8ebc006442dbb529bb7ce38f8082112682524616046452" }, { "role": "tokenizer", "name": "tokenizer.json", "path": "tokenizer.json", - "size": 711661, - "sha256": "da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0" + "size": 466247, + "sha256": "be50c3628f2bf5bb5e3a7f17b1f74611b2561a3a27eeab05e5aa30f411572037" }, { "role": "tokenizerConfig", "name": "tokenizer_config.json", "path": "tokenizer_config.json", - "size": 366, - "sha256": "9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3" + "size": 350, + "sha256": "acb92769e8195aabd29b7b2137a9e6d6e25c476a4f15aa4355c233426c61576b" }, { "role": "auxiliary", "name": "config.json", "path": "config.json", - "size": 650, - "sha256": "7135149f7cffa1a573466c6e4d8423ed73b62fd2332c575bf738a0d033f70df7" + "size": 612, + "sha256": "953f9c0d463486b10a6871cc2fd59f223b2c70184f49815e7efbcab5d8908b41" } ], "output": { diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index b6fc076..96ba4dd 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -14,7 +14,7 @@ describe('ai-sqlite knowledge graph', () => { before(async () => { db = await cds.connect.to('knowledge-graph-db', { kind: 'ai-sqlite:memory', - embedding: { model: 'Xenova/all-MiniLM-L6-v2' } + embedding: { model: 'sentence-transformers/all-MiniLM-L6-v2' } }); }); diff --git a/tests/provision-model.js b/tests/provision-model.js index cf547a8..1c416d2 100644 --- a/tests/provision-model.js +++ b/tests/provision-model.js @@ -7,7 +7,10 @@ import { provisionModel } from '../lib/vector_embedding/model-utils.js'; -const lockUrl = new URL('./fixtures/Xenova/all-MiniLM-L6-v2/embedding.lock.json', import.meta.url); +const lockUrl = new URL( + './fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json', + import.meta.url +); const { formatVersion, ...model } = JSON.parse(await fs.readFile(lockUrl, 'utf8')); if (formatVersion !== 1) throw new Error(`Unsupported test model lock version ${formatVersion}`); diff --git a/tests/recommendations.test.js b/tests/recommendations.test.js index be142ed..c7822fb 100644 --- a/tests/recommendations.test.js +++ b/tests/recommendations.test.js @@ -233,3 +233,17 @@ describe('Row-level authorization', () => { ); }); }); + +describe('Local vector embeddings', () => { + test('Bookshop exposes an embedding preview', async () => { + const { status, data } = await GET( + "/odata/v4/catalog/embedding(text='A%20book%20about%20travel')" + ); + const embedding = JSON.parse(data.value); + + assert.strictEqual(status, 200); + assert.ok(Array.isArray(embedding)); + assert.ok(embedding.length > 0); + assert.ok(embedding.every((value) => typeof value === 'number')); + }); +}); diff --git a/tests/vector.test.js b/tests/vector.test.js index b106c92..7e5fb73 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert'; import cds from '@sap/cds'; import { createEmbeddingRuntime } from '../lib/vector_embedding/embedding.js'; -const MINILM_MODEL = 'Xenova/all-MiniLM-L6-v2'; +const MINILM_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'; let runtime; From 67c5c8a4552c67c6224c154eb6bbf758078b3f45 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Sun, 30 Aug 2026 19:18:53 +0200 Subject: [PATCH 25/37] style: format Bookshop service --- tests/bookshop/srv/cat-service.cds | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bookshop/srv/cat-service.cds b/tests/bookshop/srv/cat-service.cds index cd4ffa3..00b6daf 100644 --- a/tests/bookshop/srv/cat-service.cds +++ b/tests/bookshop/srv/cat-service.cds @@ -75,7 +75,7 @@ service CatalogService { }; @requires: 'authenticated-user' - action submitOrder(book: Books:ID, quantity: Integer) returns { + action submitOrder(book: Books:ID, quantity: Integer) returns { stock : Integer }; @@ -86,7 +86,7 @@ service CatalogService { }; @requires: 'authenticated-user' - action callProcedure(); + action callProcedure(); - function embedding(text : String) returns LargeString; + function embedding(text: String) returns LargeString; } From bef17b525558b98d024610212426b24a3be6541a Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Sun, 30 Aug 2026 19:27:31 +0200 Subject: [PATCH 26/37] test: skip local embedding sample on HANA --- tests/recommendations.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/recommendations.test.js b/tests/recommendations.test.js index c7822fb..dfc40c4 100644 --- a/tests/recommendations.test.js +++ b/tests/recommendations.test.js @@ -235,7 +235,12 @@ describe('Row-level authorization', () => { }); describe('Local vector embeddings', () => { - test('Bookshop exposes an embedding preview', async () => { + test('Bookshop exposes an embedding preview', async (t) => { + if (!cds.env.requires.db.embedding?.model) { + t.skip('local SQLite embedding sample'); + return; + } + const { status, data } = await GET( "/odata/v4/catalog/embedding(text='A%20book%20about%20travel')" ); From a1983e58831cfeaeeaa8ccbaf53ebe49cfc4d0d3 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Sun, 30 Aug 2026 19:29:29 +0200 Subject: [PATCH 27/37] docs: keep embedding sample local --- README.md | 2 +- tests/bookshop/srv/cat-service.js | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0f4985e..2e5173b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Here is a complete Bookshop example. } ``` -3. Add an embedding preview to the Bookshop service: +3. Add an embedding preview to the Bookshop service. In its service implementation, which imports `cds` from `@sap/cds`: ```cds function embedding(text : String) returns LargeString; diff --git a/tests/bookshop/srv/cat-service.js b/tests/bookshop/srv/cat-service.js index ef69abb..a0909ff 100644 --- a/tests/bookshop/srv/cat-service.js +++ b/tests/bookshop/srv/cat-service.js @@ -20,13 +20,15 @@ export default class CatalogService extends cds.ApplicationService { } else return req.error(409, `${quantity} exceeds stock for book #${book}`); }); - this.on('embedding', async (req) => { - const [row] = await cds.db.run( - `SELECT VECTOR_EMBEDDING(?, 'DOCUMENT', 'local') AS embedding`, - [req.data.text] - ); - return row.embedding; - }); + if (cds.env.requires.db.embedding?.model) { + this.on('embedding', async (req) => { + const [row] = await cds.db.run( + `SELECT VECTOR_EMBEDDING(?, 'DOCUMENT', 'local') AS embedding`, + [req.data.text] + ); + return row.embedding; + }); + } this.before('UPDATE', Books.drafts, async (req) => { if (req.data.stock < 0) { From 38644da5d20914c2fd3cc14a08640005acb202c0 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Mon, 31 Aug 2026 10:10:52 +0200 Subject: [PATCH 28/37] docs: clarify native HANA vector support --- .docs/hana-vector-embeddings.md | 63 --------------------------------- .docs/vector-embeddings.md | 2 ++ README.md | 12 ------- embeddings.md | 4 +-- 4 files changed, 4 insertions(+), 77 deletions(-) delete mode 100644 .docs/hana-vector-embeddings.md diff --git a/.docs/hana-vector-embeddings.md b/.docs/hana-vector-embeddings.md deleted file mode 100644 index 0009940..0000000 --- a/.docs/hana-vector-embeddings.md +++ /dev/null @@ -1,63 +0,0 @@ -# SAP HANA vector embeddings - -SAP HANA Cloud provides `VECTOR_EMBEDDING` for generating embeddings with native models or models exposed through an SAP AI Core remote source. - -## Native models - -Add a calculated vector element to a CDS entity: - -```cds -entity Books { - key ID : Integer; - title : String(111); - descr : String(1111); - - @cds.api.ignore - embedding : Vector = (VECTOR_EMBEDDING(descr, 'DOCUMENT', 'SAP_GXY.20250407')) stored; -} -``` - -See the SAP HANA Cloud documentation for the [available models and their characteristics](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). - -## Models through SAP AI Core - -SAP HANA Cloud can also call embedding models exposed through an SAP AI Core remote source. Follow the [SAP HANA Cloud setup guide](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/creating-text-embeddings-with-sap-ai-core?locale=en-US) first. - -The fourth `VECTOR_EMBEDDING` parameter names the remote source. When it is omitted for an SAP AI Core model, the plugin uses `cds.env.ai.embeddings.remoteSource`, whose default is `AI_CORE`. - -The HDI container needs permission to reference the remote source. One setup is to create a grantor role and user: - -```sql -CREATE ROLEGROUP HDI_GRANTOR_GROUP; -CREATE ROLE HC_REMOTESOURCE_GRANTOR SET ROLEGROUP HDI_GRANTOR_GROUP; -GRANT EXECUTE ON REMOTE SOURCE - TO HC_REMOTESOURCE_GRANTOR WITH GRANT OPTION; - -ALTER USER HDI_GRANT_USER PASSWORD NO FORCE_FIRST_PASSWORD_CHANGE; -GRANT HC_REMOTESOURCE_GRANTOR TO HDI_GRANT_USER WITH GRANT OPTION; -``` - -Create a user-provided service containing those credentials: - -```sh -cf cups hana_ai -p '{"username":"HDI_GRANT_USER","password":"","tags":["hana"]}' -``` - -Then add an `.hdbgrants` file under `db/src`: - -```json -{ - "hana_ai": { - "object_owner": { - "roles": ["HC_REMOTESOURCE_GRANTOR"] - }, - "application_user": { - "roles": ["HC_REMOTESOURCE_GRANTOR"] - } - } -} -``` - -If every HDI container may access the remote source, SAP HANA Cloud also supports granting the permission to the shared HDI deployment infrastructure role. Review the linked setup guide before choosing that broader grant. - -In multitenant applications, create a remote source per tenant and grant access to the corresponding tenant binding so SAP AI Core resource groups remain isolated. diff --git a/.docs/vector-embeddings.md b/.docs/vector-embeddings.md index c068560..9e9a75c 100644 --- a/.docs/vector-embeddings.md +++ b/.docs/vector-embeddings.md @@ -3,6 +3,8 @@ > [!WARNING] > Local vector embeddings, the AI-enabled SQLite kinds, local model management, and their CLI tooling are experimental and intended to improve local development. Breaking changes are expected. For production vector search and embeddings, use SAP HANA's vector engine. +`@cap-js/ai` does not add vector functionality to SAP HANA Cloud. HANA already provides vector storage, [`VECTOR_EMBEDDING`](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector), and vector search natively. The SQLite implementation provides a similar development-time SQL shape, but it does not reproduce a HANA model or make locally generated vectors interchangeable with HANA-generated vectors. + ## Database kinds and dependencies - `ai-sqlite` uses a file-based SQLite database. diff --git a/README.md b/README.md index 2e5173b..8c345f1 100644 --- a/README.md +++ b/README.md @@ -47,17 +47,6 @@ await aiCore.stop(deployments, { id: '' }); See [SAP AI Core integration](.docs/ai-core.md) for setup, supported queries, helper methods, and multitenancy. -## SAP HANA vector embeddings - -On SAP HANA Cloud, use `VECTOR_EMBEDDING` in calculated vector elements. The plugin also supports models exposed through an SAP AI Core remote source: - -```cds -@cds.api.ignore -embedding : Vector = (VECTOR_EMBEDDING(descr, 'DOCUMENT', 'SAP_GXY.20250407')) stored; -``` - -See [SAP HANA vector embeddings](.docs/hana-vector-embeddings.md) for native models, remote sources, and HDI privileges. - ## Local vector embeddings with SQLite (experimental) > [!WARNING] @@ -129,7 +118,6 @@ See [Choosing a model](.docs/model-selection.md) and [Local vector embeddings](. - [Recommendations](.docs/recommendations.md) — generated service shape, prediction context, regression targets, and lifecycle - [SAP AI Core integration](.docs/ai-core.md) — bindings, multitenancy, supported operations, and helper methods -- [SAP HANA vector embeddings](.docs/hana-vector-embeddings.md) — native models and SAP AI Core remote sources - [Local vector embeddings](.docs/vector-embeddings.md) — SQLite kinds, model provisioning, SQL function behavior, and trust boundaries - [Choosing a model](.docs/model-selection.md) — Hugging Face filters, compatibility requirements, and size tradeoffs - [Local knowledge graph](.docs/knowledge-graph.md) — experimental `SPARQL_EXECUTE` and `sparql_table` support diff --git a/embeddings.md b/embeddings.md index f3110e9..02cb710 100644 --- a/embeddings.md +++ b/embeddings.md @@ -1,3 +1,3 @@ -# SAP HANA vector embeddings +# Vector embeddings -This documentation moved to [`.docs/hana-vector-embeddings.md`](.docs/hana-vector-embeddings.md). +Documentation for the experimental local SQLite implementation moved to [`.docs/vector-embeddings.md`](.docs/vector-embeddings.md). SAP HANA Cloud provides its vector functionality natively. From 2f240b200af25528fa4535acb73524b39a5ce91a Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 31 Aug 2026 22:40:17 +0200 Subject: [PATCH 29/37] chore: integrate review feedback (#63) * docs: clarify goal * fix: adopt less restrictive modules filtering * fix: make model discovery catch & use `prompts` * feat: enable asymmetric models * chore: lint & format * chore: remove redundant test * chore: add explanatory comment * fix: stop re-using 'bindings' name * feat: treat empty inputs like null inputs * chore: remove redundant docs file * docs: add brief explanation * docs: explain query document prompt configuration * chore: lint * chore: apply hyperspace bot feedback Co-authored-by: hyperspace-pr-bot[bot] <209611008+hyperspace-pr-bot[bot]@users.noreply.github.com> * chore: apply review suggestions Co-authored-by: hyperspace-pr-bot[bot] <209611008+hyperspace-pr-bot[bot]@users.noreply.github.com> * chore: add explanatory comment --------- Co-authored-by: hyperspace-pr-bot[bot] <209611008+hyperspace-pr-bot[bot]@users.noreply.github.com> --- .docs/ai-core.md | 3 + .docs/model-selection.md | 2 +- .docs/vector-embeddings.md | 6 +- embeddings.md | 3 - lib/vector_embedding/embedding.js | 58 +++++++++-- lib/vector_embedding/huggingface-hub.js | 22 ++-- lib/vector_embedding/model-discovery.js | 127 +++++++++++++++++++++--- lib/vector_embedding/model-install.js | 10 +- lib/vector_embedding/model-utils.js | 30 +++++- tests/huggingface-hub.test.js | 60 +++++------ tests/model-discovery.test.js | 71 ++++++++++++- tests/vector.test.js | 60 ++++++++++- 12 files changed, 381 insertions(+), 71 deletions(-) delete mode 100644 embeddings.md diff --git a/.docs/ai-core.md b/.docs/ai-core.md index 9bcf939..b4e9ed4 100644 --- a/.docs/ai-core.md +++ b/.docs/ai-core.md @@ -22,6 +22,9 @@ resources: type: org.cloudfoundry.managed-service ``` +A resource group is SAP AI Core's isolation boundary: it scopes deployments, configurations, and executions so that tenants cannot access each other's resources. +The plugin provisions one resource group per tenant in multitenant applications, and uses a single resource group otherwise. + Single-tenant applications use the `default` resource group unless configured otherwise: ```json diff --git a/.docs/model-selection.md b/.docs/model-selection.md index dd5dfe4..31f110f 100644 --- a/.docs/model-selection.md +++ b/.docs/model-selection.md @@ -20,7 +20,7 @@ For local development, start with the smallest model that meets the application' The Bookshop sample uses [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) because it is the most-downloaded candidate in the filtered list and is compact enough for a local sample. This explains the example choice; it is not a recommendation for a particular application or for production. -Production and local models need not be equivalent. For reference, SAP HANA Cloud's [`SAP_GXY.20250407` is based on RoBERTa base](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). A local MiniLM vector has different dimensions and semantics and is not interchangeable with a HANA-generated vector. Evaluate and regenerate embeddings when changing models. +Ideally, production and local should use identical models. For reference, SAP HANA Cloud's [`SAP_GXY.20250407` is based on RoBERTa base](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). A local MiniLM vector has different dimensions and semantics and is not interchangeable with a HANA-generated vector. Evaluate and regenerate embeddings when changing models. ## Check before installing diff --git a/.docs/vector-embeddings.md b/.docs/vector-embeddings.md index 9e9a75c..025c50b 100644 --- a/.docs/vector-embeddings.md +++ b/.docs/vector-embeddings.md @@ -124,7 +124,11 @@ VECTOR_EMBEDDING(text, text_type, model_and_version) VECTOR_EMBEDDING(text, text_type, model_and_version, remote_source) ``` -Only `text` affects local inference today. `text_type`, `model_and_version`, and `remote_source` preserve the SQL shape for development compatibility; `embedding.model` selects the actual local model. SQL `NULL` remains `NULL`, while empty text returns a zero vector. The result is a JSON string containing the model's vector dimensions. +`text` is embedded, and `text_type` selects the model's query or document prompt when it has one, discovered from the model, or set through `embedding.prompts.{query,document}` for models whose prompts are not discoverable. +Models where no `prompts`-metadata is available will ignore `text_type` unless `embedding.prompts` are configured. +`model_and_version` and `remote_source` preserve the SQL shape for development compatibility but do not affect local inference. +SQL `NULL` returns `NULL`, and empty or whitespace-only text is treated as no value and returns a zero vector. +The result is a JSON string containing the model's vector dimensions. ## Runtime behavior diff --git a/embeddings.md b/embeddings.md deleted file mode 100644 index 02cb710..0000000 --- a/embeddings.md +++ /dev/null @@ -1,3 +0,0 @@ -# Vector embeddings - -Documentation for the experimental local SQLite implementation moved to [`.docs/vector-embeddings.md`](.docs/vector-embeddings.md). SAP HANA Cloud provides its vector functionality natively. diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index e8338e6..3157778 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -9,6 +9,16 @@ import { const STANDARD_INPUT_NAMES = new Set(['input_ids', 'attention_mask', 'token_type_ids']); +// Map HANA's text_type argument onto the model's discovered Sentence Transformers prompts. +// Prompt-less models (no `model.prompts`) always resolve to '' and stay byte-identical. +function promptFor(prompts, textType) { + if (!prompts) return ''; + const key = typeof textType === 'string' ? textType.toLowerCase() : ''; + if (key === 'query') return prompts.query ?? ''; + if (key === 'document') return prompts.document ?? ''; + return ''; +} + async function createEmbeddingRuntime(configuration, options = {}) { const { model, modelDir } = await resolveEmbeddingModel(configuration, options); return createEmbeddingRuntimeFromModel(modelDir, model); @@ -34,9 +44,13 @@ async function createEmbeddingRuntimeFromModel(modelDir, model) { const input = tokenizeToWindow(String(text), tokenizer, tokenizerState); return processEmbedding(input, session, model); }, - vectorEmbedding(text) { - if (!text) return JSON.stringify(new Array(model.dimensions).fill(0)); - return JSON.stringify(Array.from(this.embedding(text))); + vectorEmbedding(text, textType) { + // Treat empty and whitespace-only input as "no value": embedding blank text wastes an + // inference and yields a semantically meaningless vector, so return the zero vector. + if (!text || !String(text).trim()) + return JSON.stringify(new Array(model.dimensions).fill(0)); + const prefix = promptFor(model.prompts, textType); + return JSON.stringify(Array.from(this.embedding(prefix ? `${prefix}${text}` : text))); }, dispose }; @@ -67,7 +81,7 @@ async function resolveEmbeddingModel(configuration, options = {}) { discover, validate } = options; - const { model: modelName, directory } = normalizeEmbeddingConfiguration(configuration); + const { model: modelName, directory, prompts } = normalizeEmbeddingConfiguration(configuration); const modelRoot = getModelRoot(directory, root, options.home); const modelDir = getModelDirectory(modelRoot, modelName); const installOptions = { @@ -79,7 +93,8 @@ async function resolveEmbeddingModel(configuration, options = {}) { discover, validate: validate ?? validateEmbeddingModel, timeoutMs: options.provisionTimeoutMs, - retryMs: options.provisionRetryMs + retryMs: options.provisionRetryMs, + prompts }; let model; @@ -112,7 +127,8 @@ async function resolveEmbeddingModel(configuration, options = {}) { } return installModelOnDemand(modelName, modelDir, installOptions, warn); } - return { model, modelDir }; + + return { model: prompts ? { ...model, prompts } : model, modelDir }; } async function installModelOnDemand(modelName, modelDir, options, warn) { @@ -145,7 +161,35 @@ function normalizeEmbeddingConfiguration(configuration) { ) { throw new Error('embedding.directory must be a non-empty string'); } - return { model: configuration.model, directory: configuration.directory }; + + return { + model: configuration.model, + directory: configuration.directory, + prompts: promptsFromConfig(configuration.prompts) + }; +} + +// Turn the user-facing `embedding.prompts.{query,document}` config into a runtime prompts +// object. When set, it takes precedence over the discovered prompts: the returned prompts +// override whatever the lock carries, letting users supply prefixes for models whose prompts +// are not discoverable (e.g. nomic's README-only `search_query: ` / `search_document: `). +// Returns undefined when no prompts are configured, leaving discovered prompts in place. +function promptsFromConfig(configured) { + if (configured === undefined) return undefined; + if (typeof configured !== 'object' || configured === null || Array.isArray(configured)) { + throw new TypeError('embedding.prompts must be an object with query and/or document strings'); + } + + const prompts = {}; + for (const key of ['query', 'document']) { + if (configured[key] === undefined) continue; + if (typeof configured[key] !== 'string' || !configured[key]) { + throw new TypeError(`embedding.prompts.${key} must be a non-empty string`); + } + prompts[key] = configured[key]; + } + + return Object.keys(prompts).length > 0 ? prompts : undefined; } function modelInstallHint(repository, directory) { diff --git a/lib/vector_embedding/huggingface-hub.js b/lib/vector_embedding/huggingface-hub.js index 005c885..2759f19 100644 --- a/lib/vector_embedding/huggingface-hub.js +++ b/lib/vector_embedding/huggingface-hub.js @@ -19,7 +19,7 @@ const MODEL_INFO_FIELDS = [ ]; function createHuggingFaceClient(options = {}) { - const { fetchImpl = globalThis.fetch, bindings } = options; + const { fetchImpl = globalThis.fetch, hubApi } = options; const hubUrl = options.hubUrl === undefined ? undefined : normalizeHubUrl(options.hubUrl); const requestOptions = { timeoutMs: options.requestTimeoutMs ?? HUB_REQUEST_TIMEOUT_MS, @@ -30,11 +30,11 @@ function createHuggingFaceClient(options = {}) { 'maxResponseBytes' ) }; - const loadedBindings = resolveBindings(bindings); + const loadedHubApi = resolveHubApi(hubApi); return { async getModelInfo(repository) { - const { modelInfo } = await loadedBindings; + const { modelInfo } = await loadedHubApi; return runHubOperation( `reading model metadata for '${repository}'`, (fetch) => @@ -50,7 +50,7 @@ function createHuggingFaceClient(options = {}) { }, async getFiles(repository, revision) { - const { listFiles } = await loadedBindings; + const { listFiles } = await loadedHubApi; return runHubOperation( `listing files for '${repository}'`, async (fetch) => { @@ -72,7 +72,7 @@ function createHuggingFaceClient(options = {}) { }, async getFile(repository, revision, remotePath) { - const { downloadFile } = await loadedBindings; + const { downloadFile } = await loadedHubApi; return runHubOperation( `downloading '${repository}/${remotePath}'`, async (fetch) => { @@ -257,15 +257,15 @@ class HubResponseTooLargeError extends Error { } } -async function resolveBindings(bindings) { +async function resolveHubApi(hubApi) { if ( - typeof bindings?.modelInfo === 'function' && - typeof bindings?.listFiles === 'function' && - typeof bindings?.downloadFile === 'function' + typeof hubApi?.modelInfo === 'function' && + typeof hubApi?.listFiles === 'function' && + typeof hubApi?.downloadFile === 'function' ) { - return bindings; + return hubApi; } - return { ...(await loadHuggingFaceHub()), ...bindings }; + return { ...(await loadHuggingFaceHub()), ...hubApi }; } async function loadHuggingFaceHub(importModule = (specifier) => import(specifier)) { diff --git a/lib/vector_embedding/model-discovery.js b/lib/vector_embedding/model-discovery.js index 798d911..a4f4d9a 100644 --- a/lib/vector_embedding/model-discovery.js +++ b/lib/vector_embedding/model-discovery.js @@ -6,13 +6,23 @@ import { assertSafeRepository, validateModelDescriptor } from './model-utils.js' const MODULES_FILE = 'modules.json'; const SENTENCE_CONFIG_FILE = 'sentence_bert_config.json'; +const CONFIG_ST_FILE = 'config_sentence_transformers.json'; +const ALLOWED_PROMPT_NAMES = new Set(['query', 'document', 'passage']); // TODO: Are these official? const EMBEDDING_TASKS = new Set(['feature-extraction', 'sentence-similarity']); -const SUPPORTED_MODULES = new Set([ +const PIPELINE_MODULES = new Set([ 'sentence_transformers.models.Transformer', 'sentence_transformers.models.Pooling', 'sentence_transformers.models.Normalize' ]); +// Modules that post-process output format only and do not affect the +// Transformer → Pooling → Normalize inference pipeline. +const TRANSPARENT_MODULE_NAMESPACES = [/^sentence_transformers\.quantization\./, /^st_quantize\./]; + +function isTransparentModule(type) { + return TRANSPARENT_MODULE_NAMESPACES.some((pattern) => pattern.test(type)); +} + async function discoverModel(repository, options = {}) { const { candidate, context, filesByPath, knownFiles } = await discoverModelMetadata( repository, @@ -128,7 +138,8 @@ async function discoverModelMetadata(repository, options) { name: 'last_hidden_state', pooling: semantics.pooling, normalize: semantics.normalize - } + }, + ...(semantics.prompts ? { prompts: semantics.prompts } : {}) }); return { context, @@ -275,39 +286,63 @@ async function readSentenceTransformerSemantics(context, repository, revision, f throw new Error(`Invalid Sentence Transformers modules in '${repository}/${MODULES_FILE}'`); } - // modules.json defines the ordered execution pipeline. Accepting a module that this runtime - // does not execute would silently produce embeddings with different semantics from the model. + // modules.json defines the ordered execution pipeline. Skipping a module that changes the + // semantic content of the embedding would silently produce incompatible vectors. Modules in + // TRANSPARENT_MODULES only post-process output format (e.g. quantization) and are safe to + // omit when float32 cosine similarity is the downstream search metric. for (const module of modules) { - if (!module || typeof module.type !== 'string' || !SUPPORTED_MODULES.has(module.type)) { + if (!module || typeof module.type !== 'string') + throw new Error(`Invalid Sentence Transformers module in '${repository}/${MODULES_FILE}'`); + if (!PIPELINE_MODULES.has(module.type) && !isTransparentModule(module.type)) { throw new Error( - `Unsupported Sentence Transformers module '${module?.type ?? 'unknown'}' in '${repository}'` + `Unsupported Sentence Transformers module '${module.type}' in '${repository}'` ); } } + const pipeline = modules.filter((module) => PIPELINE_MODULES.has(module.type)); const expectedTypes = [ 'sentence_transformers.models.Transformer', 'sentence_transformers.models.Pooling' ]; - if (modules.length === 3) expectedTypes.push('sentence_transformers.models.Normalize'); + if (pipeline.length === 3) expectedTypes.push('sentence_transformers.models.Normalize'); if ( - modules.length < 2 || - modules.length > 3 || - modules.some((module, index) => module.type !== expectedTypes[index]) + pipeline.length < 2 || + pipeline.length > 3 || + pipeline.some((module, index) => module.type !== expectedTypes[index]) ) { throw new Error(`Cannot determine an unambiguous pooling pipeline for '${repository}'`); } - const poolingPath = moduleConfigPath(modules[1], repository); + const poolingPath = moduleConfigPath(pipeline[1], repository); if (!filesByPath.has(poolingPath)) { throw new Error(`Sentence Transformers pooling configuration '${poolingPath}' is missing`); } const poolingConfig = (await fetchJsonFile(context, repository, revision, poolingPath)).value; const pooling = determinePooling(poolingConfig, repository); + // Check if the sentence-transformer was trained with registered prompts + // See: https://sbert.net/examples/sentence_transformer/training/prompts/README.html + // A model trained with prompts is likely to be asymmetric: + // > It would break our assumption that query and document can be embedded equally. + let prompts; + if (filesByPath.has(CONFIG_ST_FILE)) { + const stConfig = (await fetchJsonFile(context, repository, revision, CONFIG_ST_FILE)).value; + prompts = interpretPrompts(stConfig, repository); + // Intentionally checked post-normalization: empty-string prompts produce no prefix tokens, + // so include_prompt=false is a no-op for those and should not cause rejection. + if (prompts && poolingConfig.include_prompt === false) { + throw new Error( + `Sentence Transformers model '${repository}' excludes prompt tokens from pooling (include_prompt=false); prefixing cannot be emulated` + ); + } + } + let maxLength; const sentenceConfigPaths = [SENTENCE_CONFIG_FILE]; - if (modules[0]?.path) { - sentenceConfigPaths.unshift(`${normalizedModulePath(modules[0].path)}/${SENTENCE_CONFIG_FILE}`); + if (pipeline[0]?.path) { + sentenceConfigPaths.unshift( + `${normalizedModulePath(pipeline[0].path)}/${SENTENCE_CONFIG_FILE}` + ); } const sentenceConfigPath = sentenceConfigPaths.find((candidate) => filesByPath.has(candidate)); if (sentenceConfigPath) { @@ -315,7 +350,66 @@ async function readSentenceTransformerSemantics(context, repository, revision, f maxLength = positiveInteger(sentenceConfig.value.max_seq_length); } - return { pooling, normalize: modules.length === 3, maxLength }; + return { pooling, normalize: pipeline.length === 3, maxLength, ...(prompts ? { prompts } : {}) }; +} + +// Interpret Sentence Transformers `prompts` fail-closed: map only the known-safe QUERY/DOCUMENT +// shape onto HANA text types. Real models use arbitrary prompt names, so anything we cannot map +// unambiguously throws rather than silently mis-encoding. An absent `prompts` key is not an error. +function interpretPrompts(config, repository) { + if (config == null) return undefined; + if (typeof config !== 'object' || Array.isArray(config)) { + throw new Error( + `Invalid Sentence Transformers configuration in '${repository}/${CONFIG_ST_FILE}'` + ); + } + + const { prompts, default_prompt_name: defaultPromptName } = config; + + if (prompts === undefined || prompts === null) return undefined; + if (typeof prompts !== 'object' || Array.isArray(prompts)) { + throw new Error( + `Unsupported Sentence Transformers prompts in '${repository}': expected an object` + ); + } + + for (const [name, value] of Object.entries(prompts)) { + if (!ALLOWED_PROMPT_NAMES.has(name)) { + throw new Error( + `Unsupported Sentence Transformers prompt '${name}' in '${repository}'; only query, document, and passage map to HANA text types` + ); + } + if (typeof value !== 'string') { + throw new Error(`Sentence Transformers prompt '${name}' in '${repository}' must be a string`); + } + } + + if ( + defaultPromptName !== undefined && + defaultPromptName !== null && + !ALLOWED_PROMPT_NAMES.has(defaultPromptName) + ) { + throw new Error( + `Unsupported Sentence Transformers default_prompt_name '${defaultPromptName}' in '${repository}'` + ); + } + + if ( + prompts.document !== undefined && + prompts.passage !== undefined && + prompts.document !== prompts.passage + ) { + throw new Error( + `Conflicting Sentence Transformers 'document' and 'passage' prompts in '${repository}'` + ); + } + + const document = prompts.document ?? prompts.passage; + const normalized = {}; + if (prompts.query) normalized.query = prompts.query; + if (document) normalized.document = document; + + return Object.keys(normalized).length > 0 ? normalized : undefined; } function moduleConfigPath(module, repository) { @@ -343,6 +437,10 @@ function determinePooling(config, repository) { if (!config || typeof config !== 'object' || Array.isArray(config)) { throw new Error(`Invalid Sentence Transformers pooling configuration for '${repository}'`); } + // We only implement 'mean' and 'cls' pooling, so those are the only values this returns. The + // unsupported modes are still listed here on purpose: they let us detect a config that enables + // an unsupported mode (or several modes at once) and fail closed, rather than silently pooling + // with 'mean'/'cls' while ignoring a conflicting flag. const enabled = [ ['cls', config.pooling_mode_cls_token], ['mean', config.pooling_mode_mean_tokens], @@ -351,6 +449,7 @@ function determinePooling(config, repository) { ['weightedmean', config.pooling_mode_weightedmean_tokens], ['lasttoken', config.pooling_mode_lasttoken] ].filter(([, value]) => value === true); + if (config.pooling_mode !== undefined) { if (!['mean', 'cls'].includes(config.pooling_mode)) { throw new Error(`Unsupported or ambiguous Sentence Transformers pooling for '${repository}'`); diff --git a/lib/vector_embedding/model-install.js b/lib/vector_embedding/model-install.js index 7fee29d..7b6a808 100644 --- a/lib/vector_embedding/model-install.js +++ b/lib/vector_embedding/model-install.js @@ -42,7 +42,15 @@ async function installModel(repository, options = {}) { hubUrl: options.hubUrl, validate: options.validate }); - return { model, modelDir, modelRoot }; + + // A configured prompt override (embedding.prompts) takes precedence over the discovered + // prompts at runtime; the persisted lock keeps the discovered prompts, so we override + // only the returned model here. + return { + model: options.prompts ? { ...model, prompts: options.prompts } : model, + modelDir, + modelRoot + }; } catch (error) { if (error.code !== MODEL_PROVISIONING_IN_PROGRESS) throw error; const remaining = deadline - Date.now(); diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index 9786496..4a493b3 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -81,9 +81,29 @@ function validateModelDescriptor(model) { throw new Error('embedding.output.normalize must be a boolean'); } + if (model.prompts !== undefined) validatePrompts(model.prompts); + return model; } +function validatePrompts(prompts) { + if (!prompts || typeof prompts !== 'object' || Array.isArray(prompts)) { + throw new Error('embedding.prompts must be an object with query and/or document string values'); + } + const keys = Object.keys(prompts); + if (keys.length === 0) { + throw new Error('embedding.prompts must define at least one of query or document'); + } + for (const key of keys) { + if (key !== 'query' && key !== 'document') { + throw new Error(`Unsupported embedding.prompts key '${key}'`); + } + if (typeof prompts[key] !== 'string' || !prompts[key]) { + throw new Error(`embedding.prompts.${key} must be a non-empty string`); + } + } +} + function assertNoPathCollision(left, right, description = 'another embedding file') { const normalizedLeft = left.toLowerCase(); const normalizedRight = right.toLowerCase(); @@ -164,7 +184,15 @@ function modelDescriptorDigest(model) { name: model.output.name, pooling: model.output.pooling, normalize: model.output.normalize - } + }, + ...(model.prompts + ? { + prompts: { + ...(model.prompts.query !== undefined ? { query: model.prompts.query } : {}), + ...(model.prompts.document !== undefined ? { document: model.prompts.document } : {}) + } + } + : {}) }); return createHash('sha256').update(canonical).digest('hex'); } diff --git a/tests/huggingface-hub.test.js b/tests/huggingface-hub.test.js index 9dd934f..010dbf1 100644 --- a/tests/huggingface-hub.test.js +++ b/tests/huggingface-hub.test.js @@ -10,25 +10,11 @@ describe('Hugging Face Hub adapter', () => { test('pins all operations and forwards custom transport options', async () => { const calls = []; const fetchImpl = () => {}; - const bindings = { - async modelInfo(options) { - calls.push(['modelInfo', options]); - return { sha: '1'.repeat(40) }; - }, - async *listFiles(options) { - calls.push(['listFiles', options]); - yield { type: 'directory', path: 'onnx', size: 0 }; - yield { type: 'file', path: 'onnx/model.onnx', size: 42 }; - }, - async downloadFile(options) { - calls.push(['downloadFile', options]); - return new Blob(['contents']); - } - }; + const hubApi = recordingHubApi(calls); const client = createHuggingFaceClient({ fetchImpl, hubUrl: 'https://hub.example.test', - bindings + hubApi }); assert.deepEqual(await client.getModelInfo('foo/bar'), { sha: '1'.repeat(40) }); @@ -51,17 +37,17 @@ describe('Hugging Face Hub adapter', () => { }); test('rejects unsafe Hub URLs', () => { - const bindings = downloadBindings(); + const hubApi = downloadHubApi(); assert.throws( - () => createHuggingFaceClient({ hubUrl: 'http://hub.example.test', bindings }), + () => createHuggingFaceClient({ hubUrl: 'http://hub.example.test', hubApi }), /must use HTTPS/ ); assert.throws( - () => createHuggingFaceClient({ hubUrl: 'https://user@hub.example.test', bindings }), + () => createHuggingFaceClient({ hubUrl: 'https://user@hub.example.test', hubApi }), /must not include credentials/ ); assert.throws( - () => createHuggingFaceClient({ hubUrl: 'https://hub.example.test?mirror=1', bindings }), + () => createHuggingFaceClient({ hubUrl: 'https://hub.example.test?mirror=1', hubApi }), /must not include a query or fragment/ ); }); @@ -145,7 +131,7 @@ describe('Hugging Face Hub adapter', () => { }, requestRetries: 1, requestRetryMs: 0, - bindings: fetchOnlyBindings() + hubApi: fetchOnlyHubApi() }); await client.getModelInfo('foo/bar'); @@ -162,7 +148,7 @@ describe('Hugging Face Hub adapter', () => { }), requestRetries: 0, requestTimeoutMs: 10, - bindings: fetchOnlyBindings() + hubApi: fetchOnlyHubApi() }); await assert.rejects(client.getModelInfo('foo/bar'), /Timed out after 10 ms/); @@ -177,7 +163,7 @@ describe('Hugging Face Hub adapter', () => { }, requestRetries: 2, requestRetryMs: 0, - bindings: fetchOnlyBindings() + hubApi: fetchOnlyHubApi() }); await assert.rejects(client.getModelInfo('foo/bar'), /status 404/); @@ -192,7 +178,7 @@ describe('Hugging Face Hub adapter', () => { }), requestRetries: 0, maxResponseBytes: 8, - bindings: downloadBindings() + hubApi: downloadHubApi() }); await assert.rejects( @@ -210,7 +196,7 @@ describe('Hugging Face Hub adapter', () => { }), requestRetries: 0, maxResponseBytes: 8, - bindings: downloadBindings() + hubApi: downloadHubApi() }); await assert.rejects( @@ -233,7 +219,7 @@ describe('Hugging Face Hub adapter', () => { ), requestRetries: 0, maxResponseBytes: 4, - bindings: downloadBindings() + hubApi: downloadHubApi() }); await assert.rejects( @@ -256,7 +242,25 @@ describe('Hugging Face Hub adapter', () => { }); }); -function fetchOnlyBindings() { +function recordingHubApi(calls) { + return { + async modelInfo(options) { + calls.push(['modelInfo', options]); + return { sha: '1'.repeat(40) }; + }, + async *listFiles(options) { + calls.push(['listFiles', options]); + yield { type: 'directory', path: 'onnx', size: 0 }; + yield { type: 'file', path: 'onnx/model.onnx', size: 42 }; + }, + async downloadFile(options) { + calls.push(['downloadFile', options]); + return new Blob(['contents']); + } + }; +} + +function fetchOnlyHubApi() { return { async modelInfo({ fetch }) { const response = await fetch('https://hub.example.test/model'); @@ -268,7 +272,7 @@ function fetchOnlyBindings() { }; } -function downloadBindings() { +function downloadHubApi() { return { async modelInfo() {}, async *listFiles() {}, diff --git a/tests/model-discovery.test.js b/tests/model-discovery.test.js index d31009a..94429d5 100644 --- a/tests/model-discovery.test.js +++ b/tests/model-discovery.test.js @@ -291,6 +291,65 @@ describe('Hugging Face model discovery', () => { assert.equal(model.size, contents.length); assert.equal(model.sha256, digest(contents)); }); + + test('should map sentence-transformers query and passage prompts onto HANA text types', async () => { + const hub = hubFor({ + stConfig: { prompts: { query: 'query: ', passage: 'passage: ' }, default_prompt_name: null } + }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.deepEqual(descriptor.prompts, { query: 'query: ', document: 'passage: ' }); + }); + + test('should keep a query-only prompt without inventing a document prefix', async () => { + const hub = hubFor({ stConfig: { prompts: { query: 'query: ' } } }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.deepEqual(descriptor.prompts, { query: 'query: ' }); + }); + + test('should treat an empty document/passage prompt as no prefix', async () => { + const hub = hubFor({ stConfig: { prompts: { query: 'query: ', passage: '' } } }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.deepEqual(descriptor.prompts, { query: 'query: ' }); + }); + + test('should omit prompts when the model declares none', async () => { + const hub = hubFor({ stConfig: { max_seq_length: 256 } }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.equal(descriptor.prompts, undefined); + }); + + test('should reject models with prompts that exclude prompt tokens from pooling', async () => { + const hub = hubFor({ stConfig: { prompts: { query: 'query: ' } }, includePrompt: false }); + + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: hub.client }), + /include_prompt=false/ + ); + }); + + test('should reject prompt shapes that cannot be mapped to HANA text types', async () => { + const unknownKey = hubFor({ stConfig: { prompts: { classification: 'Classify: ' } } }); + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: unknownKey.client }), + /Unsupported Sentence Transformers prompt 'classification'/ + ); + + const conflicting = hubFor({ + stConfig: { prompts: { document: 'doc: ', passage: 'passage: ' } } + }); + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: conflicting.client }), + /Conflicting Sentence Transformers 'document' and 'passage' prompts/ + ); + }); }); function hubFor(options = {}) { @@ -336,12 +395,17 @@ function hubFor(options = {}) { type: `sentence_transformers.models.${type}` })) ); - files['1_Pooling/config.json'] = json(poolingConfig(options.pooling ?? 'mean')); + files['1_Pooling/config.json'] = json( + poolingConfig(options.pooling ?? 'mean', options.includePrompt) + ); if (options.sentenceConfig !== undefined) { files['sentence_bert_config.json'] = json(options.sentenceConfig); } else if (!Object.hasOwn(options, 'sentenceConfig')) { files['sentence_bert_config.json'] = json({ max_seq_length: 256 }); } + if (options.stConfig !== undefined) { + files['config_sentence_transformers.json'] = json(options.stConfig); + } } const info = { @@ -413,7 +477,7 @@ function createHub(repositories) { }; } -function poolingConfig(pooling) { +function poolingConfig(pooling, includePrompt) { const enabled = Array.isArray(pooling) ? pooling : [pooling]; return { pooling_mode_cls_token: enabled.includes('cls'), @@ -421,7 +485,8 @@ function poolingConfig(pooling) { pooling_mode_max_tokens: false, pooling_mode_mean_sqrt_len_tokens: false, pooling_mode_weightedmean_tokens: false, - pooling_mode_lasttoken: false + pooling_mode_lasttoken: false, + ...(typeof includePrompt === 'boolean' ? { include_prompt: includePrompt } : {}) }; } diff --git a/tests/vector.test.js b/tests/vector.test.js index 7e5fb73..0f581e4 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -1,7 +1,11 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert'; import cds from '@sap/cds'; -import { createEmbeddingRuntime } from '../lib/vector_embedding/embedding.js'; +import { + createEmbeddingRuntime, + createEmbeddingRuntimeFromModel, + resolveEmbeddingModel +} from '../lib/vector_embedding/embedding.js'; const MINILM_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'; @@ -141,6 +145,60 @@ describe('Vector embedding function (standalone)', () => { }); }); +describe('text-type prompts via configured prompts', () => { + let promptRuntime; + + // MiniLM ships no Sentence-Transformers prompts, so there is nothing to discover. + // These prefixes come from `embedding.prompts.{query,document}`, the user-configured override that + // takes precedence over discovered prompts — the path a prompt-trained model without + // discoverable prompts relies on. + const PROMPTS = { query: 'query: ', document: 'passage: ' }; + + before(async () => { + const { model, modelDir } = await resolveEmbeddingModel({ + model: MINILM_MODEL, + prompts: PROMPTS + }); + promptRuntime = await createEmbeddingRuntimeFromModel(modelDir, model); + }); + + after(async () => { + await promptRuntime?.dispose(); + }); + + test('should prepend the configured prompt for the forwarded text-type', () => { + assert.strictEqual( + promptRuntime.vectorEmbedding('a small cat', 'QUERY'), + runtime.vectorEmbedding('query: a small cat') + ); + }); + + test('should prepend the configured document prefix for the DOCUMENT text type', () => { + assert.strictEqual( + promptRuntime.vectorEmbedding('a small cat', 'DOCUMENT'), + runtime.vectorEmbedding('passage: a small cat') + ); + }); + + test('should ignore the text type when no prefix is configured', () => { + assert.strictEqual( + runtime.vectorEmbedding('a small cat', 'QUERY'), + runtime.vectorEmbedding('a small cat', 'DOCUMENT') + ); + assert.strictEqual( + runtime.vectorEmbedding('a small cat', 'QUERY'), + runtime.vectorEmbedding('a small cat') + ); + }); + + test('should reject a non-string configured prompt before touching the model', async () => { + await assert.rejects( + resolveEmbeddingModel({ model: MINILM_MODEL, prompts: { query: 42 } }), + /embedding\.prompts\.query must be a string/ + ); + }); +}); + describe('ai-sqlite integration', () => { let db; From b9c0adf3b2f639ab82b10115943a36caa319b752 Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:21:33 +0200 Subject: [PATCH 30/37] Update tests/vector.test.js Co-authored-by: Paul --- tests/vector.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/vector.test.js b/tests/vector.test.js index 0f581e4..55349ea 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -194,7 +194,7 @@ describe('text-type prompts via configured prompts', () => { test('should reject a non-string configured prompt before touching the model', async () => { await assert.rejects( resolveEmbeddingModel({ model: MINILM_MODEL, prompts: { query: 42 } }), - /embedding\.prompts\.query must be a string/ + /embedding\.prompts\.query must be a non-empty string/ ); }); }); From ab78c37d4a4b6fefcd50436ad4c72de93a0155b5 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 1 Sep 2026 00:54:01 +0200 Subject: [PATCH 31/37] feat: extend standard sqlite kinds --- .docs/knowledge-graph.md | 4 +- .docs/model-selection.md | 2 +- .docs/vector-embeddings.md | 45 ++++++++++++++++------- CHANGELOG.md | 4 +- README.md | 17 ++++----- lib/sqlite/load-sqlite.js | 2 +- lib/vector_embedding/embedding.js | 12 +++--- lib/vector_embedding/load-onnx-runtime.js | 2 +- lib/vector_embedding/model-utils.js | 2 +- package.json | 15 ++++---- tests/bookshop/package.json | 6 +-- tests/knowledge-graph.test.js | 5 +-- tests/model-provisioning.test.js | 45 ++++++++++++++++++++--- tests/vector-unit.test.js | 2 +- tests/vector.test.js | 23 ++++++------ 15 files changed, 118 insertions(+), 68 deletions(-) diff --git a/.docs/knowledge-graph.md b/.docs/knowledge-graph.md index f7bef86..c76adab 100644 --- a/.docs/knowledge-graph.md +++ b/.docs/knowledge-graph.md @@ -9,7 +9,7 @@ Install the optional peer dependency: npm add -D oxigraph ``` -Both `ai-sqlite` and `ai-sqlite:memory` expose a process-local Oxigraph store through `SPARQL_EXECUTE` and `sparql_table`. +With `@cap-js/ai` installed, both `sqlite` and `sqlite:memory` expose a process-local Oxigraph store through `SPARQL_EXECUTE` and `sparql_table`. ## Load RDF @@ -54,4 +54,4 @@ await db.run({ }); ``` -The RDF store lives in memory and is tied to the database service connection. Its contents are lost on disconnect or process restart even with file-based `ai-sqlite`, and RDF updates are not transactionally coupled to SQLite changes. +The RDF store lives in memory and is tied to the database service connection. Its contents are lost on disconnect or process restart even with file-based `sqlite`, and RDF updates are not transactionally coupled to SQLite changes. diff --git a/.docs/model-selection.md b/.docs/model-selection.md index 31f110f..1c7334d 100644 --- a/.docs/model-selection.md +++ b/.docs/model-selection.md @@ -18,7 +18,7 @@ The Hub filters are not a compatibility guarantee. Repositories can contain seve For local development, start with the smallest model that meets the application's language, domain, and retrieval-quality needs. Smaller models download and start faster, use less memory, and block SQLite for less time. Move to a larger model only when measurements on representative data show that the smaller one is insufficient. -The Bookshop sample uses [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) because it is the most-downloaded candidate in the filtered list and is compact enough for a local sample. This explains the example choice; it is not a recommendation for a particular application or for production. +The current default and Bookshop sample use [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2). It was selected only because, at the time of selection, it was the most-downloaded reasonably small candidate matching the sentence-similarity, ONNX, and Apache-2.0 filters. This is not a recommendation for any application or for production, and the default may change at any time while local embeddings remain experimental. Configure the model explicitly when that choice must stay stable. Ideally, production and local should use identical models. For reference, SAP HANA Cloud's [`SAP_GXY.20250407` is based on RoBERTa base](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). A local MiniLM vector has different dimensions and semantics and is not interchangeable with a HANA-generated vector. Evaluate and regenerate embeddings when changing models. diff --git a/.docs/vector-embeddings.md b/.docs/vector-embeddings.md index 025c50b..f93c093 100644 --- a/.docs/vector-embeddings.md +++ b/.docs/vector-embeddings.md @@ -1,19 +1,25 @@ # Local vector embeddings > [!WARNING] -> Local vector embeddings, the AI-enabled SQLite kinds, local model management, and their CLI tooling are experimental and intended to improve local development. Breaking changes are expected. For production vector search and embeddings, use SAP HANA's vector engine. +> Local vector embeddings, the SQLite extensions, local model management, and their CLI tooling are experimental and intended to improve local development. Breaking changes are expected. For production vector search and embeddings, use SAP HANA's vector engine. `@cap-js/ai` does not add vector functionality to SAP HANA Cloud. HANA already provides vector storage, [`VECTOR_EMBEDDING`](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector), and vector search natively. The SQLite implementation provides a similar development-time SQL shape, but it does not reproduce a HANA model or make locally generated vectors interchangeable with HANA-generated vectors. ## Database kinds and dependencies -- `ai-sqlite` uses a file-based SQLite database. -- `ai-sqlite:memory` uses an in-memory SQLite database. +`@cap-js/ai` redirects CAP's standard SQLite implementations instead of adding separate database kinds: + +- `sqlite` uses a file-based SQLite database. +- `sqlite:memory` uses an in-memory SQLite database. + +The redirect and synchronous embedding function require `@sap/cds` `^10.1` and `@cap-js/sqlite` `^3.1`. The package's other capabilities continue to support `@sap/cds` 9. + +This applies to every SQLite service in an application that installs `@cap-js/ai`. Its embedding model is provisioned and initialized when the service starts, even if the application does not call `VECTOR_EMBEDDING`. SAP HANA services are unaffected. Install the optional peers as development dependencies: ```sh -npm add -D @cap-js/sqlite @huggingface/hub@^2.15.0 \ +npm add -D @cap-js/sqlite@^3.1 @huggingface/hub@^2.15.0 \ @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 ``` @@ -21,14 +27,28 @@ npm add -D @cap-js/sqlite @huggingface/hub@^2.15.0 \ ## Configuration -Every AI-enabled SQLite service requires a model; there is no default: +Use the standard SQLite configuration: + +```json +{ + "cds": { + "requires": { + "db": { + "kind": "sqlite" + } + } + } +} +``` + +The current default model is `sentence-transformers/all-MiniLM-L6-v2`. It was selected only because, at the time of selection, it was the most-downloaded reasonably small model matching the sentence-similarity task, ONNX format, and Apache-2.0 license filters used for the sample. This is not a model recommendation. The default may change at any time while the feature is experimental, so configure `embedding.model` explicitly when the model choice must remain stable: ```json { "cds": { "requires": { "db": { - "kind": "ai-sqlite", + "kind": "sqlite", "embedding": { "model": "owner/model" } @@ -38,21 +58,18 @@ Every AI-enabled SQLite service requires a model; there is no default: } ``` -Startup fails if `cds.requires.db.embedding.model` is missing. The built-in runtime reads `model` and the optional `directory`; additional properties are allowed for extensions. +The built-in runtime reads `model` and the optional `directory`; additional properties are allowed for extensions. -Without `directory`, models are stored below `/.cds/models//`. If a valid installation is absent, startup prints a warning, downloads the model, generates `embedding.lock.json`, and reuses it on later starts. +Without `directory`, the configured or default model is stored below `/.cds/models//`. If a valid installation is absent, startup prints a warning, downloads the model, generates `embedding.lock.json`, and reuses it on later starts. -Use `ai-sqlite:memory` when the application data itself need not survive a restart: +Use `sqlite:memory` when the application data itself need not survive a restart: ```json { "cds": { "requires": { "db": { - "kind": "ai-sqlite:memory", - "embedding": { - "model": "owner/model" - } + "kind": "sqlite:memory" } } } @@ -80,7 +97,7 @@ npx @cap-js/ai install-model owner/model --directory ~/.cds/models "cds": { "requires": { "db": { - "kind": "ai-sqlite", + "kind": "sqlite", "embedding": { "model": "owner/model", "directory": "~/.cds/models" diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c1d8d..4ab328e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ ### Added -- **Experimental:** Add `ai-sqlite` and `ai-sqlite:memory` for local CAP development, including `VECTOR_EMBEDDING` with locally managed ONNX models and model provisioning tooling. +- **Experimental:** Extend the standard `sqlite` and `sqlite:memory` services for local CAP development with `VECTOR_EMBEDDING`, model provisioning tooling, and `sentence-transformers/all-MiniLM-L6-v2` as a replaceable default that may change while the feature remains experimental. - **Experimental:** Add local `SPARQL_EXECUTE` and `sparql_table` support through the optional `oxigraph` peer dependency. +Local vector embeddings require `@sap/cds` `^10.1` and `@cap-js/sqlite` `^3.1`; the package's other capabilities continue to support `@sap/cds` 9. + ## Version 1.1.0 - 2026-07-20 ### Added diff --git a/README.md b/README.md index 8c345f1..4c3811d 100644 --- a/README.md +++ b/README.md @@ -50,28 +50,27 @@ See [SAP AI Core integration](.docs/ai-core.md) for setup, supported queries, he ## Local vector embeddings with SQLite (experimental) > [!WARNING] -> `ai-sqlite`, local vector embeddings, local model management, and the related tooling are experimental facilities for local development. Breaking changes are expected, including changes caused by SQLite's synchronous function interface and by local model management. Use SAP HANA's vector engine for production vector workloads. +> The SQLite extensions, local vector embeddings, local model management, and the related tooling are experimental facilities for local development. Breaking changes are expected, including changes caused by SQLite's synchronous function interface and by local model management. Use SAP HANA's vector engine for production vector workloads. Here is a complete Bookshop example. 1. Install the local-development dependencies: ```sh - npm add -D @cap-js/ai @cap-js/sqlite @huggingface/hub@^2.15.0 \ + npm add -D @cap-js/ai @cap-js/sqlite@^3.1 @huggingface/hub@^2.15.0 \ @huggingface/tokenizers@0.1.3 onnxruntime-node@1.20.1 ``` -2. Configure the database and model in `package.json`: + Local vector embeddings require `@sap/cds` `^10.1` and `@cap-js/sqlite` `^3.1`; the package's other capabilities continue to support `@sap/cds` 9. + +2. Use the standard SQLite service. Most CAP projects already do this in development; an explicit configuration looks like this: ```json { "cds": { "requires": { "db": { - "kind": "ai-sqlite", - "embedding": { - "model": "sentence-transformers/all-MiniLM-L6-v2" - } + "kind": "sqlite" } } } @@ -106,9 +105,9 @@ Here is a complete Bookshop example. curl 'http://localhost:4004/odata/v4/catalog/embedding(text=%27A%20book%20about%20travel%27)' ``` -The first start warns that the model is missing, downloads it to `.cds/models`, and then initializes it. The response's `value` contains a JSON-encoded vector with 384 numbers. Later starts reuse the installed model. +`@cap-js/ai` redirects the standard `sqlite` and `sqlite:memory` implementations to add the local capabilities. The first start warns that the default model is missing, downloads it to `.cds/models`, and then initializes it. The response's `value` contains a JSON-encoded vector with 384 numbers. Later starts reuse the installed model. -This example uses [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) because it is the most-downloaded model in the filtered list below and is small enough for a local-development sample. It is an example, not a model recommendation. +The current default is [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2). It was selected only because, at the time of selection, it was the most-downloaded reasonably small model matching the sentence-similarity task, ONNX format, and Apache-2.0 license filters below. This is not a recommendation, and the default may change at any time while this feature is experimental. Configure `cds.requires.db.embedding.model` explicitly if the choice must remain stable. Start model discovery with [trending Apache-2.0 sentence-similarity models that provide ONNX artifacts](https://huggingface.co/models?pipeline_tag=sentence-similarity&library=onnx&license=license:apache-2.0&sort=trending). These filters select a relevant task, a locally runnable format, and a permissive license, but they do not guarantee compatibility. Choose a model as big as necessary and as small as possible, then validate it with the provided tooling. diff --git a/lib/sqlite/load-sqlite.js b/lib/sqlite/load-sqlite.js index 63df444..d0ed2f5 100644 --- a/lib/sqlite/load-sqlite.js +++ b/lib/sqlite/load-sqlite.js @@ -12,7 +12,7 @@ function loadSQLiteService(requireModule = require) { /['"]@cap-js\/sqlite['"]/.test(error.message) ) { throw new Error( - "Using ai-sqlite requires @cap-js/sqlite. Install it with 'npm add -D @cap-js/sqlite'.", + "Using @cap-js/ai with SQLite requires @cap-js/sqlite. Install it with 'npm add -D @cap-js/sqlite'.", { cause: error } ); } diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 3157778..86ceddd 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -8,6 +8,7 @@ import { } from './model-utils.js'; const STANDARD_INPUT_NAMES = new Set(['input_ids', 'attention_mask', 'token_type_ids']); +const DEFAULT_EMBEDDING_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'; // Map HANA's text_type argument onto the model's discovered Sentence Transformers prompts. // Prompt-less models (no `model.prompts`) always resolve to '' and stay byte-identical. @@ -146,13 +147,13 @@ async function installModelOnDemand(modelName, modelDir, options, warn) { } function normalizeEmbeddingConfiguration(configuration) { - if (configuration == null) { - throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); - } + if (configuration == null) configuration = {}; if (typeof configuration !== 'object' || Array.isArray(configuration)) { throw new TypeError('embedding must be an object'); } - if (typeof configuration.model !== 'string' || !configuration.model.trim()) { + const model = + configuration.model === undefined ? DEFAULT_EMBEDDING_MODEL : configuration.model; + if (typeof model !== 'string' || !model.trim()) { throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); } if ( @@ -163,7 +164,7 @@ function normalizeEmbeddingConfiguration(configuration) { } return { - model: configuration.model, + model, directory: configuration.directory, prompts: promptsFromConfig(configuration.prompts) }; @@ -394,6 +395,7 @@ function validateAttentionMask(mask) { } export { + DEFAULT_EMBEDDING_MODEL, createEmbeddingRuntime, createEmbeddingRuntimeFromModel, createFeeds, diff --git a/lib/vector_embedding/load-onnx-runtime.js b/lib/vector_embedding/load-onnx-runtime.js index e3b69e0..b0eddc4 100644 --- a/lib/vector_embedding/load-onnx-runtime.js +++ b/lib/vector_embedding/load-onnx-runtime.js @@ -18,7 +18,7 @@ function loadOnnxRuntime(requireModule) { /['"]onnxruntime-node(?:\/[^'"]*)?['"]/.test(error.message) ) { throw new Error( - "Using ai-sqlite embeddings requires onnxruntime-node@1.20.1. Install it with 'npm add -D onnxruntime-node@1.20.1'.", + "Using local SQLite embeddings requires onnxruntime-node@1.20.1. Install it with 'npm add -D onnxruntime-node@1.20.1'.", { cause: error } ); } diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index 4a493b3..2892dd0 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -789,7 +789,7 @@ async function loadTokenizerPackage(importModule = (specifier) => import(specifi /Cannot find package ['"]@huggingface\/tokenizers['"]/.test(error.message) ) { throw new Error( - "Using ai-sqlite embeddings requires @huggingface/tokenizers@0.1.3. Install it with 'npm add @huggingface/tokenizers@0.1.3'.", + "Using local SQLite embeddings requires @huggingface/tokenizers@0.1.3. Install it with 'npm add -D @huggingface/tokenizers@0.1.3'.", { cause: error } ); } diff --git a/package.json b/package.json index cfe1fd6..859b07e 100644 --- a/package.json +++ b/package.json @@ -83,15 +83,16 @@ "label": "aicore" } }, - "ai-sqlite": { - "kind": "sqlite", - "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js" + "sqlite": { + "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", + "embedding": { + "model": "sentence-transformers/all-MiniLM-L6-v2" + } }, - "ai-sqlite:memory": { - "kind": "sqlite", + "sqlite:memory": { "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", - "credentials": { - "url": ":memory:" + "embedding": { + "model": "sentence-transformers/all-MiniLM-L6-v2" } } } diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json index bcd94fa..43e9e90 100644 --- a/tests/bookshop/package.json +++ b/tests/bookshop/package.json @@ -48,18 +48,16 @@ "requires": { "[development]": { "db": { - "kind": "ai-sqlite:memory", + "kind": "sqlite:memory", "embedding": { - "model": "sentence-transformers/all-MiniLM-L6-v2", "directory": "../../.cds/models" } } }, "[test]": { "db": { - "kind": "ai-sqlite:memory", + "kind": "sqlite:memory", "embedding": { - "model": "sentence-transformers/all-MiniLM-L6-v2", "directory": "../../.cds/models" } } diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index 96ba4dd..5435273 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -5,7 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import cds from '@sap/cds'; -describe('ai-sqlite knowledge graph', () => { +describe('SQLite knowledge graph', () => { let db; const data = fileURLToPath(new URL('./bookshop/db/data/cap.ttl', import.meta.url)); @@ -13,8 +13,7 @@ describe('ai-sqlite knowledge graph', () => { before(async () => { db = await cds.connect.to('knowledge-graph-db', { - kind: 'ai-sqlite:memory', - embedding: { model: 'sentence-transformers/all-MiniLM-L6-v2' } + kind: 'sqlite:memory' }); }); diff --git a/tests/model-provisioning.test.js b/tests/model-provisioning.test.js index 337d8e0..d104d30 100644 --- a/tests/model-provisioning.test.js +++ b/tests/model-provisioning.test.js @@ -5,7 +5,10 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, test } from 'node:test'; import { runModelCommand } from '../lib/vector_embedding/cli.js'; -import { resolveEmbeddingModel } from '../lib/vector_embedding/embedding.js'; +import { + DEFAULT_EMBEDDING_MODEL, + resolveEmbeddingModel +} from '../lib/vector_embedding/embedding.js'; import { MODEL_LOCK_FILE, getModelDirectory, @@ -26,15 +29,45 @@ afterEach(async () => { }); describe('runtime model configuration', () => { - test('validates model and directory while allowing additional properties', async () => { + test('uses the default model when no model is configured', async () => { + const content = Buffer.from('default configuration fixture'); + const model = fixtureModel(content, DEFAULT_EMBEDDING_MODEL); + const root = await createTemporaryDirectory(); + const modelDir = getModelDirectory(getModelRoot(undefined, root), model.repository); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + const resolved = await resolveEmbeddingModel(undefined, { root }); + + assert.equal(resolved.modelDir, modelDir); + assert.deepEqual(resolved.model, model); + }); + + test('uses the default model with a configured cache root', async () => { + const content = Buffer.from('default shared-cache fixture'); + const model = fixtureModel(content, DEFAULT_EMBEDDING_MODEL); + const directory = await createTemporaryDirectory(); + const modelDir = getModelDirectory(directory, model.repository); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + const resolved = await resolveEmbeddingModel({ directory }); + + assert.equal(resolved.modelDir, modelDir); + assert.deepEqual(resolved.model, model); + }); + + test('validates explicit model and directory while allowing additional properties', async () => { const content = Buffer.from('configuration fixture'); const model = fixtureModel(content); await assert.rejects( - resolveEmbeddingModel(), + resolveEmbeddingModel({ model: '' }), + /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ + ); + await assert.rejects( + resolveEmbeddingModel({ model: 42 }), /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ ); await assert.rejects( - resolveEmbeddingModel({}), + resolveEmbeddingModel({ model: null }), /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ ); await assert.rejects(resolveEmbeddingModel(model.repository), /embedding must be an object/); @@ -642,10 +675,10 @@ async function createTemporaryDirectory() { return directory; } -function fixtureModel(content) { +function fixtureModel(content, repository = 'example/model') { const sha256 = createHash('sha256').update(content).digest('hex'); return { - repository: 'example/model', + repository, revision: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', dimensions: 2, maxLength: 8, diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index 055e01d..9d07e4d 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -35,7 +35,7 @@ test('explains how to install the optional tokenizer peer dependency', async () loadTokenizerPackage(async () => { throw missing; }), - /npm add @huggingface\/tokenizers@0\.1\.3/ + /npm add -D @huggingface\/tokenizers@0\.1\.3/ ); }); diff --git a/tests/vector.test.js b/tests/vector.test.js index 55349ea..7bd432f 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -2,6 +2,7 @@ import { after, before, describe, test } from 'node:test'; import assert from 'node:assert'; import cds from '@sap/cds'; import { + DEFAULT_EMBEDDING_MODEL, createEmbeddingRuntime, createEmbeddingRuntimeFromModel, resolveEmbeddingModel @@ -12,7 +13,7 @@ const MINILM_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'; let runtime; before(async () => { - runtime = await createEmbeddingRuntime({ model: MINILM_MODEL }); + runtime = await createEmbeddingRuntime(); }); after(async () => { @@ -199,22 +200,20 @@ describe('text-type prompts via configured prompts', () => { }); }); -describe('ai-sqlite integration', () => { +describe('SQLite integration', () => { let db; - test('requires an explicitly configured embedding model during startup', async () => { - await assert.rejects( - cds.connect.to('missing-embedding-model-db', { - kind: 'ai-sqlite:memory' - }), - /cds\.env\.requires\.db\.embedding\.model must be a non-empty string/ + test('uses the default embedding model', () => { + assert.strictEqual(DEFAULT_EMBEDDING_MODEL, MINILM_MODEL); + assert.strictEqual( + cds.env.requires.kinds['sqlite:memory'].embedding.model, + DEFAULT_EMBEDDING_MODEL ); }); before(async () => { db = await cds.connect.to('vector-db', { - kind: 'ai-sqlite:memory', - embedding: { model: MINILM_MODEL } + kind: 'sqlite:memory' }); }); @@ -241,8 +240,8 @@ describe('ai-sqlite integration', () => { test('allows additional embedding properties', async () => { const configuredDb = await cds.connect.to('extended-vector-db', { - kind: 'ai-sqlite:memory', - embedding: { model: MINILM_MODEL, revision: 'main', extension: { enabled: true } } + kind: 'sqlite:memory', + embedding: { revision: 'main', extension: { enabled: true } } }); try { From 83be28b4704123bfefc18991880f77599080f202 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 1 Sep 2026 00:56:55 +0200 Subject: [PATCH 32/37] style: format embedding configuration --- lib/vector_embedding/embedding.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 86ceddd..1bd3e7b 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -151,8 +151,7 @@ function normalizeEmbeddingConfiguration(configuration) { if (typeof configuration !== 'object' || Array.isArray(configuration)) { throw new TypeError('embedding must be an object'); } - const model = - configuration.model === undefined ? DEFAULT_EMBEDDING_MODEL : configuration.model; + const model = configuration.model === undefined ? DEFAULT_EMBEDDING_MODEL : configuration.model; if (typeof model !== 'string' || !model.trim()) { throw new Error('cds.env.requires.db.embedding.model must be a non-empty string'); } From a5b9b978d5b1150b776f9661d4159a1ff8aae8dc Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 1 Sep 2026 01:02:44 +0200 Subject: [PATCH 33/37] fix: inherit sqlite memory pool settings --- package.json | 5 +++-- tests/vector.test.js | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 859b07e..54df233 100644 --- a/package.json +++ b/package.json @@ -90,9 +90,10 @@ } }, "sqlite:memory": { + "kind": "sqlite", "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", - "embedding": { - "model": "sentence-transformers/all-MiniLM-L6-v2" + "credentials": { + "url": ":memory:" } } } diff --git a/tests/vector.test.js b/tests/vector.test.js index 7bd432f..bb0f027 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -205,10 +205,11 @@ describe('SQLite integration', () => { test('uses the default embedding model', () => { assert.strictEqual(DEFAULT_EMBEDDING_MODEL, MINILM_MODEL); - assert.strictEqual( - cds.env.requires.kinds['sqlite:memory'].embedding.model, - DEFAULT_EMBEDDING_MODEL - ); + const kind = cds.env.requires.kinds['sqlite:memory']; + assert.strictEqual(kind.impl, '@cap-js/ai/lib/sqlite/AISQLiteService.js'); + assert.strictEqual(kind.embedding.model, DEFAULT_EMBEDDING_MODEL); + assert.strictEqual(kind.credentials.url, ':memory:'); + assert.strictEqual(kind.pool.max, 1); }); before(async () => { From b392d5d729ec4659d5561055296f61e08f78834a Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:27:25 +0200 Subject: [PATCH 34/37] fix: harden asymmetric embedding model support (#64) * fix: harden asymmetric model semantics * fix: integrate feedback on pipeline & passages * test: align with removed passage alias * docs: align with removed passage alias Co-authored-by: Paul * chore: format * docs: clarify --------- Co-authored-by: I548646 Co-authored-by: Paul --- .docs/model-selection.md | 3 +- .docs/vector-embeddings.md | 21 +++- lib/vector_embedding/cli.js | 7 ++ lib/vector_embedding/embedding.js | 21 +++- lib/vector_embedding/model-discovery.js | 92 +++++++----------- lib/vector_embedding/model-install.js | 9 +- lib/vector_embedding/model-utils.js | 17 +++- .../all-MiniLM-L6-v2/embedding.lock.json | 5 +- tests/model-discovery.test.js | 70 +++++++++---- tests/model-provisioning.test.js | 97 +++++++++++++++++-- tests/provision-model.js | 5 +- tests/vector-unit.test.js | 16 ++- tests/vector.test.js | 4 +- 13 files changed, 261 insertions(+), 106 deletions(-) diff --git a/.docs/model-selection.md b/.docs/model-selection.md index 1c7334d..cc0372d 100644 --- a/.docs/model-selection.md +++ b/.docs/model-selection.md @@ -20,7 +20,7 @@ For local development, start with the smallest model that meets the application' The current default and Bookshop sample use [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2). It was selected only because, at the time of selection, it was the most-downloaded reasonably small candidate matching the sentence-similarity, ONNX, and Apache-2.0 filters. This is not a recommendation for any application or for production, and the default may change at any time while local embeddings remain experimental. Configure the model explicitly when that choice must stay stable. -Ideally, production and local should use identical models. For reference, SAP HANA Cloud's [`SAP_GXY.20250407` is based on RoBERTa base](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). A local MiniLM vector has different dimensions and semantics and is not interchangeable with a HANA-generated vector. Evaluate and regenerate embeddings when changing models. +Ideally, identical embedding-models should be employed during development and in production. However, this is not technically required and hard to realize, due to model availability: I.e. local ONNX models and SAP HANA native models will differ. For reference, SAP HANA Cloud's [`SAP_GXY.20250407` is based on RoBERTa base](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/vector-embedding-function-vector#available-models-without-remote-source). A local MiniLM vector has different dimensions and semantics and is not interchangeable with a HANA-generated vector. Never mix vectors from different model setups; regenerate embeddings after a change. ## Check before installing @@ -43,6 +43,7 @@ Discovery currently requires: - a determinable input limit - an unambiguous Sentence Transformers pipeline of Transformer, Pooling, and optional Normalize stages - mean or CLS pooling +- when a model declares [prompts it was trained with](https://sbert.net/examples/sentence_transformer/training/prompts/README.html) (typically `query` and `document`): Metadata that maps these prompts to [SAP HANA `VECTOR_EMBEDDING`s `text-type`](https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-sql-reference-guide/vector-embedding-function-vector) The ONNX graph must accept rank-2 `int64` `input_ids`; it may also accept `attention_mask` and `token_type_ids`. A token-level output used for pooling must be a floating-point rank-3 tensor whose final dimension matches the discovered model dimension. diff --git a/.docs/vector-embeddings.md b/.docs/vector-embeddings.md index f93c093..eb5beb8 100644 --- a/.docs/vector-embeddings.md +++ b/.docs/vector-embeddings.md @@ -122,6 +122,8 @@ npx @cap-js/ai check-model owner/model This reports likely compatibility. `install-model` is definitive because it also downloads the artifacts, loads the ONNX model, verifies its inputs and output, and runs a probe inference. +Prompt metadata is part of `embedding.lock.json`. Locks from earlier experimental versions that do not describe prompt semantics are rejected. Remove the affected model directory, then run `install-model` again to regenerate it. + See [Choosing a model](model-selection.md) for discovery filters and the supported model contract. ## SQL function @@ -141,8 +143,23 @@ VECTOR_EMBEDDING(text, text_type, model_and_version) VECTOR_EMBEDDING(text, text_type, model_and_version, remote_source) ``` -`text` is embedded, and `text_type` selects the model's query or document prompt when it has one, discovered from the model, or set through `embedding.prompts.{query,document}` for models whose prompts are not discoverable. -Models where no `prompts`-metadata is available will ignore `text_type` unless `embedding.prompts` are configured. +`text` is embedded. For models trained with query/document prompts, `text_type` applies the compatible prefix discovered from the model metadata. No additional configuration is normally needed, and `check-model` displays the detected mapping. + +If required prompts are not available in model metadata, configure them explicitly: + +```json +{ + "embedding": { + "model": "owner/model", + "prompts": { + "query": "search_query: ", + "document": "search_document: " + } + } +} +``` + +Configured `embedding.prompts` entries override the corresponding discovered entry; omitted entries continue using discovered metadata. For models with `include_prompt=false`, this runtime cannot apply discovered or configured prompts; For models that were not trained with prompts, prompt-free use is supported. `model_and_version` and `remote_source` preserve the SQL shape for development compatibility but do not affect local inference. SQL `NULL` returns `NULL`, and empty or whitespace-only text is treated as no value and returns a zero vector. The result is a JSON string containing the model's vector dimensions. diff --git a/lib/vector_embedding/cli.js b/lib/vector_embedding/cli.js index 2d25b3a..9e133f8 100644 --- a/lib/vector_embedding/cli.js +++ b/lib/vector_embedding/cli.js @@ -97,6 +97,12 @@ function parseArguments(argv) { function formatModelCheck(model) { const modelFile = model.files.find(({ role }) => role === 'model'); + const prompts = model.prompts + ? `\nText-type prompts:\n${['query', 'document'] + .filter((name) => model.prompts[name] !== undefined) + .map((name) => ` ${name.toUpperCase()}: ${JSON.stringify(model.prompts[name])}`) + .join('\n')}` + : '\nText-type prompts: none'; return `Likely compatible: ${model.repository} Revision: ${model.revision} Task: ${model.task ?? 'not declared'} @@ -106,6 +112,7 @@ Maximum input length: ${model.maxLength} Expected ONNX output: ${model.output.name} Pooling: ${model.output.pooling} Normalization: ${model.output.normalize ? 'enabled' : 'disabled'} +Prompt tokens in pooling: ${model.output.includePrompt ? 'included' : 'excluded'}${prompts} Run 'npx @cap-js/ai install-model ${model.repository}' for definitive ONNX Runtime validation. `; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 1bd3e7b..5a7d9c1 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -94,8 +94,7 @@ async function resolveEmbeddingModel(configuration, options = {}) { discover, validate: validate ?? validateEmbeddingModel, timeoutMs: options.provisionTimeoutMs, - retryMs: options.provisionRetryMs, - prompts + retryMs: options.provisionRetryMs }; let model; @@ -110,7 +109,8 @@ async function resolveEmbeddingModel(configuration, options = {}) { )}`; throw new Error(`${error.message}. ${recovery}`, { cause: error }); } - return installModelOnDemand(modelName, modelDir, installOptions, warn); + const installed = await installModelOnDemand(modelName, modelDir, installOptions, warn); + return { ...installed, model: applyPromptConfiguration(installed.model, prompts) }; } if (model.repository !== modelName) { @@ -126,10 +126,21 @@ async function resolveEmbeddingModel(configuration, options = {}) { cause: error }); } - return installModelOnDemand(modelName, modelDir, installOptions, warn); + const installed = await installModelOnDemand(modelName, modelDir, installOptions, warn); + return { ...installed, model: applyPromptConfiguration(installed.model, prompts) }; } - return { model: prompts ? { ...model, prompts } : model, modelDir }; + return { model: applyPromptConfiguration(model, prompts), modelDir }; +} + +function applyPromptConfiguration(model, configuredPrompts) { + if (!configuredPrompts) return model; + if (!model.output.includePrompt) { + throw new Error( + 'embedding.prompts cannot be used because the model excludes prompt tokens from pooling' + ); + } + return { ...model, prompts: { ...model.prompts, ...configuredPrompts } }; } async function installModelOnDemand(modelName, modelDir, options, warn) { diff --git a/lib/vector_embedding/model-discovery.js b/lib/vector_embedding/model-discovery.js index a4f4d9a..c5c71eb 100644 --- a/lib/vector_embedding/model-discovery.js +++ b/lib/vector_embedding/model-discovery.js @@ -7,7 +7,6 @@ import { assertSafeRepository, validateModelDescriptor } from './model-utils.js' const MODULES_FILE = 'modules.json'; const SENTENCE_CONFIG_FILE = 'sentence_bert_config.json'; const CONFIG_ST_FILE = 'config_sentence_transformers.json'; -const ALLOWED_PROMPT_NAMES = new Set(['query', 'document', 'passage']); // TODO: Are these official? const EMBEDDING_TASKS = new Set(['feature-extraction', 'sentence-similarity']); const PIPELINE_MODULES = new Set([ 'sentence_transformers.models.Transformer', @@ -15,14 +14,6 @@ const PIPELINE_MODULES = new Set([ 'sentence_transformers.models.Normalize' ]); -// Modules that post-process output format only and do not affect the -// Transformer → Pooling → Normalize inference pipeline. -const TRANSPARENT_MODULE_NAMESPACES = [/^sentence_transformers\.quantization\./, /^st_quantize\./]; - -function isTransparentModule(type) { - return TRANSPARENT_MODULE_NAMESPACES.some((pattern) => pattern.test(type)); -} - async function discoverModel(repository, options = {}) { const { candidate, context, filesByPath, knownFiles } = await discoverModelMetadata( repository, @@ -137,7 +128,8 @@ async function discoverModelMetadata(repository, options) { output: { name: 'last_hidden_state', pooling: semantics.pooling, - normalize: semantics.normalize + normalize: semantics.normalize, + includePrompt: semantics.includePrompt }, ...(semantics.prompts ? { prompts: semantics.prompts } : {}) }); @@ -286,39 +278,46 @@ async function readSentenceTransformerSemantics(context, repository, revision, f throw new Error(`Invalid Sentence Transformers modules in '${repository}/${MODULES_FILE}'`); } - // modules.json defines the ordered execution pipeline. Skipping a module that changes the - // semantic content of the embedding would silently produce incompatible vectors. Modules in - // TRANSPARENT_MODULES only post-process output format (e.g. quantization) and are safe to - // omit when float32 cosine similarity is the downstream search metric. + // modules.json defines the ordered execution pipeline. Accept only stages that this runtime + // actually executes; skipping a custom or future module would silently change the embeddings. for (const module of modules) { if (!module || typeof module.type !== 'string') throw new Error(`Invalid Sentence Transformers module in '${repository}/${MODULES_FILE}'`); - if (!PIPELINE_MODULES.has(module.type) && !isTransparentModule(module.type)) { + if (!PIPELINE_MODULES.has(module.type)) { throw new Error( `Unsupported Sentence Transformers module '${module.type}' in '${repository}'` ); } } - const pipeline = modules.filter((module) => PIPELINE_MODULES.has(module.type)); + const expectedTypes = [ 'sentence_transformers.models.Transformer', 'sentence_transformers.models.Pooling' ]; - if (pipeline.length === 3) expectedTypes.push('sentence_transformers.models.Normalize'); + if (modules.length === 3) expectedTypes.push('sentence_transformers.models.Normalize'); if ( - pipeline.length < 2 || - pipeline.length > 3 || - pipeline.some((module, index) => module.type !== expectedTypes[index]) + modules.length < 2 || + modules.length > 3 || + modules.some((module, index) => module.type !== expectedTypes[index]) ) { throw new Error(`Cannot determine an unambiguous pooling pipeline for '${repository}'`); } - const poolingPath = moduleConfigPath(pipeline[1], repository); + const poolingPath = moduleConfigPath(modules[1], repository); if (!filesByPath.has(poolingPath)) { throw new Error(`Sentence Transformers pooling configuration '${poolingPath}' is missing`); } const poolingConfig = (await fetchJsonFile(context, repository, revision, poolingPath)).value; const pooling = determinePooling(poolingConfig, repository); + if ( + poolingConfig.include_prompt !== undefined && + typeof poolingConfig.include_prompt !== 'boolean' + ) { + throw new Error( + `Invalid include_prompt in Sentence Transformers pooling configuration for '${repository}'` + ); + } + const includePrompt = poolingConfig.include_prompt !== false; // Check if the sentence-transformer was trained with registered prompts // See: https://sbert.net/examples/sentence_transformer/training/prompts/README.html @@ -330,7 +329,7 @@ async function readSentenceTransformerSemantics(context, repository, revision, f prompts = interpretPrompts(stConfig, repository); // Intentionally checked post-normalization: empty-string prompts produce no prefix tokens, // so include_prompt=false is a no-op for those and should not cause rejection. - if (prompts && poolingConfig.include_prompt === false) { + if (prompts && !includePrompt) { throw new Error( `Sentence Transformers model '${repository}' excludes prompt tokens from pooling (include_prompt=false); prefixing cannot be emulated` ); @@ -339,10 +338,8 @@ async function readSentenceTransformerSemantics(context, repository, revision, f let maxLength; const sentenceConfigPaths = [SENTENCE_CONFIG_FILE]; - if (pipeline[0]?.path) { - sentenceConfigPaths.unshift( - `${normalizedModulePath(pipeline[0].path)}/${SENTENCE_CONFIG_FILE}` - ); + if (modules[0]?.path) { + sentenceConfigPaths.unshift(`${normalizedModulePath(modules[0].path)}/${SENTENCE_CONFIG_FILE}`); } const sentenceConfigPath = sentenceConfigPaths.find((candidate) => filesByPath.has(candidate)); if (sentenceConfigPath) { @@ -350,12 +347,17 @@ async function readSentenceTransformerSemantics(context, repository, revision, f maxLength = positiveInteger(sentenceConfig.value.max_seq_length); } - return { pooling, normalize: pipeline.length === 3, maxLength, ...(prompts ? { prompts } : {}) }; + return { + pooling, + normalize: modules.length === 3, + includePrompt, + maxLength, + ...(prompts ? { prompts } : {}) + }; } -// Interpret Sentence Transformers `prompts` fail-closed: map only the known-safe QUERY/DOCUMENT -// shape onto HANA text types. Real models use arbitrary prompt names, so anything we cannot map -// unambiguously throws rather than silently mis-encoding. An absent `prompts` key is not an error. +// Map the conventional retrieval prompts onto HANA text types. Sentence Transformers permits +// arbitrary additional prompt names, which do not affect QUERY/DOCUMENT and are ignored here. function interpretPrompts(config, repository) { if (config == null) return undefined; if (typeof config !== 'object' || Array.isArray(config)) { @@ -364,7 +366,7 @@ function interpretPrompts(config, repository) { ); } - const { prompts, default_prompt_name: defaultPromptName } = config; + const { prompts } = config; if (prompts === undefined || prompts === null) return undefined; if (typeof prompts !== 'object' || Array.isArray(prompts)) { @@ -374,40 +376,14 @@ function interpretPrompts(config, repository) { } for (const [name, value] of Object.entries(prompts)) { - if (!ALLOWED_PROMPT_NAMES.has(name)) { - throw new Error( - `Unsupported Sentence Transformers prompt '${name}' in '${repository}'; only query, document, and passage map to HANA text types` - ); - } if (typeof value !== 'string') { throw new Error(`Sentence Transformers prompt '${name}' in '${repository}' must be a string`); } } - if ( - defaultPromptName !== undefined && - defaultPromptName !== null && - !ALLOWED_PROMPT_NAMES.has(defaultPromptName) - ) { - throw new Error( - `Unsupported Sentence Transformers default_prompt_name '${defaultPromptName}' in '${repository}'` - ); - } - - if ( - prompts.document !== undefined && - prompts.passage !== undefined && - prompts.document !== prompts.passage - ) { - throw new Error( - `Conflicting Sentence Transformers 'document' and 'passage' prompts in '${repository}'` - ); - } - - const document = prompts.document ?? prompts.passage; const normalized = {}; if (prompts.query) normalized.query = prompts.query; - if (document) normalized.document = document; + if (prompts.document) normalized.document = prompts.document; return Object.keys(normalized).length > 0 ? normalized : undefined; } diff --git a/lib/vector_embedding/model-install.js b/lib/vector_embedding/model-install.js index 7b6a808..288cc31 100644 --- a/lib/vector_embedding/model-install.js +++ b/lib/vector_embedding/model-install.js @@ -43,14 +43,7 @@ async function installModel(repository, options = {}) { validate: options.validate }); - // A configured prompt override (embedding.prompts) takes precedence over the discovered - // prompts at runtime; the persisted lock keeps the discovered prompts, so we override - // only the returned model here. - return { - model: options.prompts ? { ...model, prompts: options.prompts } : model, - modelDir, - modelRoot - }; + return { model, modelDir, modelRoot }; } catch (error) { if (error.code !== MODEL_PROVISIONING_IN_PROGRESS) throw error; const remaining = deadline - Date.now(); diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index 2892dd0..4653a61 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -7,7 +7,7 @@ import path from 'path'; const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; const MODEL_LOCK_FILE = 'embedding.lock.json'; const MODEL_INSTALL_LOCK_FILE = '.embedding.install.lock'; -const MODEL_LOCK_VERSION = 1; +const MODEL_LOCK_VERSION = 2; const MODEL_PROVISIONING_IN_PROGRESS = 'ERR_EMBEDDING_MODEL_PROVISIONING_IN_PROGRESS'; const INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; const PROVISIONED_DIRECTORY_MODE = 0o755; @@ -80,8 +80,14 @@ function validateModelDescriptor(model) { if (typeof output.normalize !== 'boolean') { throw new Error('embedding.output.normalize must be a boolean'); } + if (typeof output.includePrompt !== 'boolean') { + throw new Error('embedding.output.includePrompt must be a boolean'); + } if (model.prompts !== undefined) validatePrompts(model.prompts); + if (model.prompts && !output.includePrompt) { + throw new Error('Embedding prompts require embedding.output.includePrompt to be true'); + } return model; } @@ -183,7 +189,8 @@ function modelDescriptorDigest(model) { output: { name: model.output.name, pooling: model.output.pooling, - normalize: model.output.normalize + normalize: model.output.normalize, + includePrompt: model.output.includePrompt }, ...(model.prompts ? { @@ -503,6 +510,11 @@ async function readModelLock(modelDir) { }); } if (lock.formatVersion !== MODEL_LOCK_VERSION) { + if (lock.formatVersion === 1) { + throw new Error( + `Embedding model lock version 1 at ${lockPath} predates prompt semantics; remove and reinstall the model` + ); + } throw new Error( `Unsupported embedding model lock version ${lock.formatVersion ?? 'missing'} at ${lockPath}` ); @@ -799,6 +811,7 @@ async function loadTokenizerPackage(importModule = (specifier) => import(specifi export { MODEL_LOCK_FILE, + MODEL_LOCK_VERSION, MODEL_PROVISIONING_IN_PROGRESS, assertSafeRepository, downloadFile, diff --git a/tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json b/tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json index dbf839f..c73bcd2 100644 --- a/tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json +++ b/tests/fixtures/sentence-transformers/all-MiniLM-L6-v2/embedding.lock.json @@ -36,7 +36,8 @@ "output": { "name": "last_hidden_state", "pooling": "mean", - "normalize": true + "normalize": true, + "includePrompt": true }, - "formatVersion": 1 + "formatVersion": 2 } diff --git a/tests/model-discovery.test.js b/tests/model-discovery.test.js index 94429d5..9754a72 100644 --- a/tests/model-discovery.test.js +++ b/tests/model-discovery.test.js @@ -42,7 +42,12 @@ describe('Hugging Face model discovery', () => { }, { role: 'auxiliary', name: 'config.json', path: 'config.json' } ], - output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + output: { + name: 'last_hidden_state', + pooling: 'mean', + normalize: true, + includePrompt: true + } } ); assert.ok( @@ -69,7 +74,8 @@ describe('Hugging Face model discovery', () => { assert.deepEqual(descriptor.output, { name: 'last_hidden_state', pooling: 'mean', - normalize: true + normalize: true, + includePrompt: true }); assert.deepEqual( descriptor.files.map(({ role, name, path }) => ({ role, name, path })), @@ -292,14 +298,15 @@ describe('Hugging Face model discovery', () => { assert.equal(model.sha256, digest(contents)); }); - test('should map sentence-transformers query and passage prompts onto HANA text types', async () => { + test('should map sentence-transformers query and document prompts onto HANA text types', async () => { const hub = hubFor({ - stConfig: { prompts: { query: 'query: ', passage: 'passage: ' }, default_prompt_name: null } + stConfig: { prompts: { query: 'query: ', document: 'document: ' }, default_prompt_name: null } }); const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); - assert.deepEqual(descriptor.prompts, { query: 'query: ', document: 'passage: ' }); + assert.deepEqual(descriptor.prompts, { query: 'query: ', document: 'document: ' }); + assert.equal(descriptor.output.includePrompt, true); }); test('should keep a query-only prompt without inventing a document prefix', async () => { @@ -310,8 +317,8 @@ describe('Hugging Face model discovery', () => { assert.deepEqual(descriptor.prompts, { query: 'query: ' }); }); - test('should treat an empty document/passage prompt as no prefix', async () => { - const hub = hubFor({ stConfig: { prompts: { query: 'query: ', passage: '' } } }); + test('should treat an empty document prompt as no prefix', async () => { + const hub = hubFor({ stConfig: { prompts: { query: 'query: ', document: '' } } }); const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); @@ -335,19 +342,46 @@ describe('Hugging Face model discovery', () => { ); }); - test('should reject prompt shapes that cannot be mapped to HANA text types', async () => { - const unknownKey = hubFor({ stConfig: { prompts: { classification: 'Classify: ' } } }); + test('should preserve prompt-pooling behavior without discovered prompts', async () => { + const hub = hubFor({ includePrompt: false }); + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.equal(descriptor.output.includePrompt, false); + assert.equal(descriptor.prompts, undefined); + }); + + test('should reject invalid prompt-pooling metadata', async () => { + const hub = hubFor({ includePrompt: 'false' }); + await assert.rejects( - discoverModel(REPOSITORY, { hubClient: unknownKey.client }), - /Unsupported Sentence Transformers prompt 'classification'/ + discoverModel(REPOSITORY, { hubClient: hub.client }), + /Invalid include_prompt/ ); + }); - const conflicting = hubFor({ - stConfig: { prompts: { document: 'doc: ', passage: 'passage: ' } } + test('should ignore unrelated prompt names and defaults', async () => { + const hub = hubFor({ + stConfig: { + prompts: { query: 'query: ', classification: 'Classify: ' }, + default_prompt_name: 'classification' + } }); - await assert.rejects( - discoverModel(REPOSITORY, { hubClient: conflicting.client }), - /Conflicting Sentence Transformers 'document' and 'passage' prompts/ + + const descriptor = await discoverModel(REPOSITORY, { hubClient: hub.client }); + + assert.deepEqual(descriptor.prompts, { query: 'query: ' }); + }); + + test('should reject unimplemented modules even when their namespace suggests quantization', async () => { + await Promise.all( + ['sentence_transformers.quantization.Unknown', 'st_quantize.Unknown'].map(async (type) => { + const hub = hubFor({ moduleOrder: ['Transformer', 'Pooling', 'Normalize', type] }); + await assert.rejects( + discoverModel(REPOSITORY, { hubClient: hub.client }), + new RegExp(`Unsupported Sentence Transformers module '${type.replaceAll('.', '\\.')}'`) + ); + }) ); }); }); @@ -392,7 +426,7 @@ function hubFor(options = {}) { idx: index, name: String(index), path: type === 'Pooling' ? '1_Pooling' : '', - type: `sentence_transformers.models.${type}` + type: type.includes('.') ? type : `sentence_transformers.models.${type}` })) ); files['1_Pooling/config.json'] = json( @@ -486,7 +520,7 @@ function poolingConfig(pooling, includePrompt) { pooling_mode_mean_sqrt_len_tokens: false, pooling_mode_weightedmean_tokens: false, pooling_mode_lasttoken: false, - ...(typeof includePrompt === 'boolean' ? { include_prompt: includePrompt } : {}) + ...(includePrompt !== undefined ? { include_prompt: includePrompt } : {}) }; } diff --git a/tests/model-provisioning.test.js b/tests/model-provisioning.test.js index d104d30..11ff3f1 100644 --- a/tests/model-provisioning.test.js +++ b/tests/model-provisioning.test.js @@ -11,6 +11,7 @@ import { } from '../lib/vector_embedding/embedding.js'; import { MODEL_LOCK_FILE, + MODEL_LOCK_VERSION, getModelDirectory, getModelRoot, provisionModel, @@ -90,6 +91,47 @@ describe('runtime model configuration', () => { assert.equal(resolved.modelDir, modelDir); assert.deepEqual(resolved.model, model); }); + + test('merges configured prompts over discovered prompts per text type', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('prompt merge fixture'); + const model = { + ...fixtureModel(content), + prompts: { query: 'query: ', document: 'document: ' } + }; + const modelDir = getModelDirectory(directory, model.repository); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + const resolved = await resolveEmbeddingModel({ + model: model.repository, + directory, + prompts: { query: 'custom query: ' } + }); + + assert.deepEqual(resolved.model.prompts, { + query: 'custom query: ', + document: 'document: ' + }); + assert.deepEqual(await readModelLock(modelDir), model); + }); + + test('rejects configured prompts when prompt tokens are excluded from pooling', async () => { + const directory = await createTemporaryDirectory(); + const content = Buffer.from('prompt exclusion fixture'); + const base = fixtureModel(content); + const model = { ...base, output: { ...base.output, includePrompt: false } }; + const modelDir = getModelDirectory(directory, model.repository); + await provisionModel(model, { directory: modelDir, fetchImpl: createFetch(content) }); + + await assert.rejects( + resolveEmbeddingModel({ + model: model.repository, + directory, + prompts: { query: 'query: ' } + }), + /excludes prompt tokens from pooling/ + ); + }); }); describe('explicit model provisioning', () => { @@ -112,7 +154,7 @@ describe('explicit model provisioning', () => { assert.deepEqual(await readModelLock(directory), model); assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, MODEL_LOCK_FILE), 'utf8')), { ...model, - formatVersion: 1 + formatVersion: MODEL_LOCK_VERSION }); assert.deepEqual((await fs.readdir(directory)).sort(), [ MODEL_LOCK_FILE, @@ -181,9 +223,36 @@ describe('explicit model provisioning', () => { provisionModel({ ...model, dimensions: model.dimensions + 1 }, { directory }), /locked to a different model descriptor/ ); + await assert.rejects( + provisionModel( + { ...model, output: { ...model.output, includePrompt: false } }, + { directory } + ), + /locked to a different model descriptor/ + ); + await assert.rejects( + provisionModel({ ...model, prompts: { query: 'query: ' } }, { directory }), + /locked to a different model descriptor/ + ); assert.deepEqual(await readModelLock(directory), model); }); + test('rejects legacy model locks that predate prompt semantics', async () => { + const directory = await createTemporaryDirectory(); + const model = fixtureModel(Buffer.from('legacy lock fixture')); + const { includePrompt, ...legacyOutput } = model.output; + void includePrompt; + await fs.writeFile( + path.join(directory, MODEL_LOCK_FILE), + JSON.stringify({ ...model, output: legacyOutput, formatVersion: 1 }) + ); + + await assert.rejects( + readModelLock(directory), + /version 1.*predates prompt semantics.*reinstall/ + ); + }); + test('serializes provisioning attempts for the same directory', async () => { const directory = await createTemporaryDirectory(); const content = Buffer.from('concurrent model fixture'); @@ -386,6 +455,7 @@ describe('explicit model provisioning', () => { const requestedUrls = []; const warnings = []; let discoveries = 0; + const prompts = { query: 'query: ' }; const options = { root, fetchImpl: createFetch(content, requestedUrls), @@ -398,10 +468,10 @@ describe('explicit model provisioning', () => { warn: (message) => warnings.push(message) }; - const first = await resolveEmbeddingModel({ model: model.repository }, options); + const first = await resolveEmbeddingModel({ model: model.repository, prompts }, options); const expectedDirectory = getModelDirectory(getModelRoot(undefined, root), model.repository); - assert.equal(first.model, model); + assert.deepEqual(first.model, { ...model, prompts }); assert.equal(first.modelDir, expectedDirectory); assert.equal(discoveries, 1); assert.equal(warnings.length, 1); @@ -410,7 +480,8 @@ describe('explicit model provisioning', () => { assert.equal(requestedUrls.length, model.files.length); assert.deepEqual(await readModelLock(expectedDirectory), model); - const second = await resolveEmbeddingModel({ model: model.repository }, options); + const second = await resolveEmbeddingModel({ model: model.repository, prompts }, options); + assert.deepEqual(second.model, { ...model, prompts }); assert.equal(second.modelDir, expectedDirectory); assert.equal(discoveries, 1); assert.equal(warnings.length, 1); @@ -627,7 +698,13 @@ describe('explicit model provisioning', () => { { role: 'model', name: 'model.onnx', path: 'onnx/model.onnx' }, { role: 'tokenizer', name: 'tokenizer.json', path: 'tokenizer.json' } ], - output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + output: { + name: 'last_hidden_state', + pooling: 'mean', + normalize: true, + includePrompt: true + }, + prompts: { query: 'query: ', document: 'document: ' } }; await runModelCommand(['check-model', checked.repository], { @@ -640,6 +717,9 @@ describe('explicit model provisioning', () => { }); assert.match(output.join(''), /Likely compatible/i); + assert.match(output.join(''), /Prompt tokens in pooling: included/); + assert.match(output.join(''), /QUERY: "query: "/); + assert.match(output.join(''), /DOCUMENT: "document: "/); assert.match(output.join(''), /install-model.*definitive/i); await assert.rejects(fs.access(path.join(root, '.cds', 'models')), /ENOENT/); }); @@ -699,7 +779,12 @@ function fixtureModel(content, repository = 'example/model') { sha256 } ], - output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + output: { + name: 'last_hidden_state', + pooling: 'mean', + normalize: true, + includePrompt: true + } }; } diff --git a/tests/provision-model.js b/tests/provision-model.js index 1c416d2..de60b9f 100644 --- a/tests/provision-model.js +++ b/tests/provision-model.js @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import { validateEmbeddingModel } from '../lib/vector_embedding/embedding.js'; import { + MODEL_LOCK_VERSION, getModelDirectory, getModelRoot, provisionModel @@ -12,7 +13,9 @@ const lockUrl = new URL( import.meta.url ); const { formatVersion, ...model } = JSON.parse(await fs.readFile(lockUrl, 'utf8')); -if (formatVersion !== 1) throw new Error(`Unsupported test model lock version ${formatVersion}`); +if (formatVersion !== MODEL_LOCK_VERSION) { + throw new Error(`Unsupported test model lock version ${formatVersion}`); +} const modelDir = getModelDirectory(getModelRoot(undefined, process.cwd()), model.repository); await provisionModel(model, { directory: modelDir, validate: validateEmbeddingModel }); diff --git a/tests/vector-unit.test.js b/tests/vector-unit.test.js index 9d07e4d..9a584fe 100644 --- a/tests/vector-unit.test.js +++ b/tests/vector-unit.test.js @@ -290,6 +290,15 @@ describe('model compatibility', () => { }), /conflicts with another embedding file/ ); + assert.throws( + () => + validateModelDescriptor({ + ...model, + output: { ...model.output, includePrompt: false }, + prompts: { query: 'query: ' } + }), + /prompts require embedding\.output\.includePrompt to be true/ + ); }); }); @@ -432,6 +441,11 @@ function fixtureModel(content) { sha256 } ], - output: { name: 'last_hidden_state', pooling: 'mean', normalize: true } + output: { + name: 'last_hidden_state', + pooling: 'mean', + normalize: true, + includePrompt: true + } }; } diff --git a/tests/vector.test.js b/tests/vector.test.js index bb0f027..9e27f3d 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -153,7 +153,7 @@ describe('text-type prompts via configured prompts', () => { // These prefixes come from `embedding.prompts.{query,document}`, the user-configured override that // takes precedence over discovered prompts — the path a prompt-trained model without // discoverable prompts relies on. - const PROMPTS = { query: 'query: ', document: 'passage: ' }; + const PROMPTS = { query: 'query: ', document: 'document: ' }; before(async () => { const { model, modelDir } = await resolveEmbeddingModel({ @@ -177,7 +177,7 @@ describe('text-type prompts via configured prompts', () => { test('should prepend the configured document prefix for the DOCUMENT text type', () => { assert.strictEqual( promptRuntime.vectorEmbedding('a small cat', 'DOCUMENT'), - runtime.vectorEmbedding('passage: a small cat') + runtime.vectorEmbedding('document: a small cat') ); }); From b7df0ffb502f0dd177833ff32f140d375ce34992 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 1 Sep 2026 14:48:38 +0200 Subject: [PATCH 35/37] refactor: inherit AI sqlite memory configuration --- .docs/vector-embeddings.md | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- package.json | 7 ------- tests/bookshop/package.json | 10 ++++++++-- tests/knowledge-graph.test.js | 3 ++- tests/vector.test.js | 31 +++++++++++++++++++++++-------- 7 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.docs/vector-embeddings.md b/.docs/vector-embeddings.md index eb5beb8..ca4321d 100644 --- a/.docs/vector-embeddings.md +++ b/.docs/vector-embeddings.md @@ -7,7 +7,7 @@ ## Database kinds and dependencies -`@cap-js/ai` redirects CAP's standard SQLite implementations instead of adding separate database kinds: +`@cap-js/ai` redirects CAP's standard `sqlite` implementation instead of adding a separate database kind. With `@sap/cds` `^10.1`, the standard `sqlite:memory` preset inherits that implementation: - `sqlite` uses a file-based SQLite database. - `sqlite:memory` uses an in-memory SQLite database. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab328e..5d697a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- **Experimental:** Extend the standard `sqlite` and `sqlite:memory` services for local CAP development with `VECTOR_EMBEDDING`, model provisioning tooling, and `sentence-transformers/all-MiniLM-L6-v2` as a replaceable default that may change while the feature remains experimental. +- **Experimental:** Extend the standard `sqlite` service and its `sqlite:memory` preset for local CAP development with `VECTOR_EMBEDDING`, model provisioning tooling, and `sentence-transformers/all-MiniLM-L6-v2` as a replaceable default that may change while the feature remains experimental. - **Experimental:** Add local `SPARQL_EXECUTE` and `sparql_table` support through the optional `oxigraph` peer dependency. Local vector embeddings require `@sap/cds` `^10.1` and `@cap-js/sqlite` `^3.1`; the package's other capabilities continue to support `@sap/cds` 9. diff --git a/README.md b/README.md index 4c3811d..e7777c1 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Here is a complete Bookshop example. curl 'http://localhost:4004/odata/v4/catalog/embedding(text=%27A%20book%20about%20travel%27)' ``` -`@cap-js/ai` redirects the standard `sqlite` and `sqlite:memory` implementations to add the local capabilities. The first start warns that the default model is missing, downloads it to `.cds/models`, and then initializes it. The response's `value` contains a JSON-encoded vector with 384 numbers. Later starts reuse the installed model. +`@cap-js/ai` redirects the standard `sqlite` implementation to add the local capabilities. With `@sap/cds` `^10.1`, the standard `sqlite:memory` preset inherits that implementation. The first start warns that the default model is missing, downloads it to `.cds/models`, and then initializes it. The response's `value` contains a JSON-encoded vector with 384 numbers. Later starts reuse the installed model. The current default is [`sentence-transformers/all-MiniLM-L6-v2`](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2). It was selected only because, at the time of selection, it was the most-downloaded reasonably small model matching the sentence-similarity task, ONNX format, and Apache-2.0 license filters below. This is not a recommendation, and the default may change at any time while this feature is experimental. Configure `cds.requires.db.embedding.model` explicitly if the choice must remain stable. diff --git a/package.json b/package.json index 54df233..aca09d5 100644 --- a/package.json +++ b/package.json @@ -88,13 +88,6 @@ "embedding": { "model": "sentence-transformers/all-MiniLM-L6-v2" } - }, - "sqlite:memory": { - "kind": "sqlite", - "impl": "@cap-js/ai/lib/sqlite/AISQLiteService.js", - "credentials": { - "url": ":memory:" - } } } } diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json index 43e9e90..60d494f 100644 --- a/tests/bookshop/package.json +++ b/tests/bookshop/package.json @@ -48,7 +48,10 @@ "requires": { "[development]": { "db": { - "kind": "sqlite:memory", + "kind": "sqlite", + "credentials": { + "url": ":memory:" + }, "embedding": { "directory": "../../.cds/models" } @@ -56,7 +59,10 @@ }, "[test]": { "db": { - "kind": "sqlite:memory", + "kind": "sqlite", + "credentials": { + "url": ":memory:" + }, "embedding": { "directory": "../../.cds/models" } diff --git a/tests/knowledge-graph.test.js b/tests/knowledge-graph.test.js index 5435273..2b1680f 100644 --- a/tests/knowledge-graph.test.js +++ b/tests/knowledge-graph.test.js @@ -13,7 +13,8 @@ describe('SQLite knowledge graph', () => { before(async () => { db = await cds.connect.to('knowledge-graph-db', { - kind: 'sqlite:memory' + kind: 'sqlite', + credentials: { url: ':memory:' } }); }); diff --git a/tests/vector.test.js b/tests/vector.test.js index 9e27f3d..0a33f1f 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -9,6 +9,7 @@ import { } from '../lib/vector_embedding/embedding.js'; const MINILM_MODEL = 'sentence-transformers/all-MiniLM-L6-v2'; +const AI_SQLITE_IMPL = '@cap-js/ai/lib/sqlite/AISQLiteService.js'; let runtime; @@ -205,17 +206,30 @@ describe('SQLite integration', () => { test('uses the default embedding model', () => { assert.strictEqual(DEFAULT_EMBEDDING_MODEL, MINILM_MODEL); - const kind = cds.env.requires.kinds['sqlite:memory']; - assert.strictEqual(kind.impl, '@cap-js/ai/lib/sqlite/AISQLiteService.js'); + const kind = cds.env.requires.kinds.sqlite; + assert.strictEqual(kind.impl, AI_SQLITE_IMPL); assert.strictEqual(kind.embedding.model, DEFAULT_EMBEDDING_MODEL); - assert.strictEqual(kind.credentials.url, ':memory:'); - assert.strictEqual(kind.pool.max, 1); + }); + + const memoryKind = cds.env.requires.kinds['sqlite:memory']; + test('inherits the AI-enabled sqlite kind for sqlite:memory', { + skip: memoryKind?.kind !== 'sqlite' + }, () => { + assert.strictEqual(memoryKind.impl, AI_SQLITE_IMPL); + assert.strictEqual(memoryKind.embedding.model, DEFAULT_EMBEDDING_MODEL); + assert.strictEqual(memoryKind.credentials.url, ':memory:'); + assert.strictEqual(memoryKind.pool.evictionRunIntervalMillis, 0); + assert.strictEqual(memoryKind.pool.min, 1); + assert.strictEqual(memoryKind.pool.max, 1); }); before(async () => { - db = await cds.connect.to('vector-db', { - kind: 'sqlite:memory' - }); + db = await cds.connect.to( + 'vector-db', + memoryKind?.kind === 'sqlite' + ? { kind: 'sqlite:memory' } + : { kind: 'sqlite', credentials: { url: ':memory:' } } + ); }); after(async () => { @@ -241,7 +255,8 @@ describe('SQLite integration', () => { test('allows additional embedding properties', async () => { const configuredDb = await cds.connect.to('extended-vector-db', { - kind: 'sqlite:memory', + kind: 'sqlite', + credentials: { url: ':memory:' }, embedding: { revision: 'main', extension: { enabled: true } } }); From 94905093ef88b75573e5713d2884b47f4eba5020 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 1 Sep 2026 14:51:22 +0200 Subject: [PATCH 36/37] style: format sqlite inheritance test --- tests/vector.test.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/vector.test.js b/tests/vector.test.js index 0a33f1f..5257716 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -212,16 +212,20 @@ describe('SQLite integration', () => { }); const memoryKind = cds.env.requires.kinds['sqlite:memory']; - test('inherits the AI-enabled sqlite kind for sqlite:memory', { - skip: memoryKind?.kind !== 'sqlite' - }, () => { - assert.strictEqual(memoryKind.impl, AI_SQLITE_IMPL); - assert.strictEqual(memoryKind.embedding.model, DEFAULT_EMBEDDING_MODEL); - assert.strictEqual(memoryKind.credentials.url, ':memory:'); - assert.strictEqual(memoryKind.pool.evictionRunIntervalMillis, 0); - assert.strictEqual(memoryKind.pool.min, 1); - assert.strictEqual(memoryKind.pool.max, 1); - }); + test( + 'inherits the AI-enabled sqlite kind for sqlite:memory', + { + skip: memoryKind?.kind !== 'sqlite' + }, + () => { + assert.strictEqual(memoryKind.impl, AI_SQLITE_IMPL); + assert.strictEqual(memoryKind.embedding.model, DEFAULT_EMBEDDING_MODEL); + assert.strictEqual(memoryKind.credentials.url, ':memory:'); + assert.strictEqual(memoryKind.pool.evictionRunIntervalMillis, 0); + assert.strictEqual(memoryKind.pool.min, 1); + assert.strictEqual(memoryKind.pool.max, 1); + } + ); before(async () => { db = await cds.connect.to( From be35eb3151f8b85981620feb967a949a10b67b7f Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 1 Sep 2026 14:58:13 +0200 Subject: [PATCH 37/37] test: gate local embedding sample on AI sqlite --- tests/recommendations.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/recommendations.test.js b/tests/recommendations.test.js index dfc40c4..7e52d58 100644 --- a/tests/recommendations.test.js +++ b/tests/recommendations.test.js @@ -236,7 +236,7 @@ describe('Row-level authorization', () => { describe('Local vector embeddings', () => { test('Bookshop exposes an embedding preview', async (t) => { - if (!cds.env.requires.db.embedding?.model) { + if (cds.env.requires.db.impl !== '@cap-js/ai/lib/sqlite/AISQLiteService.js') { t.skip('local SQLite embedding sample'); return; }