Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ gen/
package-lock.json
.env
.cdsrc-private.json
resources/
resources/
.cds/models/
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@

### Added

- Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using ONNX Runtime and the `Xenova/all-MiniLM-L6-v2` model (384 dimensions)
- Downloads the pinned model revision on-demand from Hugging Face (~91MB), verifies its size and SHA-256, and caches it locally
- Add the `ai-sqlite` kind with a `VECTOR_EMBEDDING` function using compatible ONNX encoder models
- Requires `cds.env.requires.db.embedding.model`; automatically discovers model metadata and supports warned, on-demand provisioning into `.cds/models`
- Adds `npx @cap-js/ai install-model <model>` with an optional shared model-cache root
- Uses `@huggingface/tokenizers` and chunks long input without dropping per-chunk special tokens
- Allows an explicit, checksum-verified compatible encoder model descriptor per `ai-sqlite` service
- Configures embedding runtimes only through `model` and an optional relative, absolute, or home-relative `directory`; discovered metadata remains in the provisioned lock
- Supports both 3-parameter `(text, text_type, model_and_version)` and 4-parameter variants with `remote_source`
- Compatible with `SAP_GXY.20250407` and `SAP_GXY.20240715` model versions
- Synchronous execution suitable for SQLite user-defined functions
Expand Down
121 changes: 65 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ resources:

### 3. Local Vector Embeddings with SQLite

