LCOV - code coverage report
Current view: top level - src - onnx_embedding_model.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 71.4 % 28 20
Test Date: 2026-07-09 07:56:57 Functions: - 0 0

            Line data    Source code
       1              : // Copyright 2026 The Authors.
       2              : //
       3              : // Licensed under the Apache License, Version 2.0 (the "License");
       4              : // you may not use this file except in compliance with the License.
       5              : // You may obtain a copy of the License at
       6              : //
       7              : //     https://www.apache.org/licenses/LICENSE-2.0
       8              : //
       9              : // Unless required by applicable law or agreed to in writing, software
      10              : // distributed under the License is distributed on an "AS IS" BASIS,
      11              : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12              : // See the License for the specific language governing permissions and
      13              : // limitations under the License.
      14              : 
      15              : import 'dart:io';
      16              : import 'dart:typed_data';
      17              : 
      18              : import 'package:betto_onnxrt/betto_onnxrt.dart';
      19              : import 'package:betto_lexical/betto_lexical.dart' show Tokenizer;
      20              : import 'package:meta/meta.dart' show visibleForTesting;
      21              : import 'package:path/path.dart' as p;
      22              : 
      23              : import 'embedding_model.dart' show EmbeddingKind, EmbeddingModel;
      24              : 
      25              : import 'bert_tokenizer.dart';
      26              : import 'math_utils.dart';
      27              : import 'model_catalog.dart';
      28              : import 'model_tokenizer.dart' show ModelTokenizer;
      29              : import 'xlmr_tokenizer.dart' show XlmRobertaTokenizer;
      30              : 
      31              : /// ONNX Runtime-backed embedding model for dense text retrieval.
      32              : ///
      33              : /// Implements [EmbeddingModel] using a model from [ModelCatalog] via the
      34              : /// `betto_onnxrt` [OnnxRuntime] and [OnnxSession] API. Produces L2-normalised
      35              : /// float32 embeddings suitable for cosine similarity search.
      36              : ///
      37              : /// ## Model identity
      38              : ///
      39              : /// [modelId] returns the stable [ModelSpec.id] of the loaded model (e.g.
      40              : /// `bge-small-en-v1.5`). This should be persisted alongside the vector index
      41              : /// so that a model change can be detected and the index rebuilt.
      42              : ///
      43              : /// ## Loading with download-on-demand (preferred)
      44              : ///
      45              : /// Supply a [cacheDir] (and optionally a [ModelSpec] via [spec]) to fetch the
      46              : /// model on first use via [ModelDownloader]. If the model files are already
      47              : /// cached and their SHA-256 checksums match, they are used immediately.
      48              : /// Otherwise [ModelDownloader] fetches the files before opening the ORT
      49              : /// session:
      50              : ///
      51              : /// ```dart
      52              : /// final spec = ModelCatalog.lookup('bge-small-en-v1.5');
      53              : /// final model = await OnnxEmbeddingModel.load(
      54              : ///   spec: spec,
      55              : ///   cacheDir: '/path/to/cache',
      56              : ///   onProgress: (received, total) {
      57              : ///     stderr.writeln('Downloading: $received / $total bytes');
      58              : ///   },
      59              : /// );
      60              : /// ```
      61              : ///
      62              : /// ## Loading from an explicit path
      63              : ///
      64              : /// The [modelPath] parameter loads a model from a specific filesystem path,
      65              : /// bypassing the catalog and downloader. Specifying [modelPath] without [spec]
      66              : /// uses [ModelCatalog.defaultModelId] for the identity.
      67              : ///
      68              : /// ```dart
      69              : /// final model = await OnnxEmbeddingModel.load(
      70              : ///   modelPath: '/path/to/bge_small.onnx',
      71              : /// );
      72              : /// ```
      73              : ///
      74              : /// **Important:** Either [modelPath] or [cacheDir] must be supplied.
      75              : /// Calling [load] without either throws [ArgumentError] synchronously — there
      76              : /// is no bundled model asset path. See [ModelCatalog] and [ModelDownloader].
      77              : ///
      78              : /// ## Lifecycle
      79              : ///
      80              : /// [load] opens the native ORT session via [OnnxRuntime.load]. [embed] runs
      81              : /// synchronously on the calling isolate — do **not** call from the UI thread
      82              : /// in Flutter without isolate offloading. [dispose] releases native resources;
      83              : /// always call it (use `try/finally`).
      84              : ///
      85              : /// ## Thread safety
      86              : ///
      87              : /// ORT sessions are thread-affine. All [embed] and [dispose] calls must come
      88              : /// from the same isolate that called [load].
      89              : class OnnxEmbeddingModel implements EmbeddingModel {
      90              :   /// Internal constructor — use [load].
      91            0 :   OnnxEmbeddingModel._(
      92              :     this._runtime,
      93              :     this._session,
      94              :     this._tokenizer,
      95              :     this._spec,
      96              :   );
      97              : 
      98              :   final OnnxRuntime _runtime;
      99              :   final OnnxSession _session;
     100              :   final ModelTokenizer _tokenizer;
     101              : 
     102              :   /// The [ModelSpec] of the loaded model.
     103              :   ///
     104              :   /// Provides [modelId] and [dimensions] for the [EmbeddingModel] interface.
     105              :   final ModelSpec _spec;
     106              : 
     107              :   // ── EmbeddingModel interface ───────────────────────────────────────────────
     108              : 
     109              :   /// Stable identifier of the loaded model, matching a [ModelCatalog] entry.
     110              :   ///
     111              :   /// Should be persisted alongside the vector index so a later model swap can
     112              :   /// be detected and the index rebuilt. Example: `bge-small-en-v1.5`.
     113            0 :   @override
     114              :   String get modelId => _spec.id; // coverage:ignore-line
     115              : 
     116              :   /// Embedding vector length produced by this model.
     117              :   ///
     118              :   /// Sourced from `spec.meta['dimensions']`. This is the single source of
     119              :   /// truth for SQ8 byte lengths and score-path length guards.
     120              :   /// Example: 384 for BGE Small En v1.5.
     121            0 :   @override
     122              :   int get dimensions => _spec.meta['dimensions'] as int; // coverage:ignore-line
     123              : 
     124              :   // ── Factory ────────────────────────────────────────────────────────────────
     125              : 
     126              :   /// Loads an embedding model and returns an [OnnxEmbeddingModel].
     127              :   ///
     128              :   /// **Either [modelPath] or [cacheDir] must be supplied.** Calling [load]
     129              :   /// without either throws [ArgumentError] synchronously — there is no default
     130              :   /// asset path or bundled model. Use [cacheDir] to download the model on
     131              :   /// demand (preferred), or [modelPath] to load from an explicit filesystem
     132              :   /// path. See [ModelCatalog] and [ModelDownloader].
     133              :   ///
     134              :   /// ## Download-on-demand path (preferred)
     135              :   ///
     136              :   /// When [cacheDir] is provided, [ModelDownloader] is invoked to ensure the
     137              :   /// model files are present and checksummed before opening the ORT session.
     138              :   /// Files already in the cache are reused without downloading. Pass [spec] to
     139              :   /// select a specific catalog model; if omitted, [ModelCatalog.defaultModelId]
     140              :   /// (`'bge-small-en-v1.5'`) is used.
     141              :   ///
     142              :   /// [onProgress] is forwarded to [ModelDownloader.ensure] and receives
     143              :   /// incremental download progress. It is not called when files are cached.
     144              :   ///
     145              :   /// ```dart
     146              :   /// final model = await OnnxEmbeddingModel.load(
     147              :   ///   cacheDir: '/path/to/cache',
     148              :   ///   onProgress: (received, total) => print('$received / $total'),
     149              :   /// );
     150              :   /// ```
     151              :   ///
     152              :   /// ## Explicit-path
     153              :   ///
     154              :   /// When [modelPath] is provided, the file at that path is loaded directly
     155              :   /// (no download, no checksum). The model identity is set to
     156              :   /// [ModelCatalog.defaultModelId] unless [spec] is also supplied.
     157              :   ///
     158              :   /// **The second asset file must be named `vocab.txt`, beside [modelPath],
     159              :   /// regardless of `tokenizerFamily`.** This explicit-path mode is a
     160              :   /// lower-level escape hatch than the [cacheDir] path below and does not
     161              :   /// branch on tokenizer family for the asset filename — so loading an
     162              :   /// `'xlmr'`-family model this way still requires its `tokenizer.json` to
     163              :   /// be named/placed as `vocab.txt` beside [modelPath]. The [cacheDir] path
     164              :   /// handles per-family asset naming correctly and is the recommended way
     165              :   /// to load an XLM-R-family model.
     166              :   ///
     167              :   /// [tokenizer] overrides the word-segmentation step inside [BertTokenizer].
     168              :   /// Defaults to [RegExpTokenizer]. Supply `IcuTokenizer()` from
     169              :   /// `package:betto_icu` for superior Unicode coverage. Ignored for models
     170              :   /// whose `tokenizerFamily` is `'xlmr'` (word segmentation there is handled
     171              :   /// entirely by [XlmRobertaTokenizer]'s SentencePiece/Unigram pipeline, which
     172              :   /// has no equivalent seam).
     173              :   ///
     174              :   /// ## Tokenizer family selection
     175              :   ///
     176              :   /// The concrete [ModelTokenizer] implementation is chosen from
     177              :   /// `spec.meta['tokenizerFamily']`: `'bert'` loads [BertTokenizer],
     178              :   /// `'xlmr'` loads [XlmRobertaTokenizer]. This key must be present and
     179              :   /// recognised — an absent or unrecognised value throws [ArgumentError]
     180              :   /// rather than silently defaulting, so a future third tokenizer family
     181              :   /// can't be misresolved by accident.
     182              :   ///
     183              :   /// Throws [ArgumentError] if neither [modelPath] nor [cacheDir] is supplied,
     184              :   /// or if [spec]`.meta['tokenizerFamily']` is missing or unrecognised.
     185              :   /// Throws [UnsupportedError] if the model file does not exist on disk.
     186              :   /// Throws [Exception] if the ORT library cannot be loaded or the model is
     187              :   /// corrupt.
     188            2 :   static Future<OnnxEmbeddingModel> load({
     189              :     ModelSpec? spec,
     190              :     String? cacheDir,
     191              :     String? modelPath,
     192              :     Tokenizer? tokenizer,
     193              :     DownloadProgress? onProgress,
     194              :   }) async {
     195              :     // Guard: at least one of modelPath or cacheDir must be supplied.
     196              :     // This is a required-argument check: throw synchronously before any I/O
     197              :     // so callers get a fast, clear failure rather than a confusing downstream
     198              :     // error. The bundled LFS asset path has been removed — download-on-demand
     199              :     // (cacheDir) or an explicit modelPath is the only supported mechanism.
     200              :     if (modelPath == null && cacheDir == null) {
     201            1 :       throw ArgumentError(
     202              :         'Either modelPath or cacheDir must be supplied. '
     203              :         'Pass an explicit modelPath, or pass cacheDir (with an optional spec) '
     204              :         'to download the model on demand. '
     205              :         'See ModelCatalog and ModelDownloader.',
     206              :       );
     207              :     }
     208              : 
     209              :     // Resolve the model spec. When no spec is given, use the default model.
     210              :     // When a raw modelPath is supplied without a spec, we still need an id for
     211              :     // model identity tracking — use the default catalog ID.
     212              :     final resolvedSpec =
     213            1 :         spec ?? ModelCatalog.lookup(ModelCatalog.defaultModelId);
     214              : 
     215              :     // Validate tokenizerFamily synchronously, before any I/O — same
     216              :     // fail-fast rationale as the modelPath/cacheDir guard above. This is a
     217              :     // pure, fast check (no file or network access), so it is fully covered
     218              :     // by unit tests rather than requiring live model assets.
     219            2 :     final tokenizerFamily = _tokenizerFamily(resolvedSpec);
     220              : 
     221              :     final String resolvedModelPath;
     222              :     final String resolvedVocabPath;
     223              : 
     224              :     if (modelPath != null) {
     225              :       // Explicit path — bypass catalog and downloader. The second asset file
     226              :       // is named 'vocab.txt' regardless of tokenizer family for this path —
     227              :       // an explicit modelPath is a lower-level escape hatch than the
     228              :       // catalog/downloader path below, so it keeps the original, simpler
     229              :       // convention rather than branching on tokenizerFamily too.
     230              :       resolvedModelPath = modelPath;
     231            4 :       resolvedVocabPath = p.join(p.dirname(modelPath), 'vocab.txt');
     232              :     } else {
     233              :       // cacheDir != null is guaranteed by the guard above.
     234              :       // Download-on-demand path: let ModelDownloader ensure the files are
     235              :       // present and their checksums match before opening the ORT session.
     236              :       // The ModelCatalog allowlist gates which models may be downloaded.
     237            0 :       final downloader = ModelDownloader(allowlist: ModelCatalog());
     238            0 :       final resolved = await downloader.ensure(
     239              :         resolvedSpec,
     240              :         cacheDir: cacheDir!,
     241              :         onProgress: onProgress,
     242              :       );
     243              :       // File names in ResolvedModel.filePaths match the keys in ModelSpec.files:
     244              :       // 'onnx' → absolute path to the .onnx model, 'vocab' → the tokenizer
     245              :       // asset (vocab.txt for BERT-family models, tokenizer.json for
     246              :       // XLM-R-family models — the key name is a generic "second asset" slot,
     247              :       // not tied to any one file format).
     248            0 :       resolvedModelPath = resolved.filePaths['onnx']!;
     249            0 :       resolvedVocabPath = resolved.filePaths['vocab']!;
     250              :     }
     251              : 
     252            2 :     _assertFileExists(resolvedModelPath, 'model file');
     253            1 :     _assertFileExists(resolvedVocabPath, 'tokenizer asset');
     254              : 
     255              :     // coverage:ignore-start
     256              :     // The lines below require a live ORT native library and model assets.
     257              :     // They are tested through integration tests when model assets are present.
     258              :     final runtime = await OnnxRuntime.load();
     259              :     final session = runtime.createSessionFromFile(resolvedModelPath);
     260              :     // tokenizerFamily was already validated above (fail-fast, before I/O) —
     261              :     // this switch only selects which concrete loader to invoke.
     262              :     final ModelTokenizer tok = tokenizerFamily == 'xlmr'
     263              :         ? await XlmRobertaTokenizer.load(resolvedVocabPath)
     264              :         : await BertTokenizer.load(resolvedVocabPath, tokenizer: tokenizer);
     265              :     return OnnxEmbeddingModel._(runtime, session, tok, resolvedSpec);
     266              :     // coverage:ignore-end
     267              :   }
     268              : 
     269              :   /// Validates and returns `spec.meta['tokenizerFamily']`.
     270              :   ///
     271              :   /// Pure and synchronous — safe to call before any I/O, so a misconfigured
     272              :   /// [ModelSpec] fails fast with a clear message rather than surfacing as a
     273              :   /// confusing downstream error once the ORT session is already open.
     274              :   ///
     275              :   /// Throws [ArgumentError] if the key is missing or is not one of the
     276              :   /// recognised values (`'bert'`, `'xlmr'`) — deliberately not defaulted, so
     277              :   /// a future third tokenizer family can't be silently misresolved by a
     278              :   /// [ModelSpec] that forgot to set it.
     279            2 :   static String _tokenizerFamily(ModelSpec spec) {
     280            4 :     final family = spec.meta['tokenizerFamily'];
     281            3 :     if (family != 'bert' && family != 'xlmr') {
     282            2 :       throw ArgumentError(
     283            1 :         "ModelSpec '${spec.id}' has meta['tokenizerFamily'] = "
     284            1 :         "${family == null ? 'null (missing)' : "'$family'"}, but only "
     285              :         "'bert' and 'xlmr' are recognised. Add a valid tokenizerFamily "
     286              :         'entry to the ModelSpec.meta map.',
     287              :       );
     288              :     }
     289              :     return family as String;
     290              :   }
     291              : 
     292              :   /// Prepends the `kind`-appropriate prefix from `spec.meta` to [text], if
     293              :   /// one is configured.
     294              :   ///
     295              :   /// Looks up `spec.meta['queryPrefix']` for [EmbeddingKind.query] or
     296              :   /// `spec.meta['documentPrefix']` for [EmbeddingKind.document]. If the
     297              :   /// corresponding key is absent, [text] is returned unchanged — this is a
     298              :   /// deliberate no-op default so models that don't need a prefix (e.g.
     299              :   /// `bge-small-en-v1.5`) are byte-for-byte unaffected by [kind].
     300              :   ///
     301              :   /// Exposed (rather than kept private) so this pure, spec-driven logic can
     302              :   /// be unit-tested directly without needing a live ORT session — see this
     303              :   /// package's coverage notes for why [embed] itself is `coverage:ignore`d.
     304            1 :   @visibleForTesting
     305              :   static String applyPrefix(String text, EmbeddingKind kind, ModelSpec spec) {
     306            1 :     final key = kind == EmbeddingKind.query ? 'queryPrefix' : 'documentPrefix';
     307            2 :     final prefix = spec.meta[key];
     308            2 :     return prefix is String ? '$prefix$text' : text;
     309              :   }
     310              : 
     311              :   // ── EmbeddingModel.embed ──────────────────────────────────────────────────
     312              : 
     313              :   /// Embeds [text] into an L2-normalised float32 vector of [dimensions] elements.
     314              :   ///
     315              :   /// Runs synchronously on the calling isolate. For large batches or UI
     316              :   /// applications, wrap calls in [Isolate.run] — but note that ORT sessions
     317              :   /// are thread-affine, so the session must be created inside the same isolate
     318              :   /// that calls [embed].
     319              :   ///
     320              :   /// An empty or whitespace-only [text] produces a `[CLS][SEP]`-only
     321              :   /// embedding (two real tokens) and returns `truncated = false`.
     322              :   ///
     323              :   /// [kind] selects [spec.meta]'s `'queryPrefix'` (for
     324              :   /// [EmbeddingKind.query]) or `'documentPrefix'` (for
     325              :   /// [EmbeddingKind.document], the default) and prepends it to [text] before
     326              :   /// tokenisation — see [applyPrefix]. Models with neither key (e.g.
     327              :   /// `bge-small-en-v1.5`) are unaffected: the prepend is a no-op, so
     328              :   /// behaviour is byte-for-byte unchanged regardless of [kind].
     329              :   ///
     330              :   /// Returns `(embedding, truncated)`:
     331              :   /// - `embedding` — [dimensions]-element [Float32List] with unit L2 norm.
     332              :   /// - `truncated` — `true` if [text] (after prefixing) exceeded the usable
     333              :   ///   token budget and was silently cut before embedding.
     334              :   // coverage:ignore-start
     335              :   // This entire method requires a live ORT session to exercise meaningfully
     336              :   // — covered by integration tests with model assets, not make coverage.
     337              :   // (applyPrefix itself, the pure prefix-selection logic embed() delegates
     338              :   // to below, is unit-tested directly without needing a live session — see
     339              :   // its own doc comment and test/onnx_embedding_model_test.dart.)
     340              :   @override
     341              :   Future<(Float32List, bool)> embed(
     342              :     String text, {
     343              :     EmbeddingKind kind = EmbeddingKind.document,
     344              :   }) async {
     345              :     final prefixedText = applyPrefix(text, kind, _spec);
     346              :     final tokens = _tokenizer.encode(prefixedText);
     347              :     final seqLen = tokens.inputIds.length;
     348              :     final hiddenDim = dimensions; // sourced from spec.meta['dimensions']
     349              : 
     350              :     // Build int64 input tensors shaped [1, seqLen].
     351              :     // The BGE model requires three inputs: input_ids, attention_mask,
     352              :     // and token_type_ids — all shaped [1, seqLen] with int64 elements.
     353              :     final shape = [1, seqLen];
     354              :     final inputIds = OnnxTensor.fromInt64(shape, tokens.inputIds);
     355              :     final attentionMask = OnnxTensor.fromInt64(shape, tokens.attentionMask);
     356              :     final tokenTypeIds = OnnxTensor.fromInt64(shape, tokens.tokenTypeIds);
     357              : 
     358              :     // Run ONNX inference. The output 'last_hidden_state' has shape
     359              :     // [1, seqLen, hiddenDim]. We rely on OnnxSession.run() populating
     360              :     // the shape from the native OrtValue via the output-shape-readback
     361              :     // slots (31/32/33) added in the generic betto_onnxrt API.
     362              :     final outputs = _session.run(
     363              :       inputs: {
     364              :         'input_ids': inputIds,
     365              :         'attention_mask': attentionMask,
     366              :         'token_type_ids': tokenTypeIds,
     367              :       },
     368              :       outputNames: ['last_hidden_state'],
     369              :     );
     370              :     final outputTensor = outputs.first;
     371              : 
     372              :     // Extract the flat float32 output. The tensor shape is [1, seqLen, D].
     373              :     // asFloat32() throws StateError if the output element type is not float32,
     374              :     // which would indicate a mismatched model.
     375              :     final raw = outputTensor.asFloat32().toList();
     376              : 
     377              :     // Mean-pool over non-padding token positions, then L2-normalise.
     378              :     final pooled = meanPool(
     379              :       raw,
     380              :       tokens.attentionMask.toList(),
     381              :       seqLen: seqLen,
     382              :       hiddenDim: hiddenDim,
     383              :     );
     384              :     final embedding = l2Normalize(pooled);
     385              : 
     386              :     return (embedding, tokens.truncated);
     387              :     // coverage:ignore-end
     388              :   }
     389              : 
     390              :   /// Releases the native ORT session and runtime resources.
     391              :   ///
     392              :   /// Must be called exactly once when the model is no longer needed.
     393              :   /// After [dispose], [embed] must not be called.
     394            0 :   @override
     395              :   void dispose() {
     396              :     // coverage:ignore-start
     397              :     // Requires a live ORT session — covered by integration tests with model assets.
     398              :     _session.dispose();
     399              :     _runtime.dispose();
     400              :     // coverage:ignore-end
     401              :   }
     402              : 
     403              :   // ── Private helpers ────────────────────────────────────────────────────────
     404              : 
     405            2 :   static void _assertFileExists(String path, String label) {
     406            4 :     if (!File(path).existsSync()) {
     407            4 :       throw UnsupportedError(
     408              :         '$label not found at: $path\n'
     409              :         'Ensure model assets are present or configure a cacheDir for '
     410              :         'download-on-demand. See ModelCatalog and ModelDownloader.',
     411              :       );
     412              :     }
     413              :   }
     414              : }
        

Generated by: LCOV version 2.0-1