From 0996b2f60c1a7b2ad58c00f1a7c10a51a062c759 Mon Sep 17 00:00:00 2001 From: D051920 Date: Thu, 30 Jul 2026 17:34:26 +0200 Subject: [PATCH 01/20] 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 a16b543..959d363 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 6675fdf7ae86c0491c9b5370e96494fc24fae774 Mon Sep 17 00:00:00 2001 From: D051920 Date: Thu, 30 Jul 2026 18:08:54 +0200 Subject: [PATCH 02/20] 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 ac481147766f140125caf1f8104f59d5bcf90a6e Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:18:14 +0200 Subject: [PATCH 03/20] 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 fe729e789231080b6397f7264e45ef9dbaa2d1ca Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:27:42 +0200 Subject: [PATCH 04/20] 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 750b2d4e2ccd240870bd6341afb5bc3949085c0f Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:50:20 +0200 Subject: [PATCH 05/20] 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 36b7fd854baffcaf343b592d3cf6a122d4b5ff42 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 10:58:15 +0200 Subject: [PATCH 06/20] 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 959d363..e471b94 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lib", "srv" ], - "dependencies": { + "optionalDependencies": { "onnxruntime-node": "^1.20.1" }, "devDependencies": { From eeaeb319de9a8f44659dce6deb866b9d7b7638c6 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 11:05:36 +0200 Subject: [PATCH 07/20] 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 e471b94..959d363 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lib", "srv" ], - "optionalDependencies": { + "dependencies": { "onnxruntime-node": "^1.20.1" }, "devDependencies": { From b07f5d9455b4985e241aef1cbf761ded149c5e90 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 11:08:34 +0200 Subject: [PATCH 08/20] 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 0f0d584d9efd19733cf8edbac21f92e5fb23fe49 Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 11:47:37 +0200 Subject: [PATCH 09/20] 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 fb7684ddd570c271d6f0cc715d2f3a4b0950641c Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 12:05:04 +0200 Subject: [PATCH 10/20] 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 39949313765d51c9bd9f0d310a18f44df1669d6e Mon Sep 17 00:00:00 2001 From: D051920 Date: Fri, 31 Jul 2026 12:11:16 +0200 Subject: [PATCH 11/20] 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 264b37a0b3d83fe9b628658a7daf97285773f0a6 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 10:50:18 +0200 Subject: [PATCH 12/20] 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 959d363..a95cd59 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 ad1269a490021633e443b6e2d11ca6b656109911 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 11:32:56 +0200 Subject: [PATCH 13/20] 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 02f385777732fe5da7e18899bc7a6fe9a870fb1e Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 12:14:46 +0200 Subject: [PATCH 14/20] 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 a95cd59..959d363 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 f353e6d4dec96237528ffe5f107952f93544f23c Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 12:17:33 +0200 Subject: [PATCH 15/20] 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 086f64fcdc3e54e59da771c35286af1f2d60fdc0 Mon Sep 17 00:00:00 2001 From: D051920 Date: Mon, 3 Aug 2026 12:19:31 +0200 Subject: [PATCH 16/20] 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 41e9e58b894c698168aa510ecdd8fa10ecda4a58 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/20] 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 f12bd88200bce25c087aa88afa40b75170262268 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/20] 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 38d810938da2915a23007026475db9edfb677e9f 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/20] 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 bcf90163535a488188ea80b7bf84a03c0fc9c5b6 Mon Sep 17 00:00:00 2001 From: D051920 Date: Tue, 18 Aug 2026 14:35:49 +0200 Subject: [PATCH 20/20] export embeddings --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 959d363..78ebae7 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",