Skip to content

feat: configure and provision local embedding models - #51

Merged
sjvans merged 3 commits into
AISQLiteServicefrom
feat/actual-tokenizer
Aug 27, 2026
Merged

feat: configure and provision local embedding models#51
sjvans merged 3 commits into
AISQLiteServicefrom
feat/actual-tokenizer

Conversation

@BobdenOs

@BobdenOs BobdenOs commented Aug 14, 2026

Copy link
Copy Markdown

Summary

  • replace the handwritten WordPiece implementation with @huggingface/tokenizers@0.1.3
  • expose @huggingface/tokenizers as an optional peer dependency and fail with an installation hint when ai-sqlite uses it without the package installed
  • require an explicit embedding model; there is no built-in/default model
  • keep runtime configuration limited to embedding: { model, directory? }
  • discover compatible Hugging Face ONNX artifacts and embedding metadata automatically from the model name
  • provision missing project-local models on demand into <cds.root>/.cds/models/<model> after logging a warning
  • support explicit local/shared provisioning with npx @cap-js/ai install-model <model> [--directory <root>]
  • generate a pinned embedding.lock.json containing the resolved revision, files, checksums, dimensions, token limit, pooling, and normalization
  • validate staged models through ONNX Runtime before publishing them and cleanly dispose validation/runtime sessions
  • embed one model input window and truncate longer input; applications should split long documents and store one vector per chunk

Runtime configuration

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

Without directory, the effective installation is:

<cds.root>/.cds/models/foo/bar

For a shared pre-installed cache:

npx @cap-js/ai install-model foo/bar --directory ~/.cds/models
{
  "embedding": {
    "model": "foo/bar",
    "directory": "~/.cds/models"
  }
}

Configured directories are verified but remain offline/read-only during application startup.

Model compatibility

  • resolves Hugging Face revisions to immutable commits
  • supports conventional onnx/model.onnx, including external ONNX data files
  • derives dimensions and bounded token length from model/tokenizer metadata
  • derives pooling and normalization from an unambiguous Sentence Transformers module chain
  • validates input/output names, tensor shape/type, and dimensions through a startup probe
  • rejects missing or ambiguous model metadata instead of guessing defaults

Validation

  • embedding, discovery, provisioning, ai-sqlite, and knowledge-graph tests: 68/68 passing
  • npx -y eslint@10 .
  • npx -y prettier@3 --check .
  • git diff --check

Stack

Category: Feature / Refactor

  • Added relevant changelog documentation

@sjvans
sjvans force-pushed the sqlite-embeddings branch 2 times, most recently from e6cf3dd to 447f21c Compare August 25, 2026 20:37
Base automatically changed from sqlite-embeddings to AISQLiteService August 25, 2026 22:03
@sjvans
sjvans force-pushed the feat/actual-tokenizer branch from dae05b3 to 394af11 Compare August 25, 2026 22:51
@sjvans sjvans changed the title remove hardcoded file names and allow arbitrary models to be used feat: support configurable local embedding models Aug 25, 2026
@sjvans

sjvans commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary of the update:

  • Rebased the tokenizer work onto AISQLiteService and kept Xenova/all-MiniLM-L6-v2 as the zero-configuration default.
  • Replaced the handwritten WordPiece tokenizer with @huggingface/tokenizers@0.1.3, using the model-provided tokenizer artifacts instead of duplicating tokenizer behavior.
  • Reworked long-text chunking so every chunk retains tokenizer-derived special tokens, attention masks, and token type IDs without depending on tokenizer-side truncation.
  • Replaced the shared embedding state with a runtime scoped to each ai-sqlite service, allowing different services to use different compatible encoder configurations safely.
  • Added explicit custom-model descriptors covering repository, immutable revision, dimensions, maximum sequence length, artifact roles, expected sizes, and SHA-256 checksums. MiniLM remains the default when no descriptor is supplied.
  • Kept downloads bounded, checksum-verified, atomic, and cache-isolated by repository, revision, and artifact-set digest.
  • Added startup validation for ONNX input names, output names, tensor types, tensor shapes, and configured dimensions. Compatible models may use input_ids, attention_mask, and token_type_ids, and support mean, cls, or already-pooled output handling.
  • Preserved the HANA-compatible VECTOR_EMBEDDING signature. model_and_version remains informational and does not dynamically select or download a model during a query.
  • Expanded the unit and integration coverage for tokenizer boundaries, long-input chunking, custom descriptors, cache isolation, pooling, normalization, optional inputs, invalid model contracts, and per-service runtime isolation.
  • Updated the README and changelog with the supported custom-model contract and security requirements.

Verification on commit 394af11: all 38 tests pass, including 24 focused tokenizer/vector tests; ESLint, Prettier, and git diff --check pass.

onnxruntime-node remains pinned to 1.20.1; investigation of later versions and the private synchronous runtime binding is tracked separately in #54.

PDT42
PDT42 previously requested changes Aug 26, 2026

