From 8a3d1879e942ce24a8f94c7c10a87c09d38192a5 Mon Sep 17 00:00:00 2001 From: D051920 Date: Thu, 30 Jul 2026 17:34:26 +0200 Subject: [PATCH 01/24] Sync wrapper for Sqlite for using ONNX embeddings function --- cds-plugin.js | 14 +- lib/vector_handling/index.js | 44 ++++ .../semantic-search/InferenceSession.js | 236 ++++++++++++++++++ .../semantic-search/embedding.js | 198 +++++++++++++++ .../semantic-search/model-utils.js | 107 ++++++++ package.json | 3 + 6 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 lib/vector_handling/index.js create mode 100644 lib/vector_handling/semantic-search/InferenceSession.js create mode 100644 lib/vector_handling/semantic-search/embedding.js create mode 100644 lib/vector_handling/semantic-search/model-utils.js diff --git a/cds-plugin.js b/cds-plugin.js index c648fa1..c8600e4 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -3,13 +3,25 @@ import cds from '@sap/cds'; import enhanceModelWithRecommendations from './lib/csn-enhancements/recommendations.js'; import registerHandlersForRecommendations from './lib/handlers/recommendations.js'; import registerMtxHandlers from './lib/mtx/index.js'; +import addSQLiteVectorSupport from './lib/vector_handling/index.js'; cds.on('compile.for.runtime', enhanceModelWithRecommendations); cds.on('compile.to.edmx', enhanceModelWithRecommendations); cds.on('served', async (services) => { for (const name in services) { - if (name === 'db') continue; + if (name === 'db') { + // Register vector support for SQLite + const db = await cds.connect.to('db'); + if (db.kind === 'sqlite') { + // Access the underlying database connection + const dbc = db.dbc; + if (dbc) { + await addSQLiteVectorSupport(dbc); + } + } + continue; + } // eslint-disable-next-line no-await-in-loop const srv = await cds.connect.to(name); registerHandlersForRecommendations(srv); diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js new file mode 100644 index 0000000..90680f0 --- /dev/null +++ b/lib/vector_handling/index.js @@ -0,0 +1,44 @@ +export default async function addSQLiteVectorSupport(dbc) { + let embedding; + try { + embedding = await import('./semantic-search/embedding.js'); + } catch (err) { + console.warn('Failed to load embedding module:', err.message); + return; + } + + try { + await embedding.createSession(); + } catch (err) { + console.warn('Failed to initialize embedding model, VECTOR_EMBEDDING will not be available:', err.message); + return; // Don't register the function if embedding model fails + } + + // Register VECTOR_EMBEDDING with 3 parameters (text, text_type, model_and_version) + dbc.function('VECTOR_EMBEDDING', { deterministic: true }, (text, text_type, model_and_version) => { + if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') + throw Error(`VECTOR_EMBEDDING called but text_type is ${text_type} and not DOCUMENT or QUERY`); + return generateVector(text, text_type, model_and_version, embedding); + }); + + // Register VECTOR_EMBEDDING with 4 parameters (including remote_source) + dbc.function('VECTOR_EMBEDDING', { deterministic: true }, (text, text_type, model_and_version, remote_source) => { + if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') + throw Error( + `VECTOR_EMBEDDING called for ${remote_source} but text_type is ${text_type} and not DOCUMENT or QUERY` + ); + return generateVector(text, text_type, model_and_version, embedding); + }); +} + +const model_dimensions = { + 'SAP_GXY.20250407': 384, // 768 actually + 'SAP_GXY.20240715': 384, // 768 actually +}; + +function generateVector(text, _, model_and_version, embedding) { + if (text) { + return JSON.stringify(Array.from(embedding.embedding(text).embedding)); + } + return JSON.stringify(new Array(model_dimensions[model_and_version] ?? 384).fill(0)); +} diff --git a/lib/vector_handling/semantic-search/InferenceSession.js b/lib/vector_handling/semantic-search/InferenceSession.js new file mode 100644 index 0000000..f4fe24b --- /dev/null +++ b/lib/vector_handling/semantic-search/InferenceSession.js @@ -0,0 +1,236 @@ +'use strict'; +// Copy from onnxruntime-common/dist/cjs/inference-session-impl.js and referenced files by it +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Adjusted to meet the needs of SQLite by making the run functions synchronous to avoid WorkerThreads +import ort from 'onnxruntime-common'; +import binding from 'onnxruntime-node/dist/binding.js'; + +class InferenceSession { + constructor(handler) { + this.handler = handler; + } + + run(feeds) { + const fetches = {}; + let options = {}; + // check inputs + if (typeof feeds !== 'object' || feeds === null || feeds instanceof ort.Tensor || Array.isArray(feeds)) { + throw new TypeError( + "'feeds' must be an object that use input names as keys and OnnxValue as corresponding values." + ); + } + // check if all inputs are in feed + for (const name of this.handler.inputNames) { + if (typeof feeds[name] === 'undefined') throw new Error(`input '${name}' is missing in 'feeds'.`); + } + // if no fetches is specified, we use the full output names list + for (const name of this.handler.outputNames) { + fetches[name] = null; + } + // feeds, fetches and options are prepared + const results = this.handler.run(feeds, fetches, options); + const returnValue = {}; + for (const key in results) { + if (Object.hasOwnProperty.call(results, key)) { + const result = results[key]; + if (result instanceof ort.Tensor) returnValue[key] = result; + else returnValue[key] = new ort.Tensor(result.type, result.data, result.dims); + } + } + return returnValue; + } + + static async create(arg0) { + let filePathOrUint8Array; + if (arg0 instanceof Uint8Array) filePathOrUint8Array = arg0; + else + throw Error( + 'Argument is not supported. Check original InferenceSession implementation if this adjustment needs to be adopted' + ); + + // resolve backend, update session options with validated EPs, and create session handler + const [backend, optionsWithValidatedEPs] = await resolveBackendAndExecutionProviders(); + const handler = await backend.createInferenceSessionHandler(filePathOrUint8Array, optionsWithValidatedEPs); + return new InferenceSession(handler); + } +} + +// Copy from onnxruntime-common/dist/cjs/backend-impl.js +async function resolveBackendAndExecutionProviders() { + const backends = new Map(); + const backendsList = listSupportedBackends(); + for (const backend of backendsList) { + backends.set(backend.name, { backend: onnxruntimeBackend }); + } + const backendNames = [...backends.keys()]; + // try to resolve and initialize all requested backends + let backend; + const errors = []; + const availableBackendNames = new Set(); + for (const backendName of backendNames) { + const resolveResult = await tryResolveAndInitializeBackend(backendName, backends); + if (typeof resolveResult === 'string') { + errors.push({ name: backendName, err: resolveResult }); + } else { + if (!backend) { + backend = resolveResult; + } + if (backend === resolveResult) { + availableBackendNames.add(backendName); + } + } + } + // if no backend is available, throw error. + if (!backend) { + throw new Error(`no available backend found. ERR: ${errors.map((e) => `[${e.name}] ${e.err}`).join(', ')}`); + } + return [ + backend, + new Proxy( + {}, + { + get: (target, prop) => { + if (prop === 'executionProviders') { + return []; + } + return Reflect.get(target, prop); + }, + } + ), + ]; +} + +async function tryResolveAndInitializeBackend(backendName, backends) { + const backendInfo = backends.get(backendName); + if (!backendInfo) { + return 'backend not found.'; + } + if (backendInfo.initialized) { + return backendInfo.backend; + } else if (backendInfo.aborted) { + return backendInfo.error; + } else { + const isInitializing = !!backendInfo.initPromise; + try { + if (!isInitializing) { + backendInfo.initPromise = backendInfo.backend.init(backendName); + } + await backendInfo.initPromise; + backendInfo.initialized = true; + return backendInfo.backend; + } catch (e) { + if (!isInitializing) { + backendInfo.error = `${e}`; + backendInfo.aborted = true; + } + return backendInfo.error; + } finally { + delete backendInfo.initPromise; + } + } +} + +// Copy from test/bookshop/node_modules/onnxruntime-node/dist/backend.js +const dataTypeStrings = [ + undefined, + 'float32', + 'uint8', + 'int8', + 'uint16', + 'int16', + 'int32', + 'int64', + 'string', + 'bool', + 'float16', + 'float64', + 'uint32', + 'uint64', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'uint4', + 'int4', +]; +class OnnxruntimeSessionHandler { + static inferenceSession = new WeakMap(); + constructor(pathOrBuffer, options) { + binding.initOrt(); + OnnxruntimeSessionHandler.inferenceSession.set(this, new binding.binding.InferenceSession()); + if (typeof pathOrBuffer === 'string') { + OnnxruntimeSessionHandler.inferenceSession.get(this).loadModel(pathOrBuffer, options); + } else { + OnnxruntimeSessionHandler.inferenceSession + .get(this) + .loadModel(pathOrBuffer.buffer, pathOrBuffer.byteOffset, pathOrBuffer.byteLength, options); + } + // prepare input/output names and metadata + this.inputNames = []; + this.outputNames = []; + this.inputMetadata = []; + this.outputMetadata = []; + // this function takes raw metadata from binding and returns a tuple of the following 2 items: + // - an array of string representing names + // - an array of converted InferenceSession.ValueMetadata + const fillNamesAndMetadata = (rawMetadata) => { + const names = []; + const metadata = []; + for (const m of rawMetadata) { + names.push(m.name); + if (!m.isTensor) { + metadata.push({ name: m.name, isTensor: false }); + } else { + const type = dataTypeStrings[m.type]; + if (type === undefined) { + throw new Error(`Unsupported data type: ${m.type}`); + } + const shape = []; + for (let i = 0; i < m.shape.length; ++i) { + const dim = m.shape[i]; + if (dim === -1) { + shape.push(m.symbolicDimensions[i]); + } else if (dim >= 0) { + shape.push(dim); + } else { + throw new Error(`Invalid dimension: ${dim}`); + } + } + metadata.push({ + name: m.name, + isTensor: m.isTensor, + type, + shape, + }); + } + } + return [names, metadata]; + }; + [this.inputNames, this.inputMetadata] = fillNamesAndMetadata( + OnnxruntimeSessionHandler.inferenceSession.get(this).inputMetadata + ); + [this.outputNames, this.outputMetadata] = fillNamesAndMetadata( + OnnxruntimeSessionHandler.inferenceSession.get(this).outputMetadata + ); + } + async dispose() { + OnnxruntimeSessionHandler.inferenceSession.get(this).dispose(); + } + run(feeds, fetches, options) { + return OnnxruntimeSessionHandler.inferenceSession.get(this).run(feeds, fetches, options); + } +} +class OnnxruntimeBackend { + init() {} + createInferenceSessionHandler(pathOrBuffer, options) { + return new OnnxruntimeSessionHandler(pathOrBuffer, options || {}); + } +} +const onnxruntimeBackend = new OnnxruntimeBackend(); +const listSupportedBackends = binding.binding.listSupportedBackends; + +export { InferenceSession }; diff --git a/lib/vector_handling/semantic-search/embedding.js b/lib/vector_handling/semantic-search/embedding.js new file mode 100644 index 0000000..c078260 --- /dev/null +++ b/lib/vector_handling/semantic-search/embedding.js @@ -0,0 +1,198 @@ +import os from 'os'; +import path from 'path'; +import ort from 'onnxruntime-node'; +import { + downloadModelIfNeeded, + forceRedownloadModel, + loadModelAndVocab, + preTokenize, + wordPieceTokenize, + validateTokenIds, +} from './model-utils.js'; + +const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; +const MODEL_DIR = path.join(getDataDir(), 'models', MODEL_NAME.replace('/', '_')); +const FILES = ['onnx/model.onnx', 'tokenizer.json', 'tokenizer_config.json']; + +async function initializeModelAndVocab() { + try { + const result = await loadModelAndVocab(MODEL_DIR); + session = result.session; + vocab = result.vocab; + } catch { + await forceRedownloadModel(MODEL_DIR, FILES); + await downloadModelIfNeeded(MODEL_DIR, FILES, MODEL_NAME); + const result = await loadModelAndVocab(MODEL_DIR); + session = result.session; + vocab = result.vocab; + } +} + +/** + * Main tokenization function that combines all steps + */ +function wordPieceTokenizer(text, vocab, maxLength = 512) { + const unkToken = '[UNK]'; + const clsToken = '[CLS]'; + const sepToken = '[SEP]'; + + 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); + + const tokens = [clsToken]; + const ids = [clsId]; + + for (const preToken of preTokens) { + const lowercaseToken = preToken.toLowerCase(); + const wordPieceTokens = wordPieceTokenize(lowercaseToken, 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 }]; + + // For longer texts, create overlapping chunks + const maxContentLength = maxLength - 2; + const overlap = Math.floor(maxContentLength * 0.1); + const chunkSize = maxContentLength - overlap; + + const chunks = []; + const contentTokens = tokens.slice(1, -1); + const contentIds = ids.slice(1, -1); + + for (let i = 0; i < contentTokens.length; i += chunkSize) { + const chunkTokens = [clsToken, ...contentTokens.slice(i, i + maxContentLength - 1), sepToken]; + const chunkIds = [clsId, ...contentIds.slice(i, i + maxContentLength - 1), 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 ort.Tensor('int64', inputIds, [1, validIds.length]); + const attentionTensor = new ort.Tensor('int64', attentionMask, [1, validIds.length]); + const tokenTypeTensor = new ort.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']; + 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 vocab = null; + +async function createSession() { + await downloadModelIfNeeded(MODEL_DIR, FILES, MODEL_NAME); + await initializeModelAndVocab(); +} + +function embedding(text) { + const chunks = wordPieceTokenizer(text, vocab); + 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); + 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); +} + +export default embedding; +export { embedding, createSession }; diff --git a/lib/vector_handling/semantic-search/model-utils.js b/lib/vector_handling/semantic-search/model-utils.js new file mode 100644 index 0000000..9f14f10 --- /dev/null +++ b/lib/vector_handling/semantic-search/model-utils.js @@ -0,0 +1,107 @@ +import { InferenceSession } from './InferenceSession.js'; +import fs from 'fs/promises'; +import { constants } from 'fs'; +import path from 'path'; + +// File operations +async function fileExists(filePath) { + try { + await fs.access(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function downloadFile(url, outputPath) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to download ${url}, status ${res.status} (${res.statusText})`); + await fs.writeFile(outputPath, await res.arrayBuffer()); +} + +// Model management +async function downloadModelIfNeeded(modelDir, files, modelName) { + await fs.mkdir(modelDir, { recursive: true }); + for (const file of files) { + const filePath = path.join(modelDir, path.basename(file)); + if (!(await fileExists(filePath))) + await downloadFile(`https://huggingface.co/${modelName}/resolve/main/${file}`, filePath); + } +} + +async function forceRedownloadModel(modelDir, files) { + for (const file of files) { + const filePath = path.join(modelDir, path.basename(file)); + if (await fileExists(filePath)) await fs.unlink(filePath).catch(() => {}); + } +} + +async function loadModelAndVocab(modelDir) { + const modelPath = path.join(modelDir, 'model.onnx'); + const vocabPath = path.join(modelDir, 'tokenizer.json'); + + const session = await InferenceSession.create(await fs.readFile(modelPath)); + const tokenizerJson = JSON.parse(await fs.readFile(vocabPath, 'utf-8')); + + if (!tokenizerJson.model || !tokenizerJson.model.vocab) + throw new Error('Invalid tokenizer structure: missing model.vocab'); + + const cleanVocab = new Map(); + for (const [token, id] of Object.entries(tokenizerJson.model.vocab)) { + if (typeof id === 'number') cleanVocab.set(token, id); + } + + return { session, vocab: cleanVocab }; +} + +// Tokenization helpers +function preTokenize(text) { + return text + .normalize('NFD') + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/[!\s]\p{P}[!\s]/gu, (p) => ` ${p} `) + .split(/\s/g) + .filter((a) => a); +} + +function wordPieceTokenize(token, vocab, unkToken = '[UNK]', maxInputCharsPerWord = 200) { + if (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; +} + +// Validate token IDs before conversion to BigInt +function validateTokenIds(ids) { + ids.forEach((id) => { + if (typeof id !== 'number' || isNaN(id) || !isFinite(id)) + throw new Error(`Invalid token ID detected: ${id} (type: ${typeof id})`); + }); + return ids; +} + +export { downloadModelIfNeeded, forceRedownloadModel, loadModelAndVocab, preTokenize, wordPieceTokenize, validateTokenIds }; diff --git a/package.json b/package.json index bfb70e5..a677c31 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "lib", "srv" ], + "dependencies": { + "onnxruntime-node": "^1.20.1" + }, "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0" From 1c58d996a4698147970fe4f25e2e216df79fc5aa Mon Sep 17 00:00:00 2001 From: D051920 Date: Thu, 30 Jul 2026 18:08:54 +0200 Subject: [PATCH 02/24] fix imple and add tests --- CHANGELOG.md | 12 ++++ cds-plugin.js | 56 +++++++++++---- lib/vector_handling/index.js | 6 +- .../semantic-search/InferenceSession.js | 7 +- .../semantic-search/model-utils.js | 3 +- tests/vector.test.js | 68 +++++++++++++++++++ 6 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 tests/vector.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 55fc7eb..ecf62a9 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 + +- SQLite vector support: `VECTOR_EMBEDDING` function using ONNX Runtime with `Xenova/all-MiniLM-L6-v2` model (384 dimensions) + - Automatically registers on SQLite database connections + - Downloads model on-demand from Hugging Face (~10MB, cached locally) + - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants + - Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions + - Synchronous execution suitable for SQLite user-defined functions + + ## Version 1.1.0 - 2026-07-20 diff --git a/cds-plugin.js b/cds-plugin.js index c8600e4..93d2095 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -5,23 +5,55 @@ import registerHandlersForRecommendations from './lib/handlers/recommendations.j import registerMtxHandlers from './lib/mtx/index.js'; import addSQLiteVectorSupport from './lib/vector_handling/index.js'; +// Extend SQLiteService class to add vector support +const originalSQLiteService = await (async () => { + try { + const mod = await import('@cap-js/sqlite'); + return mod.default || mod; + } catch (e) { + console.warn('[cds-ai] Failed to import @cap-js/sqlite:', e.message); + return null; + } +})(); + +if (originalSQLiteService) { + // Patch the factory getter on the prototype to add VECTOR_EMBEDDING function + const originalFactoryDescriptor = Object.getOwnPropertyDescriptor(originalSQLiteService.prototype, 'factory'); + + if (originalFactoryDescriptor && originalFactoryDescriptor.get) { + Object.defineProperty(originalSQLiteService.prototype, 'factory', { + get() { + const originalFactory = originalFactoryDescriptor.get.call(this); + const originalCreate = originalFactory.create; + + return { + ...originalFactory, + create: async (tenant) => { + const dbc = await originalCreate.call(originalFactory, tenant); + + // Register VECTOR_EMBEDDING function on this connection + try { + await addSQLiteVectorSupport(dbc); + } catch (err) { + console.warn('[cds-ai] Failed to register VECTOR_EMBEDDING:', err.message); + } + + return dbc; + }, + }; + }, + configurable: true, + }); + } +} + cds.on('compile.for.runtime', enhanceModelWithRecommendations); cds.on('compile.to.edmx', enhanceModelWithRecommendations); cds.on('served', async (services) => { + // Register other handlers for (const name in services) { - if (name === 'db') { - // Register vector support for SQLite - const db = await cds.connect.to('db'); - if (db.kind === 'sqlite') { - // Access the underlying database connection - const dbc = db.dbc; - if (dbc) { - await addSQLiteVectorSupport(dbc); - } - } - continue; - } + if (name === 'db') continue; // eslint-disable-next-line no-await-in-loop const srv = await cds.connect.to(name); registerHandlersForRecommendations(srv); diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js index 90680f0..ae01c45 100644 --- a/lib/vector_handling/index.js +++ b/lib/vector_handling/index.js @@ -3,15 +3,15 @@ export default async function addSQLiteVectorSupport(dbc) { try { embedding = await import('./semantic-search/embedding.js'); } catch (err) { - console.warn('Failed to load embedding module:', err.message); + console.warn('[cds-ai] Failed to load embedding module:', err.message); return; } try { await embedding.createSession(); } catch (err) { - console.warn('Failed to initialize embedding model, VECTOR_EMBEDDING will not be available:', err.message); - return; // Don't register the function if embedding model fails + console.warn('[cds-ai] Failed to initialize embedding model:', err.message); + return; } // Register VECTOR_EMBEDDING with 3 parameters (text, text_type, model_and_version) diff --git a/lib/vector_handling/semantic-search/InferenceSession.js b/lib/vector_handling/semantic-search/InferenceSession.js index f4fe24b..c397fce 100644 --- a/lib/vector_handling/semantic-search/InferenceSession.js +++ b/lib/vector_handling/semantic-search/InferenceSession.js @@ -1,10 +1,11 @@ -'use strict'; // Copy from onnxruntime-common/dist/cjs/inference-session-impl.js and referenced files by it // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // Adjusted to meet the needs of SQLite by making the run functions synchronous to avoid WorkerThreads -import ort from 'onnxruntime-common'; -import binding from 'onnxruntime-node/dist/binding.js'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const ort = require('onnxruntime-common'); +const binding = require('onnxruntime-node/dist/binding.js'); class InferenceSession { constructor(handler) { diff --git a/lib/vector_handling/semantic-search/model-utils.js b/lib/vector_handling/semantic-search/model-utils.js index 9f14f10..28df3f7 100644 --- a/lib/vector_handling/semantic-search/model-utils.js +++ b/lib/vector_handling/semantic-search/model-utils.js @@ -16,7 +16,8 @@ async function fileExists(filePath) { async function downloadFile(url, outputPath) { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to download ${url}, status ${res.status} (${res.statusText})`); - await fs.writeFile(outputPath, await res.arrayBuffer()); + const arrayBuffer = await res.arrayBuffer(); + await fs.writeFile(outputPath, Buffer.from(arrayBuffer)); } // Model management diff --git a/tests/vector.test.js b/tests/vector.test.js new file mode 100644 index 0000000..ebdc901 --- /dev/null +++ b/tests/vector.test.js @@ -0,0 +1,68 @@ +import path from 'path'; +import { describe, test, before } from 'node:test'; +import assert from 'node:assert'; +import cds from '@sap/cds'; +import cdsTest from '@cap-js/cds-test'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Initialize cds test environment +cdsTest(path.join(__dirname, './bookshop')); + +describe('Vector functions (SQLite only)', () => { + let db; + + before(async () => { + db = cds.db || (await cds.connect.to('db')); + }); + + describe('VECTOR_EMBEDDING', () => { + test('computes embedding with ONNX model', async () => { + if (db?.kind !== 'sqlite') { + console.log('Skipping - not SQLite'); + return; + } + + const result = await db.run( + `SELECT VECTOR_EMBEDDING(title, 'DOCUMENT', 'SAP_GXY.20250407') as embedding + FROM sap_capire_bookshop_Books LIMIT 1` + ); + + const embedding = JSON.parse(result[0].embedding); + 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 () => { + if (db?.kind !== 'sqlite') return; + + const result = await db.run( + `SELECT + VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407') as e1, + VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407') as e2` + ); + + assert.strictEqual(result[0].e1, result[0].e2, 'Same input should produce identical embeddings'); + }); + + test('different inputs produce different outputs', async () => { + if (db?.kind !== 'sqlite') return; + + const result = await db.run( + `SELECT + VECTOR_EMBEDDING('hello world', 'DOCUMENT', 'SAP_GXY.20250407') as e1, + VECTOR_EMBEDDING('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407') as e2` + ); + + assert.notStrictEqual(result[0].e1, result[0].e2, 'Different inputs should produce different embeddings'); + }); + }); +}); From d25fb1b8cf00a018f19329d6edafaa859244ccbd Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:18:14 +0200 Subject: [PATCH 03/24] add semantic tests --- tests/vector.test.js | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/vector.test.js b/tests/vector.test.js index ebdc901..42e573d 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -64,5 +64,54 @@ describe('Vector functions (SQLite only)', () => { assert.notStrictEqual(result[0].e1, result[0].e2, 'Different inputs should produce different embeddings'); }); + + test('semantically similar sentences produce similar vectors', async () => { + if (db?.kind !== 'sqlite') return; + + const result = await db.run( + `SELECT + VECTOR_EMBEDDING('I love programming', 'DOCUMENT', 'SAP_GXY.20250407') as e1, + VECTOR_EMBEDDING('I enjoy coding', 'DOCUMENT', 'SAP_GXY.20250407') as e2` + ); + + const v1 = JSON.parse(result[0].e1); + const v2 = JSON.parse(result[0].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 () => { + if (db?.kind !== 'sqlite') return; + + const result = await db.run( + `SELECT + VECTOR_EMBEDDING('The cat sat on the mat', 'DOCUMENT', 'SAP_GXY.20250407') as e1, + VECTOR_EMBEDDING('Quantum physics is fascinating', 'DOCUMENT', 'SAP_GXY.20250407') as e2` + ); + + const v1 = JSON.parse(result[0].e1); + const v2 = JSON.parse(result[0].e2); + + const similarity = cosineSimilarity(v1, v2); + assert.ok(similarity < 0.1, `Semantically different sentences should have low cosine similarity (got ${similarity.toFixed(3)})`); + }); }); }); + +// 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 1df64f0c6b372236651a8c415030efc0ee3166ff Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:27:42 +0200 Subject: [PATCH 04/24] use LOG --- cds-plugin.js | 6 ++++-- lib/vector_handling/index.js | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index 93d2095..a23816f 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -5,13 +5,15 @@ import registerHandlersForRecommendations from './lib/handlers/recommendations.j import registerMtxHandlers from './lib/mtx/index.js'; import addSQLiteVectorSupport from './lib/vector_handling/index.js'; +const LOG = cds.log('@cap-js/ai'); + // Extend SQLiteService class to add vector support const originalSQLiteService = await (async () => { try { const mod = await import('@cap-js/sqlite'); return mod.default || mod; } catch (e) { - console.warn('[cds-ai] Failed to import @cap-js/sqlite:', e.message); + LOG.warn('Failed to import @cap-js/sqlite:', e.message); return null; } })(); @@ -35,7 +37,7 @@ if (originalSQLiteService) { try { await addSQLiteVectorSupport(dbc); } catch (err) { - console.warn('[cds-ai] Failed to register VECTOR_EMBEDDING:', err.message); + LOG.warn('Failed to register VECTOR_EMBEDDING:', err.message); } return dbc; diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js index ae01c45..a66b33f 100644 --- a/lib/vector_handling/index.js +++ b/lib/vector_handling/index.js @@ -1,16 +1,20 @@ +import cds from '@sap/cds'; + +const LOG = cds.log('@cap-js/ai'); + export default async function addSQLiteVectorSupport(dbc) { let embedding; try { embedding = await import('./semantic-search/embedding.js'); } catch (err) { - console.warn('[cds-ai] Failed to load embedding module:', err.message); + LOG.warn('Failed to load embedding module:', err.message); return; } try { await embedding.createSession(); } catch (err) { - console.warn('[cds-ai] Failed to initialize embedding model:', err.message); + LOG.warn('Failed to initialize embedding model:', err.message); return; } From 630893eca9a6528a2fee00e32524c5367e5303e3 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:50:20 +0200 Subject: [PATCH 05/24] test 4 params --- CHANGELOG.md | 3 ++- lib/vector_handling/index.js | 4 ++-- tests/vector.test.js | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf62a9..e593b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,10 @@ - SQLite vector support: `VECTOR_EMBEDDING` function using ONNX Runtime with `Xenova/all-MiniLM-L6-v2` model (384 dimensions) - Automatically registers on SQLite database connections - Downloads model on-demand from Hugging Face (~10MB, cached locally) - - Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants + - 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 diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js index a66b33f..6de50c0 100644 --- a/lib/vector_handling/index.js +++ b/lib/vector_handling/index.js @@ -36,8 +36,8 @@ export default async function addSQLiteVectorSupport(dbc) { } const model_dimensions = { - 'SAP_GXY.20250407': 384, // 768 actually - 'SAP_GXY.20240715': 384, // 768 actually + 'SAP_GXY.20250407': 384, + 'SAP_GXY.20240715': 384, }; function generateVector(text, _, model_and_version, embedding) { diff --git a/tests/vector.test.js b/tests/vector.test.js index 42e573d..fad3a80 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -96,6 +96,21 @@ describe('Vector functions (SQLite only)', () => { const similarity = cosineSimilarity(v1, v2); assert.ok(similarity < 0.1, `Semantically different sentences should have low cosine similarity (got ${similarity.toFixed(3)})`); }); + + test('4-parameter version with remote_source works', async () => { + if (db?.kind !== 'sqlite') return; + + const result = await db.run( + `SELECT VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407', 'MY_GENAI_HUB_REMOTE_SOURCE') as embedding` + ); + + const embedding = JSON.parse(result[0].embedding); + assert.ok(Array.isArray(embedding), 'Embedding should be an array'); + assert.strictEqual(embedding.length, 384, 'Embedding should have 384 dimensions'); + + // Note: In real HANA, remote_source would connect to SAP AI Core. + // In our SQLite implementation, we ignore it and use local ONNX model. + }); }); }); From 3c2ebeb82dc44312dd6a265fef6871dcfc38e2b9 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:58:15 +0200 Subject: [PATCH 06/24] small fixes --- lib/vector_handling/semantic-search/embedding.js | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/vector_handling/semantic-search/embedding.js b/lib/vector_handling/semantic-search/embedding.js index c078260..b112a47 100644 --- a/lib/vector_handling/semantic-search/embedding.js +++ b/lib/vector_handling/semantic-search/embedding.js @@ -172,6 +172,7 @@ function embedding(text) { 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; } diff --git a/package.json b/package.json index a677c31..e1de060 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lib", "srv" ], - "dependencies": { + "optionalDependencies": { "onnxruntime-node": "^1.20.1" }, "devDependencies": { From af1d63f62acaa20da9dc3b4e254943b04f593655 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 11:05:36 +0200 Subject: [PATCH 07/24] fix: address PR bot comments - add division by zero guard and fix lint errors --- lib/vector_handling/semantic-search/InferenceSession.js | 1 + lib/vector_handling/semantic-search/model-utils.js | 5 +++++ package.json | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/vector_handling/semantic-search/InferenceSession.js b/lib/vector_handling/semantic-search/InferenceSession.js index c397fce..6d3f40e 100644 --- a/lib/vector_handling/semantic-search/InferenceSession.js +++ b/lib/vector_handling/semantic-search/InferenceSession.js @@ -70,6 +70,7 @@ async function resolveBackendAndExecutionProviders() { const errors = []; const availableBackendNames = new Set(); for (const backendName of backendNames) { + // eslint-disable-next-line no-await-in-loop const resolveResult = await tryResolveAndInitializeBackend(backendName, backends); if (typeof resolveResult === 'string') { errors.push({ name: backendName, err: resolveResult }); diff --git a/lib/vector_handling/semantic-search/model-utils.js b/lib/vector_handling/semantic-search/model-utils.js index 28df3f7..4594ce4 100644 --- a/lib/vector_handling/semantic-search/model-utils.js +++ b/lib/vector_handling/semantic-search/model-utils.js @@ -23,16 +23,21 @@ async function downloadFile(url, outputPath) { // Model management async function downloadModelIfNeeded(modelDir, files, modelName) { await fs.mkdir(modelDir, { recursive: true }); + // eslint-disable-next-line no-await-in-loop for (const file of files) { const filePath = path.join(modelDir, path.basename(file)); + // eslint-disable-next-line no-await-in-loop if (!(await fileExists(filePath))) + // eslint-disable-next-line no-await-in-loop await downloadFile(`https://huggingface.co/${modelName}/resolve/main/${file}`, filePath); } } async function forceRedownloadModel(modelDir, files) { + // eslint-disable-next-line no-await-in-loop for (const file of files) { const filePath = path.join(modelDir, path.basename(file)); + // eslint-disable-next-line no-await-in-loop if (await fileExists(filePath)) await fs.unlink(filePath).catch(() => {}); } } diff --git a/package.json b/package.json index e1de060..a677c31 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lib", "srv" ], - "optionalDependencies": { + "dependencies": { "onnxruntime-node": "^1.20.1" }, "devDependencies": { From d31c1a1a4d1e310b1e9c6ea4a5e210c8ce2c7e60 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 11:08:34 +0200 Subject: [PATCH 08/24] chore: run prettier formatting --- cds-plugin.js | 9 +++-- lib/vector_handling/index.js | 36 ++++++++++++------- .../semantic-search/InferenceSession.js | 27 +++++++++----- .../semantic-search/embedding.js | 8 ++--- .../semantic-search/model-utils.js | 32 +++++++++++------ tests/vector.test.js | 22 +++++++++--- 6 files changed, 91 insertions(+), 43 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index a23816f..b7f61cf 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -20,7 +20,10 @@ const originalSQLiteService = await (async () => { if (originalSQLiteService) { // Patch the factory getter on the prototype to add VECTOR_EMBEDDING function - const originalFactoryDescriptor = Object.getOwnPropertyDescriptor(originalSQLiteService.prototype, 'factory'); + const originalFactoryDescriptor = Object.getOwnPropertyDescriptor( + originalSQLiteService.prototype, + 'factory' + ); if (originalFactoryDescriptor && originalFactoryDescriptor.get) { Object.defineProperty(originalSQLiteService.prototype, 'factory', { @@ -41,10 +44,10 @@ if (originalSQLiteService) { } return dbc; - }, + } }; }, - configurable: true, + configurable: true }); } } diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js index 6de50c0..4bff745 100644 --- a/lib/vector_handling/index.js +++ b/lib/vector_handling/index.js @@ -19,25 +19,35 @@ export default async function addSQLiteVectorSupport(dbc) { } // Register VECTOR_EMBEDDING with 3 parameters (text, text_type, model_and_version) - dbc.function('VECTOR_EMBEDDING', { deterministic: true }, (text, text_type, model_and_version) => { - if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') - throw Error(`VECTOR_EMBEDDING called but text_type is ${text_type} and not DOCUMENT or QUERY`); - return generateVector(text, text_type, model_and_version, embedding); - }); + dbc.function( + 'VECTOR_EMBEDDING', + { deterministic: true }, + (text, text_type, model_and_version) => { + if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') + throw Error( + `VECTOR_EMBEDDING called but text_type is ${text_type} and not DOCUMENT or QUERY` + ); + return generateVector(text, text_type, model_and_version, embedding); + } + ); // Register VECTOR_EMBEDDING with 4 parameters (including remote_source) - dbc.function('VECTOR_EMBEDDING', { deterministic: true }, (text, text_type, model_and_version, remote_source) => { - if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') - throw Error( - `VECTOR_EMBEDDING called for ${remote_source} but text_type is ${text_type} and not DOCUMENT or QUERY` - ); - return generateVector(text, text_type, model_and_version, embedding); - }); + dbc.function( + 'VECTOR_EMBEDDING', + { deterministic: true }, + (text, text_type, model_and_version, remote_source) => { + if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') + throw Error( + `VECTOR_EMBEDDING called for ${remote_source} but text_type is ${text_type} and not DOCUMENT or QUERY` + ); + return generateVector(text, text_type, model_and_version, embedding); + } + ); } const model_dimensions = { 'SAP_GXY.20250407': 384, - 'SAP_GXY.20240715': 384, + 'SAP_GXY.20240715': 384 }; function generateVector(text, _, model_and_version, embedding) { diff --git a/lib/vector_handling/semantic-search/InferenceSession.js b/lib/vector_handling/semantic-search/InferenceSession.js index 6d3f40e..5d52e09 100644 --- a/lib/vector_handling/semantic-search/InferenceSession.js +++ b/lib/vector_handling/semantic-search/InferenceSession.js @@ -16,14 +16,20 @@ class InferenceSession { const fetches = {}; let options = {}; // check inputs - if (typeof feeds !== 'object' || feeds === null || feeds instanceof ort.Tensor || Array.isArray(feeds)) { + if ( + typeof feeds !== 'object' || + feeds === null || + feeds instanceof ort.Tensor || + Array.isArray(feeds) + ) { throw new TypeError( "'feeds' must be an object that use input names as keys and OnnxValue as corresponding values." ); } // check if all inputs are in feed for (const name of this.handler.inputNames) { - if (typeof feeds[name] === 'undefined') throw new Error(`input '${name}' is missing in 'feeds'.`); + if (typeof feeds[name] === 'undefined') + throw new Error(`input '${name}' is missing in 'feeds'.`); } // if no fetches is specified, we use the full output names list for (const name of this.handler.outputNames) { @@ -52,7 +58,10 @@ class InferenceSession { // resolve backend, update session options with validated EPs, and create session handler const [backend, optionsWithValidatedEPs] = await resolveBackendAndExecutionProviders(); - const handler = await backend.createInferenceSessionHandler(filePathOrUint8Array, optionsWithValidatedEPs); + const handler = await backend.createInferenceSessionHandler( + filePathOrUint8Array, + optionsWithValidatedEPs + ); return new InferenceSession(handler); } } @@ -85,7 +94,9 @@ async function resolveBackendAndExecutionProviders() { } // if no backend is available, throw error. if (!backend) { - throw new Error(`no available backend found. ERR: ${errors.map((e) => `[${e.name}] ${e.err}`).join(', ')}`); + throw new Error( + `no available backend found. ERR: ${errors.map((e) => `[${e.name}] ${e.err}`).join(', ')}` + ); } return [ backend, @@ -97,9 +108,9 @@ async function resolveBackendAndExecutionProviders() { return []; } return Reflect.get(target, prop); - }, + } } - ), + ) ]; } @@ -157,7 +168,7 @@ const dataTypeStrings = [ undefined, undefined, 'uint4', - 'int4', + 'int4' ]; class OnnxruntimeSessionHandler { static inferenceSession = new WeakMap(); @@ -206,7 +217,7 @@ class OnnxruntimeSessionHandler { name: m.name, isTensor: m.isTensor, type, - shape, + shape }); } } diff --git a/lib/vector_handling/semantic-search/embedding.js b/lib/vector_handling/semantic-search/embedding.js index b112a47..ff089a0 100644 --- a/lib/vector_handling/semantic-search/embedding.js +++ b/lib/vector_handling/semantic-search/embedding.js @@ -7,7 +7,7 @@ import { loadModelAndVocab, preTokenize, wordPieceTokenize, - validateTokenIds, + validateTokenIds } from './model-utils.js'; const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; @@ -80,7 +80,7 @@ function wordPieceTokenizer(text, vocab, maxLength = 512) { chunks.push({ tokens: chunkTokens, - ids: chunkIds, + ids: chunkIds }); } @@ -108,7 +108,7 @@ function processChunkedEmbeddings(chunks, session) { const feeds = { input_ids: inputTensor, attention_mask: attentionTensor, - token_type_ids: tokenTypeTensor, + token_type_ids: tokenTypeTensor }; const results = session.run(feeds); @@ -163,7 +163,7 @@ function embedding(text) { value: vector, writable: true, configurable: true, - enumerable: false, + enumerable: false }); function normalizeEmbedding(embedding) { diff --git a/lib/vector_handling/semantic-search/model-utils.js b/lib/vector_handling/semantic-search/model-utils.js index 4594ce4..da9ef4e 100644 --- a/lib/vector_handling/semantic-search/model-utils.js +++ b/lib/vector_handling/semantic-search/model-utils.js @@ -15,7 +15,8 @@ async function fileExists(filePath) { async function downloadFile(url, outputPath) { const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to download ${url}, status ${res.status} (${res.statusText})`); + if (!res.ok) + throw new Error(`Failed to download ${url}, status ${res.status} (${res.statusText})`); const arrayBuffer = await res.arrayBuffer(); await fs.writeFile(outputPath, Buffer.from(arrayBuffer)); } @@ -62,15 +63,17 @@ async function loadModelAndVocab(modelDir) { // Tokenization helpers function preTokenize(text) { - return text - .normalize('NFD') - // eslint-disable-next-line no-control-regex - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '') - .replace(/\s+/g, ' ') - .trim() - .replace(/[!\s]\p{P}[!\s]/gu, (p) => ` ${p} `) - .split(/\s/g) - .filter((a) => a); + return ( + text + .normalize('NFD') + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/[!\s]\p{P}[!\s]/gu, (p) => ` ${p} `) + .split(/\s/g) + .filter((a) => a) + ); } function wordPieceTokenize(token, vocab, unkToken = '[UNK]', maxInputCharsPerWord = 200) { @@ -110,4 +113,11 @@ function validateTokenIds(ids) { return ids; } -export { downloadModelIfNeeded, forceRedownloadModel, loadModelAndVocab, preTokenize, wordPieceTokenize, validateTokenIds }; +export { + downloadModelIfNeeded, + forceRedownloadModel, + loadModelAndVocab, + preTokenize, + wordPieceTokenize, + validateTokenIds +}; diff --git a/tests/vector.test.js b/tests/vector.test.js index fad3a80..c60cb36 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -50,7 +50,11 @@ describe('Vector functions (SQLite only)', () => { VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407') as e2` ); - assert.strictEqual(result[0].e1, result[0].e2, 'Same input should produce identical embeddings'); + assert.strictEqual( + result[0].e1, + result[0].e2, + 'Same input should produce identical embeddings' + ); }); test('different inputs produce different outputs', async () => { @@ -62,7 +66,11 @@ describe('Vector functions (SQLite only)', () => { VECTOR_EMBEDDING('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407') as e2` ); - assert.notStrictEqual(result[0].e1, result[0].e2, 'Different inputs should produce different embeddings'); + assert.notStrictEqual( + result[0].e1, + result[0].e2, + 'Different inputs should produce different embeddings' + ); }); test('semantically similar sentences produce similar vectors', async () => { @@ -78,7 +86,10 @@ describe('Vector functions (SQLite only)', () => { const v2 = JSON.parse(result[0].e2); const similarity = cosineSimilarity(v1, v2); - assert.ok(similarity > 0.8, `Semantically similar sentences should have high cosine similarity (got ${similarity.toFixed(3)})`); + 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 () => { @@ -94,7 +105,10 @@ describe('Vector functions (SQLite only)', () => { const v2 = JSON.parse(result[0].e2); const similarity = cosineSimilarity(v1, v2); - assert.ok(similarity < 0.1, `Semantically different sentences should have low cosine similarity (got ${similarity.toFixed(3)})`); + assert.ok( + similarity < 0.1, + `Semantically different sentences should have low cosine similarity (got ${similarity.toFixed(3)})` + ); }); test('4-parameter version with remote_source works', async () => { From d4d812776c44e982cf4827d6990cf9cd69afb7a0 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 11:47:37 +0200 Subject: [PATCH 09/24] fix tests --- cds-plugin.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index b7f61cf..8cbc598 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -8,17 +8,16 @@ import addSQLiteVectorSupport from './lib/vector_handling/index.js'; const LOG = cds.log('@cap-js/ai'); // Extend SQLiteService class to add vector support -const originalSQLiteService = await (async () => { +(async () => { + let originalSQLiteService; try { const mod = await import('@cap-js/sqlite'); - return mod.default || mod; + originalSQLiteService = mod.default || mod; } catch (e) { LOG.warn('Failed to import @cap-js/sqlite:', e.message); - return null; + return; } -})(); -if (originalSQLiteService) { // Patch the factory getter on the prototype to add VECTOR_EMBEDDING function const originalFactoryDescriptor = Object.getOwnPropertyDescriptor( originalSQLiteService.prototype, @@ -50,7 +49,7 @@ if (originalSQLiteService) { configurable: true }); } -} +})(); cds.on('compile.for.runtime', enhanceModelWithRecommendations); cds.on('compile.to.edmx', enhanceModelWithRecommendations); From 01fce91eb92afa5d3bd4d3ee752a571f11f62baf Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 12:05:04 +0200 Subject: [PATCH 10/24] dix duplicated function registration --- lib/vector_handling/index.js | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js index 4bff745..17ae736 100644 --- a/lib/vector_handling/index.js +++ b/lib/vector_handling/index.js @@ -18,28 +18,15 @@ export default async function addSQLiteVectorSupport(dbc) { return; } - // Register VECTOR_EMBEDDING with 3 parameters (text, text_type, model_and_version) + // Register VECTOR_EMBEDDING function + // better-sqlite3 does NOT support arity-based dispatch - the second registration + // overwrites the first. We register a variadic function that handles both 3 and 4 parameters. + // Note: remote_source (4th param) is accepted for HANA API compatibility but ignored. + // SQLite always uses the local ONNX model and cannot connect to remote embedding services. dbc.function( 'VECTOR_EMBEDDING', - { deterministic: true }, - (text, text_type, model_and_version) => { - if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') - throw Error( - `VECTOR_EMBEDDING called but text_type is ${text_type} and not DOCUMENT or QUERY` - ); - return generateVector(text, text_type, model_and_version, embedding); - } - ); - - // Register VECTOR_EMBEDDING with 4 parameters (including remote_source) - dbc.function( - 'VECTOR_EMBEDDING', - { deterministic: true }, + { deterministic: true, varargs: true }, (text, text_type, model_and_version, remote_source) => { - if (text_type !== 'DOCUMENT' && text_type !== 'QUERY') - throw Error( - `VECTOR_EMBEDDING called for ${remote_source} but text_type is ${text_type} and not DOCUMENT or QUERY` - ); return generateVector(text, text_type, model_and_version, embedding); } ); From 0959bd2b19c5bce665ac6e6c7fed1f1e7e00160f Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 12:11:16 +0200 Subject: [PATCH 11/24] more frixes --- lib/vector_handling/index.js | 37 ++++++++++++++----- .../semantic-search/embedding.js | 8 ++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js index 17ae736..7838ba2 100644 --- a/lib/vector_handling/index.js +++ b/lib/vector_handling/index.js @@ -2,19 +2,36 @@ import cds from '@sap/cds'; const LOG = cds.log('@cap-js/ai'); -export default async function addSQLiteVectorSupport(dbc) { - let embedding; - try { - embedding = await import('./semantic-search/embedding.js'); - } catch (err) { - LOG.warn('Failed to load embedding module:', err.message); - return; +// Initialize embedding session once (shared across all connections) +let embeddingModule; +let sessionInitPromise; + +async function ensureSessionInitialized() { + if (!embeddingModule) { + try { + embeddingModule = await import('./semantic-search/embedding.js'); + } catch (err) { + LOG.warn('Failed to load embedding module:', err.message); + throw err; + } } + if (!sessionInitPromise) { + sessionInitPromise = embeddingModule.createSession().catch((err) => { + LOG.warn('Failed to initialize embedding model:', err.message); + sessionInitPromise = null; // Reset on failure to allow retry + throw err; + }); + } + + return sessionInitPromise; +} + +export default async function addSQLiteVectorSupport(dbc) { try { - await embedding.createSession(); + await ensureSessionInitialized(); } catch (err) { - LOG.warn('Failed to initialize embedding model:', err.message); + // Session initialization failed, skip registration return; } @@ -27,7 +44,7 @@ export default async function addSQLiteVectorSupport(dbc) { 'VECTOR_EMBEDDING', { deterministic: true, varargs: true }, (text, text_type, model_and_version, remote_source) => { - return generateVector(text, text_type, model_and_version, embedding); + return generateVector(text, text_type, model_and_version, embeddingModule); } ); } diff --git a/lib/vector_handling/semantic-search/embedding.js b/lib/vector_handling/semantic-search/embedding.js index ff089a0..8a9402b 100644 --- a/lib/vector_handling/semantic-search/embedding.js +++ b/lib/vector_handling/semantic-search/embedding.js @@ -113,6 +113,10 @@ function processChunkedEmbeddings(chunks, session) { 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; @@ -155,6 +159,10 @@ async function createSession() { } function embedding(text) { + if (!session || !vocab) + throw new Error( + 'Embedding session not initialized. Call createSession() before using embedding().' + ); const chunks = wordPieceTokenizer(text, vocab); const vector = normalizeEmbedding(processChunkedEmbeddings(chunks, session)); From 05b626ff788f4027f18475ca41b2f35096623fe4 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 10:50:18 +0200 Subject: [PATCH 12/24] export vector_embedding directly --- README.md | 73 ++++++++++++++++ cds-plugin.js | 45 ---------- lib/vector_handling/sync-wrapper.js | 51 +++++++++++ package.json | 4 + tests/vector.test.js | 128 ++++++++++------------------ 5 files changed, 175 insertions(+), 126 deletions(-) create mode 100644 lib/vector_handling/sync-wrapper.js diff --git a/README.md b/README.md index 7a2de53..fb55ed3 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,79 @@ resources: type: org.cloudfoundry.managed-service ``` +### 3. Vector Embedding API for Plugin Integration + +The `@cap-js/ai` plugin exports a standalone vector embedding function that can be used by other plugins (like `@cap-js/sqlite`) to generate embeddings using an ONNX model. + +#### Usage + +```javascript +import { vector_embedding } from '@cap-js/ai/vector-embedding'; + +// Model initializes automatically on import - just use it +const embeddingJSON = vector_embedding('Hello world', 'DOCUMENT', 'SAP_GXY.20250407'); +const embedding = JSON.parse(embeddingJSON); // Array of 384 float values +``` + +#### Function Signature + +```typescript +function vector_embedding( + text: string | null, + text_type: string, + model_and_version: string +): string +``` + +**Parameters:** +- `text` - Text to embed (returns zero vector if null or empty) +- `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:** +- **Auto-initialization**: ONNX model loads automatically when module is imported (top-level await) +- **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:** +- Throws if ONNX model failed to load during import +- Throws if embedding generation fails +- Import errors can be caught to detect if AI plugin is available + +**Example Integration (Database Plugin):** + +```javascript +// In a database plugin like @cap-js/sqlite +let aiEmbedding = null; +try { + // ONNX model initializes automatically via top-level await + const aiPlugin = await import('@cap-js/ai/vector-embedding'); + aiEmbedding = aiPlugin.vector_embedding; +} catch (err) { + // AI plugin not available, use fallback +} + +// Register SQL function +dbc.function('VECTOR_EMBEDDING', { deterministic: true }, (text, text_type, model) => { + if (text == null) return null; + + if (aiEmbedding) { + try { + return aiEmbedding(text, text_type, model); + } catch (err) { + // Fall back to alternative implementation + } + } + + // Fallback implementation + return JSON.stringify(hashBasedEmbedding(text)); +}); +``` + ## Test the plugin locally diff --git a/cds-plugin.js b/cds-plugin.js index 8cbc598..6286746 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -3,54 +3,9 @@ import cds from '@sap/cds'; import enhanceModelWithRecommendations from './lib/csn-enhancements/recommendations.js'; import registerHandlersForRecommendations from './lib/handlers/recommendations.js'; import registerMtxHandlers from './lib/mtx/index.js'; -import addSQLiteVectorSupport from './lib/vector_handling/index.js'; const LOG = cds.log('@cap-js/ai'); -// Extend SQLiteService class to add vector support -(async () => { - let originalSQLiteService; - try { - const mod = await import('@cap-js/sqlite'); - originalSQLiteService = mod.default || mod; - } catch (e) { - LOG.warn('Failed to import @cap-js/sqlite:', e.message); - return; - } - - // Patch the factory getter on the prototype to add VECTOR_EMBEDDING function - const originalFactoryDescriptor = Object.getOwnPropertyDescriptor( - originalSQLiteService.prototype, - 'factory' - ); - - if (originalFactoryDescriptor && originalFactoryDescriptor.get) { - Object.defineProperty(originalSQLiteService.prototype, 'factory', { - get() { - const originalFactory = originalFactoryDescriptor.get.call(this); - const originalCreate = originalFactory.create; - - return { - ...originalFactory, - create: async (tenant) => { - const dbc = await originalCreate.call(originalFactory, tenant); - - // Register VECTOR_EMBEDDING function on this connection - try { - await addSQLiteVectorSupport(dbc); - } catch (err) { - LOG.warn('Failed to register VECTOR_EMBEDDING:', err.message); - } - - return dbc; - } - }; - }, - configurable: true - }); - } -})(); - cds.on('compile.for.runtime', enhanceModelWithRecommendations); cds.on('compile.to.edmx', enhanceModelWithRecommendations); diff --git a/lib/vector_handling/sync-wrapper.js b/lib/vector_handling/sync-wrapper.js new file mode 100644 index 0000000..490df79 --- /dev/null +++ b/lib/vector_handling/sync-wrapper.js @@ -0,0 +1,51 @@ +import cds from '@sap/cds'; + +const LOG = cds.log('@cap-js/ai'); + +// Auto-initialize on module load +let embeddingModule; +let initializationError; + +try { + embeddingModule = await import('./semantic-search/embedding.js'); + await embeddingModule.createSession(); + LOG?.info?.('Vector embedding ONNX model initialized'); +} catch (err) { + LOG.warn('Failed to initialize embedding model:', err.message); + initializationError = err; +} + +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 (initializationError) { + throw new Error( + `Embedding module failed to initialize: ${initializationError.message}` + ); + } + + if (!embeddingModule) { + throw new Error('Embedding module not available'); + } + + 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 { vector_embedding }; diff --git a/package.json b/package.json index a677c31..b9d0779 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", "main": "cds-plugin.js", + "exports": { + ".": "./cds-plugin.js", + "./vector-embedding": "./lib/vector_handling/sync-wrapper.js" + }, "scripts": { "lint": "npx -y eslint@10 .", "test": "node --test tests/*.test.js", diff --git a/tests/vector.test.js b/tests/vector.test.js index c60cb36..b8e407a 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -1,36 +1,13 @@ -import path from 'path'; -import { describe, test, before } from 'node:test'; +import { describe, test } from 'node:test'; import assert from 'node:assert'; -import cds from '@sap/cds'; -import cdsTest from '@cap-js/cds-test'; -import { fileURLToPath } from 'url'; +import { vector_embedding } from '../lib/vector_handling/sync-wrapper.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Initialize cds test environment -cdsTest(path.join(__dirname, './bookshop')); - -describe('Vector functions (SQLite only)', () => { - let db; - - before(async () => { - db = cds.db || (await cds.connect.to('db')); - }); - - describe('VECTOR_EMBEDDING', () => { +describe('Vector embedding function (standalone)', () => { + describe('vector_embedding', () => { test('computes embedding with ONNX model', async () => { - if (db?.kind !== 'sqlite') { - console.log('Skipping - not SQLite'); - return; - } - - const result = await db.run( - `SELECT VECTOR_EMBEDDING(title, 'DOCUMENT', 'SAP_GXY.20250407') as embedding - FROM sap_capire_bookshop_Books LIMIT 1` - ); + const result = vector_embedding('Hello world', 'DOCUMENT', 'SAP_GXY.20250407'); - const embedding = JSON.parse(result[0].embedding); + 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'); @@ -42,48 +19,25 @@ describe('Vector functions (SQLite only)', () => { }); test('deterministic - same input produces same output', async () => { - if (db?.kind !== 'sqlite') return; + const e1 = vector_embedding('test text', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('test text', 'DOCUMENT', 'SAP_GXY.20250407'); - const result = await db.run( - `SELECT - VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407') as e1, - VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407') as e2` - ); - - assert.strictEqual( - result[0].e1, - result[0].e2, - 'Same input should produce identical embeddings' - ); + assert.strictEqual(e1, e2, 'Same input should produce identical embeddings'); }); test('different inputs produce different outputs', async () => { - if (db?.kind !== 'sqlite') return; + const e1 = vector_embedding('hello world', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407'); - const result = await db.run( - `SELECT - VECTOR_EMBEDDING('hello world', 'DOCUMENT', 'SAP_GXY.20250407') as e1, - VECTOR_EMBEDDING('goodbye world', 'DOCUMENT', 'SAP_GXY.20250407') as e2` - ); - - assert.notStrictEqual( - result[0].e1, - result[0].e2, - 'Different inputs should produce different embeddings' - ); + assert.notStrictEqual(e1, e2, 'Different inputs should produce different embeddings'); }); test('semantically similar sentences produce similar vectors', async () => { - if (db?.kind !== 'sqlite') return; + const e1 = vector_embedding('I love programming', 'DOCUMENT', 'SAP_GXY.20250407'); + const e2 = vector_embedding('I enjoy coding', 'DOCUMENT', 'SAP_GXY.20250407'); - const result = await db.run( - `SELECT - VECTOR_EMBEDDING('I love programming', 'DOCUMENT', 'SAP_GXY.20250407') as e1, - VECTOR_EMBEDDING('I enjoy coding', 'DOCUMENT', 'SAP_GXY.20250407') as e2` - ); - - const v1 = JSON.parse(result[0].e1); - const v2 = JSON.parse(result[0].e2); + const v1 = JSON.parse(e1); + const v2 = JSON.parse(e2); const similarity = cosineSimilarity(v1, v2); assert.ok( @@ -93,16 +47,11 @@ describe('Vector functions (SQLite only)', () => { }); test('semantically different sentences are far apart in vector space', async () => { - if (db?.kind !== 'sqlite') return; - - const result = await db.run( - `SELECT - VECTOR_EMBEDDING('The cat sat on the mat', 'DOCUMENT', 'SAP_GXY.20250407') as e1, - VECTOR_EMBEDDING('Quantum physics is fascinating', 'DOCUMENT', 'SAP_GXY.20250407') as e2` - ); + 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(result[0].e1); - const v2 = JSON.parse(result[0].e2); + const v1 = JSON.parse(e1); + const v2 = JSON.parse(e2); const similarity = cosineSimilarity(v1, v2); assert.ok( @@ -111,19 +60,36 @@ describe('Vector functions (SQLite only)', () => { ); }); - test('4-parameter version with remote_source works', async () => { - if (db?.kind !== 'sqlite') return; + test('handles empty text', async () => { + const result = vector_embedding('', 'DOCUMENT', 'SAP_GXY.20250407'); - const result = await db.run( - `SELECT VECTOR_EMBEDDING('test text', 'DOCUMENT', 'SAP_GXY.20250407', 'MY_GENAI_HUB_REMOTE_SOURCE') as embedding` - ); + 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'); + }); - const embedding = JSON.parse(result[0].embedding); - assert.ok(Array.isArray(embedding), 'Embedding should be an array'); - assert.strictEqual(embedding.length, 384, 'Embedding should have 384 dimensions'); + 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'); - // Note: In real HANA, remote_source would connect to SAP AI Core. - // In our SQLite implementation, we ignore it and use local ONNX model. + 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'); }); }); }); From b5539ef661b2f92f7607334f63c5a6c47f21ad6a Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 11:32:56 +0200 Subject: [PATCH 13/24] rem unused --- cds-plugin.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index 6286746..3537ff0 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -4,8 +4,6 @@ import enhanceModelWithRecommendations from './lib/csn-enhancements/recommendati import registerHandlersForRecommendations from './lib/handlers/recommendations.js'; import registerMtxHandlers from './lib/mtx/index.js'; -const LOG = cds.log('@cap-js/ai'); - cds.on('compile.for.runtime', enhanceModelWithRecommendations); cds.on('compile.to.edmx', enhanceModelWithRecommendations); From f7a46afcb3ecd7e5c7865f2bd88bdd67b09536c6 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 12:14:46 +0200 Subject: [PATCH 14/24] refactor --- .../index.js} | 0 .../semantic-search/InferenceSession.js | 0 .../semantic-search/embedding.js | 0 .../semantic-search/model-utils.js | 0 lib/vector_handling/index.js | 62 ------------------- package.json | 4 -- tests/vector.test.js | 2 +- 7 files changed, 1 insertion(+), 67 deletions(-) rename lib/{vector_handling/sync-wrapper.js => vector_embedding/index.js} (100%) rename lib/{vector_handling => vector_embedding}/semantic-search/InferenceSession.js (100%) rename lib/{vector_handling => vector_embedding}/semantic-search/embedding.js (100%) rename lib/{vector_handling => vector_embedding}/semantic-search/model-utils.js (100%) delete mode 100644 lib/vector_handling/index.js diff --git a/lib/vector_handling/sync-wrapper.js b/lib/vector_embedding/index.js similarity index 100% rename from lib/vector_handling/sync-wrapper.js rename to lib/vector_embedding/index.js diff --git a/lib/vector_handling/semantic-search/InferenceSession.js b/lib/vector_embedding/semantic-search/InferenceSession.js similarity index 100% rename from lib/vector_handling/semantic-search/InferenceSession.js rename to lib/vector_embedding/semantic-search/InferenceSession.js diff --git a/lib/vector_handling/semantic-search/embedding.js b/lib/vector_embedding/semantic-search/embedding.js similarity index 100% rename from lib/vector_handling/semantic-search/embedding.js rename to lib/vector_embedding/semantic-search/embedding.js diff --git a/lib/vector_handling/semantic-search/model-utils.js b/lib/vector_embedding/semantic-search/model-utils.js similarity index 100% rename from lib/vector_handling/semantic-search/model-utils.js rename to lib/vector_embedding/semantic-search/model-utils.js diff --git a/lib/vector_handling/index.js b/lib/vector_handling/index.js deleted file mode 100644 index 7838ba2..0000000 --- a/lib/vector_handling/index.js +++ /dev/null @@ -1,62 +0,0 @@ -import cds from '@sap/cds'; - -const LOG = cds.log('@cap-js/ai'); - -// Initialize embedding session once (shared across all connections) -let embeddingModule; -let sessionInitPromise; - -async function ensureSessionInitialized() { - if (!embeddingModule) { - try { - embeddingModule = await import('./semantic-search/embedding.js'); - } catch (err) { - LOG.warn('Failed to load embedding module:', err.message); - throw err; - } - } - - if (!sessionInitPromise) { - sessionInitPromise = embeddingModule.createSession().catch((err) => { - LOG.warn('Failed to initialize embedding model:', err.message); - sessionInitPromise = null; // Reset on failure to allow retry - throw err; - }); - } - - return sessionInitPromise; -} - -export default async function addSQLiteVectorSupport(dbc) { - try { - await ensureSessionInitialized(); - } catch (err) { - // Session initialization failed, skip registration - return; - } - - // Register VECTOR_EMBEDDING function - // better-sqlite3 does NOT support arity-based dispatch - the second registration - // overwrites the first. We register a variadic function that handles both 3 and 4 parameters. - // Note: remote_source (4th param) is accepted for HANA API compatibility but ignored. - // SQLite always uses the local ONNX model and cannot connect to remote embedding services. - dbc.function( - 'VECTOR_EMBEDDING', - { deterministic: true, varargs: true }, - (text, text_type, model_and_version, remote_source) => { - return generateVector(text, text_type, model_and_version, embeddingModule); - } - ); -} - -const model_dimensions = { - 'SAP_GXY.20250407': 384, - 'SAP_GXY.20240715': 384 -}; - -function generateVector(text, _, model_and_version, embedding) { - if (text) { - return JSON.stringify(Array.from(embedding.embedding(text).embedding)); - } - return JSON.stringify(new Array(model_dimensions[model_and_version] ?? 384).fill(0)); -} diff --git a/package.json b/package.json index b9d0779..a677c31 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,6 @@ "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", "main": "cds-plugin.js", - "exports": { - ".": "./cds-plugin.js", - "./vector-embedding": "./lib/vector_handling/sync-wrapper.js" - }, "scripts": { "lint": "npx -y eslint@10 .", "test": "node --test tests/*.test.js", diff --git a/tests/vector.test.js b/tests/vector.test.js index b8e407a..49c035c 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert'; -import { vector_embedding } from '../lib/vector_handling/sync-wrapper.js'; +import { vector_embedding } from '../lib/vector_embedding/index.js'; describe('Vector embedding function (standalone)', () => { describe('vector_embedding', () => { From b596891cecabb125f52973d70a9bb53b7d696164 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 12:17:33 +0200 Subject: [PATCH 15/24] refactor --- lib/vector_embedding/{semantic-search => }/InferenceSession.js | 0 lib/vector_embedding/{semantic-search => }/embedding.js | 0 lib/vector_embedding/index.js | 2 +- lib/vector_embedding/{semantic-search => }/model-utils.js | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename lib/vector_embedding/{semantic-search => }/InferenceSession.js (100%) rename lib/vector_embedding/{semantic-search => }/embedding.js (100%) rename lib/vector_embedding/{semantic-search => }/model-utils.js (100%) diff --git a/lib/vector_embedding/semantic-search/InferenceSession.js b/lib/vector_embedding/InferenceSession.js similarity index 100% rename from lib/vector_embedding/semantic-search/InferenceSession.js rename to lib/vector_embedding/InferenceSession.js diff --git a/lib/vector_embedding/semantic-search/embedding.js b/lib/vector_embedding/embedding.js similarity index 100% rename from lib/vector_embedding/semantic-search/embedding.js rename to lib/vector_embedding/embedding.js diff --git a/lib/vector_embedding/index.js b/lib/vector_embedding/index.js index 490df79..5ebf1ee 100644 --- a/lib/vector_embedding/index.js +++ b/lib/vector_embedding/index.js @@ -7,7 +7,7 @@ let embeddingModule; let initializationError; try { - embeddingModule = await import('./semantic-search/embedding.js'); + embeddingModule = await import('./embedding.js'); await embeddingModule.createSession(); LOG?.info?.('Vector embedding ONNX model initialized'); } catch (err) { diff --git a/lib/vector_embedding/semantic-search/model-utils.js b/lib/vector_embedding/model-utils.js similarity index 100% rename from lib/vector_embedding/semantic-search/model-utils.js rename to lib/vector_embedding/model-utils.js From 77f1adb133b6a0e33ef385c35e49e0283c305f91 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 12:19:31 +0200 Subject: [PATCH 16/24] linter --- lib/vector_embedding/index.js | 4 +--- tests/vector.test.js | 10 ++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/vector_embedding/index.js b/lib/vector_embedding/index.js index 5ebf1ee..6111447 100644 --- a/lib/vector_embedding/index.js +++ b/lib/vector_embedding/index.js @@ -33,9 +33,7 @@ const model_dimensions = { */ function vector_embedding(text, text_type, model_and_version) { if (initializationError) { - throw new Error( - `Embedding module failed to initialize: ${initializationError.message}` - ); + throw new Error(`Embedding module failed to initialize: ${initializationError.message}`); } if (!embeddingModule) { diff --git a/tests/vector.test.js b/tests/vector.test.js index 49c035c..4fb5257 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -66,7 +66,10 @@ describe('Vector embedding function (standalone)', () => { 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'); + assert.ok( + embedding.every((v) => v === 0), + 'Empty text should return all zeros' + ); }); test('handles null text', async () => { @@ -75,7 +78,10 @@ describe('Vector embedding function (standalone)', () => { 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'); + assert.ok( + embedding.every((v) => v === 0), + 'Null text should return all zeros' + ); }); test('uses correct dimensions for different models', async () => { From 3c0268d715495208d566913513a7521ad725fd41 Mon Sep 17 00:00:00 2001 From: Vitaly Kozyura <58591662+vkozyura@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:07:05 +0200 Subject: [PATCH 17/24] remove comment --- cds-plugin.js | 1 - 1 file changed, 1 deletion(-) diff --git a/cds-plugin.js b/cds-plugin.js index 3537ff0..c648fa1 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -8,7 +8,6 @@ cds.on('compile.for.runtime', enhanceModelWithRecommendations); cds.on('compile.to.edmx', enhanceModelWithRecommendations); cds.on('served', async (services) => { - // Register other handlers for (const name in services) { if (name === 'db') continue; // eslint-disable-next-line no-await-in-loop From c6328cdc922ae7a422ff97426e473d3e5876adc1 Mon Sep 17 00:00:00 2001 From: Vitaly Kozyura <58591662+vkozyura@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:11:44 +0200 Subject: [PATCH 18/24] Update CHANGELOG.md --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e593b05..5eded45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,7 @@ ### Added -- SQLite vector support: `VECTOR_EMBEDDING` function using ONNX Runtime with `Xenova/all-MiniLM-L6-v2` model (384 dimensions) - - Automatically registers on SQLite database connections +- Support `VECTOR_EMBEDDING` function using ONNX Runtime with `Xenova/all-MiniLM-L6-v2` model (384 dimensions) - Downloads model on-demand from Hugging Face (~10MB, cached 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 From 26966bdaf7f587b59ada7b371cefee8ad7631c22 Mon Sep 17 00:00:00 2001 From: Vitaly Kozyura <58591662+vkozyura@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:13:39 +0200 Subject: [PATCH 19/24] Update README.md --- README.md | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/README.md b/README.md index fb55ed3..f162cf5 100644 --- a/README.md +++ b/README.md @@ -248,37 +248,6 @@ function vector_embedding( - Throws if embedding generation fails - Import errors can be caught to detect if AI plugin is available -**Example Integration (Database Plugin):** - -```javascript -// In a database plugin like @cap-js/sqlite -let aiEmbedding = null; -try { - // ONNX model initializes automatically via top-level await - const aiPlugin = await import('@cap-js/ai/vector-embedding'); - aiEmbedding = aiPlugin.vector_embedding; -} catch (err) { - // AI plugin not available, use fallback -} - -// Register SQL function -dbc.function('VECTOR_EMBEDDING', { deterministic: true }, (text, text_type, model) => { - if (text == null) return null; - - if (aiEmbedding) { - try { - return aiEmbedding(text, text_type, model); - } catch (err) { - // Fall back to alternative implementation - } - } - - // Fallback implementation - return JSON.stringify(hashBasedEmbedding(text)); -}); -``` - - ## 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. From 4bb30cba12b6a711ba696abc665a4ba24534138f Mon Sep 17 00:00:00 2001 From: D051920 Date: Tue, 18 Aug 2026 14:35:49 +0200 Subject: [PATCH 20/24] export embeddings --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index a677c31..88d951d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", "main": "cds-plugin.js", + "exports": { + ".": "./cds-plugin.js", + "./vector_embedding": "./lib/vector_embedding/index.js" + }, "scripts": { "lint": "npx -y eslint@10 .", "test": "node --test tests/*.test.js", From 0c4fdf740cf2f3f4316f5c01261361e330199427 Mon Sep 17 00:00:00 2001 From: D051920 Date: Thu, 20 Aug 2026 16:10:56 +0200 Subject: [PATCH 21/24] 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. --- package.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 88d951d..0b0d305 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,11 @@ "main": "cds-plugin.js", "exports": { ".": "./cds-plugin.js", - "./vector_embedding": "./lib/vector_embedding/index.js" + "./cds-plugin": "./cds-plugin.js", + "./cds-plugin.js": "./cds-plugin.js", + "./vector_embedding": "./lib/vector_embedding/index.js", + "./srv/*": "./srv/*", + "./lib/*": "./lib/*" }, "scripts": { "lint": "npx -y eslint@10 .", From 280677147c21331856b822b20a93c79c07c649d1 Mon Sep 17 00:00:00 2001 From: D051920 Date: Thu, 20 Aug 2026 16:19:22 +0200 Subject: [PATCH 22/24] 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. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0b0d305..37290b7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "format:check": "npx -y prettier@3 --check . && format-cds --check" }, "files": [ + "cds-plugin.js", "CHANGELOG.md", "lib", "srv" From 447f21c1d5d7269d4d789acb09d0b824337a6126 Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 25 Aug 2026 22:35:03 +0200 Subject: [PATCH 23/24] feat: integrate embeddings with ai-sqlite --- CHANGELOG.md | 2 +- README.md | 43 ++-- lib/sqlite/AISQLiteService.js | 15 +- lib/vector_embedding/InferenceSession.js | 247 ++++------------------- lib/vector_embedding/index.js | 35 ++-- package.json | 23 +-- tests/vector.test.js | 39 +++- 7 files changed, 141 insertions(+), 263 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eded45..ffddc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- Support `VECTOR_EMBEDDING` function using ONNX Runtime with `Xenova/all-MiniLM-L6-v2` model (384 dimensions) +- Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using ONNX Runtime and the `Xenova/all-MiniLM-L6-v2` model (384 dimensions) - Downloads model on-demand from Hugging Face (~10MB, cached 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 f162cf5..244d071 100644 --- a/README.md +++ b/README.md @@ -205,32 +205,40 @@ resources: type: org.cloudfoundry.managed-service ``` -### 3. Vector Embedding API for Plugin Integration +### 3. Local Vector Embeddings with SQLite -The `@cap-js/ai` plugin exports a standalone vector embedding function that can be used by other plugins (like `@cap-js/sqlite`) to generate embeddings using an ONNX model. +The `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX model. #### Usage -```javascript -import { vector_embedding } from '@cap-js/ai/vector-embedding'; +Install the optional runtime dependencies: -// Model initializes automatically on import - just use it -const embeddingJSON = vector_embedding('Hello world', 'DOCUMENT', 'SAP_GXY.20250407'); -const embedding = JSON.parse(embeddingJSON); // Array of 384 float values +```sh +npm add @cap-js/sqlite onnxruntime-node ``` -#### Function Signature +Select `ai-sqlite` for the database service: -```typescript -function vector_embedding( - text: string | null, - text_type: string, - model_and_version: string -): string +```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 (returns zero vector if null or empty) +- `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'` @@ -238,15 +246,14 @@ function vector_embedding( - JSON stringified array of embedding values (384 dimensions) **Features:** -- **Auto-initialization**: ONNX model loads automatically when module is imported (top-level await) +- **Initialization**: The ONNX model is loaded when the `ai-sqlite` service starts - **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:** -- Throws if ONNX model failed to load during import +- Starting `ai-sqlite` fails if the ONNX model cannot be initialized - Throws if embedding generation fails -- Import errors can be caught to detect if AI plugin is available ## 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 index 5d52e09..b1ea5e7 100644 --- a/lib/vector_embedding/InferenceSession.js +++ b/lib/vector_embedding/InferenceSession.js @@ -1,11 +1,13 @@ -// Copy from onnxruntime-common/dist/cjs/inference-session-impl.js and referenced files by it // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// Adjusted to meet the needs of SQLite by making the run functions synchronous to avoid WorkerThreads +// +// 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 ort = require('onnxruntime-common'); -const binding = require('onnxruntime-node/dist/binding.js'); +const binding = require('onnxruntime-node/dist/binding.js').binding; class InferenceSession { constructor(handler) { @@ -13,9 +15,6 @@ class InferenceSession { } run(feeds) { - const fetches = {}; - let options = {}; - // check inputs if ( typeof feeds !== 'object' || feeds === null || @@ -23,227 +22,61 @@ class InferenceSession { Array.isArray(feeds) ) { throw new TypeError( - "'feeds' must be an object that use input names as keys and OnnxValue as corresponding values." + "'feeds' must be an object that uses input names as keys and tensors as values." ); } - // check if all inputs are in feed + for (const name of this.handler.inputNames) { - if (typeof feeds[name] === 'undefined') - throw new Error(`input '${name}' is missing in 'feeds'.`); - } - // if no fetches is specified, we use the full output names list - for (const name of this.handler.outputNames) { - fetches[name] = null; + if (feeds[name] === undefined) throw new Error(`input '${name}' is missing in 'feeds'.`); } - // feeds, fetches and options are prepared - const results = this.handler.run(feeds, fetches, options); - const returnValue = {}; - for (const key in results) { - if (Object.hasOwnProperty.call(results, key)) { - const result = results[key]; - if (result instanceof ort.Tensor) returnValue[key] = result; - else returnValue[key] = new ort.Tensor(result.type, result.data, result.dims); - } - } - return returnValue; - } - static async create(arg0) { - let filePathOrUint8Array; - if (arg0 instanceof Uint8Array) filePathOrUint8Array = arg0; - else - throw Error( - 'Argument is not supported. Check original InferenceSession implementation if this adjustment needs to be adopted' - ); - - // resolve backend, update session options with validated EPs, and create session handler - const [backend, optionsWithValidatedEPs] = await resolveBackendAndExecutionProviders(); - const handler = await backend.createInferenceSessionHandler( - filePathOrUint8Array, - optionsWithValidatedEPs - ); - return new InferenceSession(handler); - } -} + const fetches = Object.fromEntries(this.handler.outputNames.map((name) => [name, null])); + const results = this.handler.run(feeds, fetches, {}); + const output = {}; -// Copy from onnxruntime-common/dist/cjs/backend-impl.js -async function resolveBackendAndExecutionProviders() { - const backends = new Map(); - const backendsList = listSupportedBackends(); - for (const backend of backendsList) { - backends.set(backend.name, { backend: onnxruntimeBackend }); - } - const backendNames = [...backends.keys()]; - // try to resolve and initialize all requested backends - let backend; - const errors = []; - const availableBackendNames = new Set(); - for (const backendName of backendNames) { - // eslint-disable-next-line no-await-in-loop - const resolveResult = await tryResolveAndInitializeBackend(backendName, backends); - if (typeof resolveResult === 'string') { - errors.push({ name: backendName, err: resolveResult }); - } else { - if (!backend) { - backend = resolveResult; - } - if (backend === resolveResult) { - availableBackendNames.add(backendName); - } + 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); } - } - // if no backend is available, throw error. - if (!backend) { - throw new Error( - `no available backend found. ERR: ${errors.map((e) => `[${e.name}] ${e.err}`).join(', ')}` - ); - } - return [ - backend, - new Proxy( - {}, - { - get: (target, prop) => { - if (prop === 'executionProviders') { - return []; - } - return Reflect.get(target, prop); - } - } - ) - ]; -} -async function tryResolveAndInitializeBackend(backendName, backends) { - const backendInfo = backends.get(backendName); - if (!backendInfo) { - return 'backend not found.'; + return output; } - if (backendInfo.initialized) { - return backendInfo.backend; - } else if (backendInfo.aborted) { - return backendInfo.error; - } else { - const isInitializing = !!backendInfo.initPromise; - try { - if (!isInitializing) { - backendInfo.initPromise = backendInfo.backend.init(backendName); - } - await backendInfo.initPromise; - backendInfo.initialized = true; - return backendInfo.backend; - } catch (e) { - if (!isInitializing) { - backendInfo.error = `${e}`; - backendInfo.aborted = true; - } - return backendInfo.error; - } finally { - delete backendInfo.initPromise; + + 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)); } } -// Copy from test/bookshop/node_modules/onnxruntime-node/dist/backend.js -const dataTypeStrings = [ - undefined, - 'float32', - 'uint8', - 'int8', - 'uint16', - 'int16', - 'int32', - 'int64', - 'string', - 'bool', - 'float16', - 'float64', - 'uint32', - 'uint64', - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - 'uint4', - 'int4' -]; -class OnnxruntimeSessionHandler { - static inferenceSession = new WeakMap(); - constructor(pathOrBuffer, options) { - binding.initOrt(); - OnnxruntimeSessionHandler.inferenceSession.set(this, new binding.binding.InferenceSession()); +class SynchronousSessionHandler { + constructor(pathOrBuffer) { + this.session = new binding.InferenceSession(); if (typeof pathOrBuffer === 'string') { - OnnxruntimeSessionHandler.inferenceSession.get(this).loadModel(pathOrBuffer, options); + this.session.loadModel(pathOrBuffer, {}); } else { - OnnxruntimeSessionHandler.inferenceSession - .get(this) - .loadModel(pathOrBuffer.buffer, pathOrBuffer.byteOffset, pathOrBuffer.byteLength, options); + this.session.loadModel( + pathOrBuffer.buffer, + pathOrBuffer.byteOffset, + pathOrBuffer.byteLength, + {} + ); } - // prepare input/output names and metadata - this.inputNames = []; - this.outputNames = []; - this.inputMetadata = []; - this.outputMetadata = []; - // this function takes raw metadata from binding and returns a tuple of the following 2 items: - // - an array of string representing names - // - an array of converted InferenceSession.ValueMetadata - const fillNamesAndMetadata = (rawMetadata) => { - const names = []; - const metadata = []; - for (const m of rawMetadata) { - names.push(m.name); - if (!m.isTensor) { - metadata.push({ name: m.name, isTensor: false }); - } else { - const type = dataTypeStrings[m.type]; - if (type === undefined) { - throw new Error(`Unsupported data type: ${m.type}`); - } - const shape = []; - for (let i = 0; i < m.shape.length; ++i) { - const dim = m.shape[i]; - if (dim === -1) { - shape.push(m.symbolicDimensions[i]); - } else if (dim >= 0) { - shape.push(dim); - } else { - throw new Error(`Invalid dimension: ${dim}`); - } - } - metadata.push({ - name: m.name, - isTensor: m.isTensor, - type, - shape - }); - } - } - return [names, metadata]; - }; - [this.inputNames, this.inputMetadata] = fillNamesAndMetadata( - OnnxruntimeSessionHandler.inferenceSession.get(this).inputMetadata - ); - [this.outputNames, this.outputMetadata] = fillNamesAndMetadata( - OnnxruntimeSessionHandler.inferenceSession.get(this).outputMetadata - ); - } - async dispose() { - OnnxruntimeSessionHandler.inferenceSession.get(this).dispose(); + this.inputNames = this.session.inputNames; + this.outputNames = this.session.outputNames; } + run(feeds, fetches, options) { - return OnnxruntimeSessionHandler.inferenceSession.get(this).run(feeds, fetches, options); + return this.session.run(feeds, fetches, options); } -} -class OnnxruntimeBackend { - init() {} - createInferenceSessionHandler(pathOrBuffer, options) { - return new OnnxruntimeSessionHandler(pathOrBuffer, options || {}); + + async dispose() { + this.session.dispose(); } } -const onnxruntimeBackend = new OnnxruntimeBackend(); -const listSupportedBackends = binding.binding.listSupportedBackends; export { InferenceSession }; diff --git a/lib/vector_embedding/index.js b/lib/vector_embedding/index.js index 6111447..edfa0b1 100644 --- a/lib/vector_embedding/index.js +++ b/lib/vector_embedding/index.js @@ -2,17 +2,24 @@ import cds from '@sap/cds'; const LOG = cds.log('@cap-js/ai'); -// Auto-initialize on module load let embeddingModule; -let initializationError; - -try { - embeddingModule = await import('./embedding.js'); - await embeddingModule.createSession(); - LOG?.info?.('Vector embedding ONNX model initialized'); -} catch (err) { - LOG.warn('Failed to initialize embedding model:', err.message); - initializationError = err; +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 = { @@ -32,12 +39,8 @@ const model_dimensions = { * @throws {Error} If embedding module failed to initialize or generation fails */ function vector_embedding(text, text_type, model_and_version) { - if (initializationError) { - throw new Error(`Embedding module failed to initialize: ${initializationError.message}`); - } - if (!embeddingModule) { - throw new Error('Embedding module not available'); + throw new Error('Embedding module is not initialized'); } if (text) { @@ -46,4 +49,4 @@ function vector_embedding(text, text_type, model_and_version) { return JSON.stringify(new Array(model_dimensions[model_and_version] ?? 384).fill(0)); } -export { vector_embedding }; +export { initializeEmbedding, vector_embedding }; diff --git a/package.json b/package.json index 37290b7..9bca0a2 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,6 @@ "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", "main": "cds-plugin.js", - "exports": { - ".": "./cds-plugin.js", - "./cds-plugin": "./cds-plugin.js", - "./cds-plugin.js": "./cds-plugin.js", - "./vector_embedding": "./lib/vector_embedding/index.js", - "./srv/*": "./srv/*", - "./lib/*": "./lib/*" - }, "scripts": { "lint": "npx -y eslint@10 .", "test": "node --test tests/*.test.js", @@ -24,20 +16,23 @@ "format:check": "npx -y prettier@3 --check . && format-cds --check" }, "files": [ - "cds-plugin.js", "CHANGELOG.md", "lib", "srv" ], - "dependencies": { - "onnxruntime-node": "^1.20.1" - }, "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.test.js b/tests/vector.test.js index 4fb5257..0c20ab3 100644 --- a/tests/vector.test.js +++ b/tests/vector.test.js @@ -1,6 +1,9 @@ -import { describe, test } from 'node:test'; +import { after, before, describe, test } from 'node:test'; import assert from 'node:assert'; -import { vector_embedding } from '../lib/vector_embedding/index.js'; +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', () => { @@ -100,6 +103,38 @@ describe('Vector embedding function (standalone)', () => { }); }); +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'); From 9dde6431eae44097dc4dafda84269d39e18c338d Mon Sep 17 00:00:00 2001 From: Sebastian Van Syckel Date: Tue, 25 Aug 2026 23:17:59 +0200 Subject: [PATCH 24/24] fix: harden local embedding runtime --- CHANGELOG.md | 2 +- README.md | 6 +- lib/vector_embedding/InferenceSession.js | 15 +- lib/vector_embedding/embedding.js | 83 +++++---- lib/vector_embedding/model-utils.js | 228 +++++++++++++++++------ package.json | 4 +- tests/vector-unit.test.js | 133 +++++++++++++ 7 files changed, 370 insertions(+), 101 deletions(-) create mode 100644 tests/vector-unit.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ffddc89..ffee0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,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) - - Downloads model on-demand from Hugging Face (~10MB, cached locally) + - 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 diff --git a/README.md b/README.md index 244d071..d572b53 100644 --- a/README.md +++ b/README.md @@ -214,9 +214,11 @@ The `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embed Install the optional runtime dependencies: ```sh -npm add @cap-js/sqlite onnxruntime-node +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 @@ -247,12 +249,14 @@ 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 - **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/vector_embedding/InferenceSession.js b/lib/vector_embedding/InferenceSession.js index b1ea5e7..0ea71be 100644 --- a/lib/vector_embedding/InferenceSession.js +++ b/lib/vector_embedding/InferenceSession.js @@ -6,7 +6,16 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); -const ort = require('onnxruntime-common'); +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 { @@ -79,4 +88,6 @@ class SynchronousSessionHandler { } } -export { InferenceSession }; +const { Tensor } = ort; + +export { InferenceSession, Tensor }; diff --git a/lib/vector_embedding/embedding.js b/lib/vector_embedding/embedding.js index 8a9402b..cb35081 100644 --- a/lib/vector_embedding/embedding.js +++ b/lib/vector_embedding/embedding.js @@ -1,40 +1,41 @@ import os from 'os'; import path from 'path'; -import ort from 'onnxruntime-node'; +import { Tensor } from './InferenceSession.js'; import { downloadModelIfNeeded, - forceRedownloadModel, - loadModelAndVocab, + loadModelAndTokenizer, preTokenize, wordPieceTokenize, validateTokenIds } from './model-utils.js'; -const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; -const MODEL_DIR = path.join(getDataDir(), 'models', MODEL_NAME.replace('/', '_')); -const FILES = ['onnx/model.onnx', 'tokenizer.json', 'tokenizer_config.json']; - -async function initializeModelAndVocab() { - try { - const result = await loadModelAndVocab(MODEL_DIR); - session = result.session; - vocab = result.vocab; - } catch { - await forceRedownloadModel(MODEL_DIR, FILES); - await downloadModelIfNeeded(MODEL_DIR, FILES, MODEL_NAME); - const result = await loadModelAndVocab(MODEL_DIR); - session = result.session; - vocab = result.vocab; - } -} +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, vocab, maxLength = 512) { +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; @@ -44,14 +45,13 @@ function wordPieceTokenizer(text, vocab, maxLength = 512) { throw new Error('Special tokens must have numeric IDs'); } - const preTokens = preTokenize(text); + const preTokens = preTokenize(text, normalizer); const tokens = [clsToken]; const ids = [clsId]; for (const preToken of preTokens) { - const lowercaseToken = preToken.toLowerCase(); - const wordPieceTokens = wordPieceTokenize(lowercaseToken, vocab, unkToken); + const wordPieceTokens = wordPieceTokenize(preToken, vocab, unkToken); for (const wpToken of wordPieceTokens) { const tokenId = vocab.get(wpToken) ?? unkId; @@ -65,18 +65,15 @@ function wordPieceTokenizer(text, vocab, maxLength = 512) { if (tokens.length <= maxLength) return [{ tokens, ids }]; - // For longer texts, create overlapping chunks + // Keep each chunk within the limit embedded in the pinned tokenizer. const maxContentLength = maxLength - 2; - const overlap = Math.floor(maxContentLength * 0.1); - const chunkSize = maxContentLength - overlap; - const chunks = []; const contentTokens = tokens.slice(1, -1); const contentIds = ids.slice(1, -1); - for (let i = 0; i < contentTokens.length; i += chunkSize) { - const chunkTokens = [clsToken, ...contentTokens.slice(i, i + maxContentLength - 1), sepToken]; - const chunkIds = [clsId, ...contentIds.slice(i, i + maxContentLength - 1), sepId]; + 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, @@ -101,9 +98,9 @@ function processChunkedEmbeddings(chunks, session) { const attentionMask = new BigInt64Array(validIds.length).fill(BigInt(1)); const tokenTypeIds = new BigInt64Array(validIds.length).fill(BigInt(0)); - const inputTensor = new ort.Tensor('int64', inputIds, [1, validIds.length]); - const attentionTensor = new ort.Tensor('int64', attentionMask, [1, validIds.length]); - const tokenTypeTensor = new ort.Tensor('int64', tokenTypeIds, [1, validIds.length]); + 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, @@ -151,19 +148,20 @@ function processChunkedEmbeddings(chunks, session) { } let session = null; -let vocab = null; +let tokenizer = null; async function createSession() { - await downloadModelIfNeeded(MODEL_DIR, FILES, MODEL_NAME); - await initializeModelAndVocab(); + const modelDir = getModelDir(); + await downloadModelIfNeeded(modelDir, MODEL); + ({ session, tokenizer } = await loadModelAndTokenizer(modelDir)); } function embedding(text) { - if (!session || !vocab) + if (!session || !tokenizer) throw new Error( 'Embedding session not initialized. Call createSession() before using embedding().' ); - const chunks = wordPieceTokenizer(text, vocab); + const chunks = wordPieceTokenizer(text, tokenizer); const vector = normalizeEmbedding(processChunkedEmbeddings(chunks, session)); const chunkObj = { content: text }; @@ -203,5 +201,10 @@ function getDataDir(appName = 'semantic-search') { 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 }; +export { embedding, createSession, wordPieceTokenizer }; diff --git a/lib/vector_embedding/model-utils.js b/lib/vector_embedding/model-utils.js index da9ef4e..da2bb61 100644 --- a/lib/vector_embedding/model-utils.js +++ b/lib/vector_embedding/model-utils.js @@ -1,83 +1,200 @@ -import { InferenceSession } from './InferenceSession.js'; +import { createHash, randomUUID } from 'crypto'; +import { createReadStream } from 'fs'; import fs from 'fs/promises'; -import { constants } from 'fs'; 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'); +} -// File operations -async function fileExists(filePath) { +async function isValidFile(filePath, file) { try { - await fs.access(filePath, constants.F_OK); - return true; - } catch { - return false; + 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) { - const res = await fetch(url); - if (!res.ok) - throw new Error(`Failed to download ${url}, status ${res.status} (${res.statusText})`); - const arrayBuffer = await res.arrayBuffer(); - await fs.writeFile(outputPath, Buffer.from(arrayBuffer)); +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(() => {}); + } } -// Model management -async function downloadModelIfNeeded(modelDir, files, modelName) { +async function downloadModelIfNeeded(modelDir, model, options) { await fs.mkdir(modelDir, { recursive: true }); - // eslint-disable-next-line no-await-in-loop - for (const file of files) { - const filePath = path.join(modelDir, path.basename(file)); + + for (const file of model.files) { + const filePath = path.join(modelDir, file.name); // eslint-disable-next-line no-await-in-loop - if (!(await fileExists(filePath))) - // eslint-disable-next-line no-await-in-loop - await downloadFile(`https://huggingface.co/${modelName}/resolve/main/${file}`, filePath); - } -} + if (await isValidFile(filePath, file)) continue; -async function forceRedownloadModel(modelDir, files) { - // eslint-disable-next-line no-await-in-loop - for (const file of files) { - const filePath = path.join(modelDir, path.basename(file)); + 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 - if (await fileExists(filePath)) await fs.unlink(filePath).catch(() => {}); + await downloadFile(url, filePath, file, options); } } -async function loadModelAndVocab(modelDir) { +async function loadModelAndTokenizer(modelDir) { const modelPath = path.join(modelDir, 'model.onnx'); - const vocabPath = path.join(modelDir, 'tokenizer.json'); - - const session = await InferenceSession.create(await fs.readFile(modelPath)); - const tokenizerJson = JSON.parse(await fs.readFile(vocabPath, 'utf-8')); + const tokenizerPath = path.join(modelDir, 'tokenizer.json'); + const tokenizerJson = JSON.parse(await fs.readFile(tokenizerPath, 'utf8')); - if (!tokenizerJson.model || !tokenizerJson.model.vocab) + if (!tokenizerJson.model?.vocab) { throw new Error('Invalid tokenizer structure: missing model.vocab'); + } - const cleanVocab = new Map(); + const vocab = new Map(); for (const [token, id] of Object.entries(tokenizerJson.model.vocab)) { - if (typeof id === 'number') cleanVocab.set(token, id); + 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'); } - return { session, vocab: cleanVocab }; + 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; } -// Tokenization helpers -function preTokenize(text) { +function isChineseCharacter(codePoint) { return ( - text - .normalize('NFD') - // eslint-disable-next-line no-control-regex - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '') - .replace(/\s+/g, ' ') - .trim() - .replace(/[!\s]\p{P}[!\s]/gu, (p) => ` ${p} `) - .split(/\s/g) - .filter((a) => a) + (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 = 200) { - if (token.length > maxInputCharsPerWord) return [unkToken]; +function wordPieceTokenize(token, vocab, unkToken = '[UNK]', maxInputCharsPerWord = 100) { + if (Array.from(token).length > maxInputCharsPerWord) return [unkToken]; const outputTokens = []; let start = 0; @@ -104,19 +221,20 @@ function wordPieceTokenize(token, vocab, unkToken = '[UNK]', maxInputCharsPerWor return outputTokens; } -// Validate token IDs before conversion to BigInt function validateTokenIds(ids) { ids.forEach((id) => { - if (typeof id !== 'number' || isNaN(id) || !isFinite(id)) + if (!Number.isSafeInteger(id) || id < 0) { throw new Error(`Invalid token ID detected: ${id} (type: ${typeof id})`); + } }); return ids; } export { + downloadFile, downloadModelIfNeeded, - forceRedownloadModel, - loadModelAndVocab, + isValidFile, + loadModelAndTokenizer, preTokenize, wordPieceTokenize, validateTokenIds diff --git a/package.json b/package.json index 9bca0a2..8df0da8 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,11 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", - "onnxruntime-node": "^1.20.1" + "onnxruntime-node": "1.20.1" }, "peerDependencies": { "@sap/cds": ">=9", - "onnxruntime-node": "^1.20.1" + "onnxruntime-node": "1.20.1" }, "peerDependenciesMeta": { "onnxruntime-node": { 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') + } + ] + }; +}