The `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX encoder model. It uses the pinned `Xenova/all-MiniLM-L6-v2` model by default.
The `ai-sqlite` database kind extends `@cap-js/sqlite` with local semantic embeddings using an ONNX encoder model. Configure the model explicitly for every service.

#### Usage

Expand All @@ -219,18 +219,67 @@ 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:
#### Model provisioning

Runtime configuration is intentionally limited to a model name and an optional model-cache root:

```json
{
"cds": {
"requires": {
"db": {
"kind": "ai-sqlite",
"embedding": {
"model": "foo/bar"
}
}
}
}
}
```

`embedding.model` is required. If it is absent, `ai-sqlite` fails during startup. No revision, dimensions, tokenizer, file, pooling, checksum, or descriptor settings are accepted in runtime configuration.

Without `directory`, the model is stored below the CAP project at `.cds/models/foo/bar`. Startup reuses a valid installation from there. If it is missing, startup logs a warning, discovers and downloads the model, generates `embedding.lock.json`, and reuses that installation on subsequent starts.

To provision the project-local model before startup instead:

```sh
npx @cap-js/ai install-model foo/bar
```

To share a model across projects, select another cache root:

```sh
npx @cap-js/ai install-model foo/bar --directory ~/.cds/models
```

```json
{
"cds": {
"requires": {
"db": "ai-sqlite"
"db": {
"kind": "ai-sqlite",
"embedding": {
"model": "foo/bar",
"directory": "~/.cds/models"
}
}
}
}
}
```

`directory` always names the cache root; the model is stored below it using the repository path, for example `~/.cds/models/foo/bar`. Relative directories are resolved from `cds.root`, absolute directories are used unchanged, and `~/` is resolved from the user's home directory.

When `directory` is configured, startup treats it as a pre-installed shared cache: it verifies the model but does not download or modify it. This makes runtime deployment deterministic and allows the shared directory to be read-only.

##### Automatic model discovery

The installer resolves the model's current Hugging Face revision to an immutable commit, selects the conventional `onnx/model.onnx` and tokenizer/configuration files, calculates or obtains their checksums, and derives the dimensions, tokenizer limit, pooling, and normalization metadata. It then writes all resolved metadata to `embedding.lock.json` alongside the downloaded artifacts.

Discovery supports compatible Hugging Face ONNX Sentence Transformers models with machine-readable pooling semantics. Repositories with missing or ambiguous artifacts or semantics fail with a compatibility error instead of using guessed defaults. Once installed, startup uses the pinned lock and does not follow later changes to the model repository.

The HANA-compatible SQL function can then be used in CQL:

```js
Expand All @@ -247,73 +296,33 @@ SELECT.from('Books').columns`

**Returns:**

- JSON stringified array of embedding values (384 dimensions for the default MiniLM model; custom models use their configured `dimensions`)
- JSON stringified array of embedding values with the configured model's dimensions

**Features:**

- **Initialization**: The ONNX model is loaded when the `ai-sqlite` service starts
- **Verified cache**: The pinned model revision and artifact set are cached by default below the user's data directory; set `CDS_AI_MODEL_CACHE` to use a pre-provisioned cache root
- **Automatic provisioning**: Use model-only configuration for warned, on-demand installation into `.cds/models`
- **Explicit provisioning**: Preinstall local or shared models with `npx @cap-js/ai install-model`
- **Verified artifacts**: The provisioned lock pins the revision, artifact sizes, and SHA-256 checksums
- **Hugging Face tokenization**: Uses `@huggingface/tokenizers` and safely chunks text that exceeds the model limit
- **Deterministic**: Same input always produces same output
- **Normalized vectors**: MiniLM embeddings are L2-normalized; custom descriptors control this with `output.normalize`
- **Automatic output handling**: Pooling and normalization are derived from Sentence Transformers metadata
- **Semantic similarity**: Embeddings capture text meaning for similarity search

#### Compatible custom encoder models
#### Compatible encoder models

Configure a different model through the database service's `embedding` option. Models are not discovered dynamically: every artifact must belong to an immutable revision and have an expected size and SHA-256 checksum.
Compatible repositories must provide `onnx/model.onnx`, `tokenizer.json`, `tokenizer_config.json`, and `config.json`. The model must accept `input_ids` and may additionally accept `attention_mask` and `token_type_ids`, all as `int64` tensors, and expose a `last_hidden_state` float output.

```json
{
"cds": {
"requires": {
"db": {
"kind": "ai-sqlite",
"embedding": {
"repository": "organization/model",
"revision": "0123456789abcdef0123456789abcdef01234567",
"dimensions": 768,
"maxLength": 512,
"files": [
{
"role": "model",
"name": "model.onnx",
"path": "onnx/model.onnx",
"size": 123456789,
"sha256": "<64 lowercase hexadecimal characters>"
},
{
"role": "tokenizer",
"name": "tokenizer.json",
"path": "tokenizer.json",
"size": 123456,
"sha256": "<64 lowercase hexadecimal characters>"
},
{
"role": "tokenizerConfig",
"name": "tokenizer_config.json",
"path": "tokenizer_config.json",
"size": 1234,
"sha256": "<64 lowercase hexadecimal characters>"
}
],
"output": {
"name": "last_hidden_state",
"pooling": "mean",
"normalize": true
}
}
}
}
}
}
```
Pooling semantics are read from Sentence Transformers `modules.json` and its pooling configuration. Converted repositories such as `Xenova/*` can declare a single `base_model`; its immutable Sentence Transformers metadata is used to determine mean or CLS pooling and normalization. Unsupported module chains, ambiguous pooling modes, missing metadata, or incompatible ONNX inputs and outputs fail explicitly.

Compatible models must accept `input_ids` and may additionally accept `attention_mask` and `token_type_ids`, all as `int64` tensors. Their configured float32 or float64 output must support `mean` or `cls` pooling from `[1, sequence, dimensions]`, or `none` for an already pooled `[dimensions]` or `[1, dimensions]` tensor. Additional pinned ONNX data files can use the `auxiliary` role. Startup probes the model and rejects incompatible input names, output names, types, shapes, or dimensions.
Provisioning canonicalizes symlinked parent directories and rejects a model directory that is itself a symlink. Existing valid locks remain pinned and are reused rather than silently following changes to the repository's default branch.

**Error Handling:**

- Starting `ai-sqlite` fails if the ONNX model cannot be initialized
- Downloads are time-limited and accepted only when their expected size and SHA-256 match
- Starting `ai-sqlite` fails if `cds.env.requires.db.embedding.model` is not set or the ONNX model cannot be initialized
- A missing model in the project-local `.cds/models` cache is installed after a startup warning
- Starting `ai-sqlite` fails with a provisioning command if a configured model directory is missing or fails integrity checks
- Provisioning downloads are time-limited and accepted only when their expected size and SHA-256 match
- Throws if embedding generation fails

## Test the plugin locally
Expand Down
10 changes: 10 additions & 0 deletions bin/cds-ai.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env node

import { runModelCommand } from '../lib/vector_embedding/cli.js';

try {
await runModelCommand(process.argv.slice(2));
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
}
27 changes: 24 additions & 3 deletions lib/sqlite/AISQLiteService.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,30 @@ const LOG = cds.log('@cap-js/ai');

export default class AISQLiteService extends SQLiteService {
async init() {
this._embeddingRuntime = await createEmbeddingRuntime(this.options.embedding);
LOG.info('Vector embedding ONNX model initialized');
return super.init();
this._embeddingRuntime = await createEmbeddingRuntime(this.options.embedding, {
root: cds.root,
warn: (message) => LOG.warn(message)
});
try {
const service = await super.init();
LOG.info('Vector embedding ONNX model initialized');
return service;
} catch (error) {
await this._embeddingRuntime.dispose().catch(() => {});
this._embeddingRuntime = undefined;
throw error;
}
}

async disconnect(tenant) {
try {
return await super.disconnect(tenant);
} finally {
if (tenant === undefined) {
await this._embeddingRuntime?.dispose();
this._embeddingRuntime = undefined;
}
}
}

get factory() {
Expand Down
40 changes: 28 additions & 12 deletions lib/vector_embedding/InferenceSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ class InferenceSession {
return output;
}

dispose() {
const handler = this.handler;
if (!handler) return;
this.handler = undefined;
return handler.dispose();
}

static async create(pathOrBuffer) {
if (typeof pathOrBuffer !== 'string' && !(pathOrBuffer instanceof Uint8Array)) {
throw new TypeError('Expected an ONNX model path or Uint8Array');
Expand All @@ -73,25 +80,34 @@ class InferenceSession {
class SynchronousSessionHandler {
constructor(pathOrBuffer) {
this.session = new binding.InferenceSession();
if (typeof pathOrBuffer === 'string') {
this.session.loadModel(pathOrBuffer, {});
} else {
this.session.loadModel(
pathOrBuffer.buffer,
pathOrBuffer.byteOffset,
pathOrBuffer.byteLength,
{}
);
try {
if (typeof pathOrBuffer === 'string') {
this.session.loadModel(pathOrBuffer, {});
} else {
this.session.loadModel(
pathOrBuffer.buffer,
pathOrBuffer.byteOffset,
pathOrBuffer.byteLength,
{}
);
}
this.inputNames = this.session.inputNames;
this.outputNames = this.session.outputNames;
} catch (error) {
try {
this.session.dispose();
} catch {
// Preserve the model loading error.
}
throw error;
}
this.inputNames = this.session.inputNames;
this.outputNames = this.session.outputNames;
}

run(feeds, fetches, options) {
return this.session.run(feeds, fetches, options);
}

async dispose() {
dispose() {
this.session.dispose();
}
}
Expand Down
58 changes: 58 additions & 0 deletions lib/vector_embedding/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { installModel } from './model-install.js';
import { validateEmbeddingModel } from './embedding.js';

const HELP = `Usage:
npx @cap-js/ai install-model <model> [--directory <path>]

Options:
--directory <path> Use this model-cache root instead of .cds/models
--help Show this help
`;

async function runModelCommand(argv, options = {}) {
const { cwd = process.cwd(), stdout = process.stdout } = options;
const command = parseArguments(argv);
if (command.help) {
stdout.write(HELP);
return;
}

const { modelDir } = await installModel(command.model, {
root: cwd,
directory: command.directory,
home: options.home,
fetchImpl: options.fetchImpl,
discover: options.discover,
validate: options.validate ?? validateEmbeddingModel,
timeoutMs: options.timeoutMs,
retryMs: options.retryMs
});
stdout.write(`Installed ${command.model} in ${modelDir}\n`);
}

function parseArguments(argv) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) return { help: true };
if (argv[0] !== 'install-model') {
throw new Error(`Unsupported command.\n\n${HELP}`);
}

let model;
let directory;
for (let index = 1; index < argv.length; index++) {
const argument = argv[index];
if (argument === '--directory') {
const value = argv[++index];
if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value`);
directory = value;
continue;
}
if (argument.startsWith('-')) throw new Error(`Unknown option '${argument}'`);
if (model) throw new Error(`Unexpected argument '${argument}'`);
model = argument;
}

if (!model) throw new Error('Specify a model name');
return { directory, model };
}

export { HELP, parseArguments, runModelCommand };
Loading
Loading