@PDT42 PDT42 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this should replace: #53

  • The model configuration should be updated to require only, model, local dir where to store the model, normalization, pooling; could consider keeping the others for consistency checks
  • I'd like more readable test titles, please 😅; Currently the titles don't tell me what the test expects to happen and doesn't allow me to agree or disagree with that expectation

Comment thread lib/vector_embedding/embedding.js Outdated
Comment thread lib/sqlite/AISQLiteService.js Outdated
Comment thread lib/vector_embedding/embedding.js Outdated
Comment thread lib/vector_embedding/model-utils.js
@PDT42

PDT42 commented Aug 26, 2026

Copy link
Copy Markdown

After some confusion about the @cap-js/sqlite version and figuring out, I have to supply cosine_similarity ...

See Details Edited the ai node module with:
dbc.function('COSINE_SIMILARITY', deterministic, (a, b) => cosineSimilarity(toFloatArray(a), toFloatArray(b)))

...

function toFloatArray(vector) {
  if (vector == null) return null
  if (vector instanceof Float32Array) return Array.from(vector)
  if (Buffer.isBuffer(vector)) return JSON.parse(vector.toString('utf8'))
  if (vector instanceof Uint8Array) return JSON.parse(Buffer.from(vector).toString('utf8'))
  if (typeof vector === 'string') return JSON.parse(vector)
  if (Array.isArray(vector)) return vector
  throw new Error(`Unsupported vector type: ${typeof vector}`)
}

function cosineSimilarity(a, b) {
  if (a == null || b == null) return null
  let dot = 0, normA = 0, normB = 0
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i]
    normA += a[i] * a[i]
    normB += b[i] * b[i]
  }
  const denom = Math.sqrt(normA) * Math.sqrt(normB)
  return denom === 0 ? 0 : dot / denom
}

I was able to make this work in https://github.tools.sap/cap/know-cap/pull/20 👌

Comment thread package.json Outdated
@sjvans
sjvans force-pushed the feat/actual-tokenizer branch from 44dafe9 to 55ddfdb Compare August 27, 2026 12:45
sjvans and others added 2 commits August 27, 2026 14:46
* feat: add explicit embedding model provisioning

* feat: support lazy embedding model provisioning

* docs: explain embedding model provisioning

* fix: require explicit embedding model

* refactor: require provisioned embedding models

* feat: provision embedding models by name
@sjvans
sjvans force-pushed the feat/actual-tokenizer branch from 55ddfdb to 2693601 Compare August 27, 2026 12:50
@sjvans sjvans changed the title feat: support configurable local embedding models feat: configure and provision local embedding models Aug 27, 2026
@sjvans
sjvans marked this pull request as ready for review August 27, 2026 13:01
@sjvans
sjvans requested a review from a team as a code owner August 27, 2026 13:01
@sjvans
sjvans merged commit 16cf65c into AISQLiteService Aug 27, 2026
1 check passed
@sjvans
sjvans deleted the feat/actual-tokenizer branch August 27, 2026 13:01
@hyperspace-pr-bot

Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


feat: Configure and provision local embedding models

This PR overhauls the ai-sqlite embedding subsystem, replacing the hardcoded Xenova/all-MiniLM-L6-v2 model and handwritten WordPiece tokenizer with a flexible, configuration-driven architecture that supports any compatible Hugging Face ONNX encoder model.

🔑 Key Changes

Runtime configuration is now limited to model and an optional directory:

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

Model provisioning supports two modes:

  • On-demand: Missing project-local models (.cds/models/<model>) are downloaded at startup with a warning
  • Explicit: npx @cap-js/ai install-model <model> [--directory <path>] for pre-installation or shared caches

🏗️ Architecture Changes

  • Tokenizer: Replaces the handwritten WordPiece implementation with @huggingface/tokenizers@0.1.3 (now an optional peer dependency with a clear install hint if missing)
  • Model discovery (model-discovery.js): Automatically resolves Hugging Face ONNX artifacts, checksums, dimensions, token limits, pooling, and normalization from the model name
  • Model lock (embedding.lock.json): Generated per installation; pins the immutable revision, file checksums, dimensions, and output semantics
  • CLI (bin/cds-ai.js + lib/vector_embedding/cli.js): New install-model command exposed via npx @cap-js/ai
  • Session lifecycle: AISQLiteService now creates and disposes the embedding runtime via createEmbeddingRuntime(), with proper cleanup on disconnect()
  • Input truncation: Embeds only the first model input window; applications must split long documents manually

✅ Compatibility

  • Validates ONNX session inputs/outputs, tensor shapes, and dimensions at startup
  • Rejects ambiguous or unsupported Sentence Transformers pooling configurations
  • Supports Xenova/* repositories that declare a base_model for semantics

Have you...

  • Added relevant entry to the change log?

Related: #49, #55, #58, #54


  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.29.54

@hyperspace-pr-bot hyperspace-pr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR introduces a well-structured model provisioning system with strong integrity guarantees, but there are a few substantive issues: the module-level singleton in createSession/embedding is incompatible with the new per-service runtime design and will silently misbehave in multi-service scenarios; ensureDirectory will throw ENOENT when the target directory doesn't yet exist; and the installOptions.directory forwarding is fragile due to double-resolution of the path. Please also address the existing reviewer comments about validateModelDescriptor being too strict for user-facing runtime config, and the @huggingface/tokenizers optional peer dependency noted in the package.json thread.

PR Bot Information

Version: 1.29.54

  • Event Trigger: pull_request.ready_for_review
  • LLM: anthropic--claude-4.6-sonnet
  • Correlation ID: 5f91bc50-a217-11f1-875d-3b7d143453d0
  • File Content Strategy: Full file content

Comment on lines +358 to 369
let sessionRuntime;
let sessionInitialization;

async function createSession() {
const modelDir = getModelDir();
await downloadModelIfNeeded(modelDir, MODEL);
({ session, tokenizer } = await loadModelAndTokenizer(modelDir));
async function createSession(configuration, options) {
sessionInitialization ??= createEmbeddingRuntime(configuration, options)
.then((runtime) => (sessionRuntime = runtime))
.catch((error) => {
sessionInitialization = undefined;
throw error;
});
return sessionInitialization;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Module-level sessionRuntime and sessionInitialization state is shared across all callers of createSession

AISQLiteService now creates a per-service _embeddingRuntime via createEmbeddingRuntime, but createSession / embedding (used by the index.js compatibility path) still writes to module-level singletons. Calling createSession twice with different configurations (e.g., two services with different models in tests or multi-service scenarios) silently ignores the second configuration because sessionInitialization ??= … is already set. More critically, dispose() on the first runtime also sets sessionRuntime = undefined, leaving any subsequent call to embedding() throwing "session not initialized" even though a second runtime may still exist.

Consider removing the module-level singleton entirely from createSession/embedding and routing the index.js path through the per-service _embeddingRuntime held by AISQLiteService, or at minimum document that only a single global session is supported and guard against re-initialization with a different configuration.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +368 to +372
async function ensureDirectory(directory) {
await ensureParentDirectories(directory);
await assertModelDirectory(directory);
await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: ensureDirectory calls assertModelDirectory after ensureParentDirectories, but ensureParentDirectories only creates the parent directories, not the directory itself. If directory does not yet exist, assertModelDirectory returns false (directory not found) and then fs.chmod is called on a path that does not exist, throwing ENOENT.

Should use fs.mkdir(directory, { recursive: true }) or call fs.mkdir for the leaf directory before assertModelDirectory.

Suggested change
async function ensureDirectory(directory) {
await ensureParentDirectories(directory);
await assertModelDirectory(directory);
await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE);
}
async function ensureDirectory(directory) {
await ensureParentDirectories(directory);
await fs.mkdir(directory, { mode: PROVISIONED_DIRECTORY_MODE }).catch((error) => {
if (error.code !== 'EEXIST') throw error;
});
await assertModelDirectory(directory);
await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE);
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +614 to +620
let owner;
try {
owner = JSON.parse(contents);
} catch {
if (Date.now() - stat.mtimeMs <= INSTALL_LOCK_STALE_MS) return false;
}
if (!isStaleInstallLock(owner, stat)) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: recoverStaleInstallLock falls through to isStaleInstallLock(owner, stat) even when JSON parsing failed — owner is undefined after a caught parse error. The if (!isStaleInstallLock(owner, stat)) return false; guard does handle undefined owner correctly (falls back to mtime check), but only when Date.now() - stat.mtimeMs > INSTALL_LOCK_STALE_MS. If the lock file has invalid JSON and was written recently, isStaleInstallLock returns false (not stale) and the function returns false, permanently blocking provisioning. Consider throwing or treating a corrupt recent lock as non-recoverable rather than silently stalling.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +74 to +83
const installOptions = {
root,
directory: modelRoot,
home: options.home,
fetchImpl,
discover,
validate: validate ?? validateEmbeddingModel,
timeoutMs: options.provisionTimeoutMs,
retryMs: options.provisionRetryMs
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: resolveEmbeddingModel passes modelRoot as directory in installOptions, but installModel re-derives modelRoot = getModelRoot(options.directory, options.root, options.home) from it. Because modelRoot is already a fully-resolved absolute path (not the original user-supplied directory string), the ~/ and relative-path resolution branches in getModelRoot are bypassed and the paths remain consistent — but only by coincidence. If the user supplies directory: "~/.cds/models", the resolved modelRoot is /home/user/.cds/models; passing that as options.directory to installModel hits the path.resolve(root, directory) branch, which is correct only because absolute paths are returned unchanged. This is fragile. Consider passing the raw directory option through, or passing the pre-resolved modelDir directly to provisionModel.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: The --help option in HELP text is indented with extra spaces ( --help Show this help) making the alignment inconsistent with --directory. This may confuse users reading the help output.

Consider aligning both options consistently.

Suggested change
--help Show this help
--help Show this help

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants