From e3797131873369b3a76659a8d0f41b998d8195bd Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Tue, 14 Jul 2026 16:39:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(embeddings):=20BGE=20support=20=E2=80=94?= =?UTF-8?q?=20CLS=20pooling,=20retrieval=20prefixes,=20integer-buffer-safe?= =?UTF-8?q?=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/specs/embedding-model-coverage.md (E5BGE-0..3): - BertPooling { MEAN, CLS } on BertEncoderRuntime/createBertEncoderRuntime; detected from sentence-transformers 1_Pooling/config.json (absent -> MEAN, max/sqrt-len rejected loudly). Pooling stays outside the traced graph. - EmbeddingModel SPI gains embedQuery/embedDocument/embedDocuments (defaults delegate to embed — additive). PrefixedEmbeddingModel + EmbeddingModelProfiles apply per-repo retrieval prefixes (E5 query:/passage:, BGE query instruction); fromHuggingFace wires them automatically, fromSafeTensors takes explicit prefixes. - FloatSafeTensorsLoader: BGE checkpoints persist an I64 embeddings.position_ids buffer the engine loader cannot skip (SKaiNET-developers/SKaiNET#822); interim float-only loader over the public StreamingSafeTensorsReader, dropped when the engine gains a tensor filter. - Tests: CLS pooling (row-0 semantics, differs from MEAN, mask-independent), 1_Pooling parser shapes, prefix decorator + profile registry. BCV dumps regenerated; full build + apiCheck green. - E2E: kbert-cli BAAI/bge-small-en-v1.5 -> 384 dims, sensible similarity. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 20 +++ docs/specs/embedding-model-coverage.md | 115 ++++++++++++++++++ llm-api/api/jvm/llm-api.api | 3 + .../kotlin/sk/ainet/llm/api/EmbeddingModel.kt | 14 +++ llm-inference/bert/api/jvm/bert.api | 13 +- .../kotlin/sk/ainet/models/bert/BertConfig.kt | 19 +++ .../ainet/models/bert/BertEncoderRuntime.kt | 57 ++++++--- .../models/bert/BertPoolingConfigTest.kt | 52 ++++++++ .../models/bert/BertEncoderRuntimeTest.kt | 39 ++++++ llm-providers/api/llm-providers.api | 37 +++++- .../ainet/llm/providers/BertEmbeddingModel.kt | 29 ++++- .../llm/providers/FloatSafeTensorsLoader.kt | 76 ++++++++++++ .../llm/providers/PrefixedEmbeddingModel.kt | 89 ++++++++++++++ .../providers/PrefixedEmbeddingModelTest.kt | 67 ++++++++++ 14 files changed, 604 insertions(+), 26 deletions(-) create mode 100644 docs/specs/embedding-model-coverage.md create mode 100644 llm-inference/bert/src/commonTest/kotlin/sk/ainet/models/bert/BertPoolingConfigTest.kt create mode 100644 llm-providers/src/main/kotlin/sk/ainet/llm/providers/FloatSafeTensorsLoader.kt create mode 100644 llm-providers/src/main/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModel.kt create mode 100644 llm-providers/src/test/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dd025d3f..db0aa05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BGE embedding models** (`BAAI/bge-small-en-v1.5` and siblings) run on the BERT DSL path: + - **CLS pooling.** `BertPooling { MEAN, CLS }` on `BertEncoderRuntime` / + `createBertEncoderRuntime`; auto-detected from the sentence-transformers + `1_Pooling/config.json` (absent file → `MEAN`, unsupported max/sqrt-len modes rejected + loudly). Pooling stays outside the traced graph — OPTIMIZED mode and StableHLO export + are unaffected. + - **Query/document asymmetry.** `EmbeddingModel` gains `embedQuery` / `embedDocument` / + `embedDocuments` (defaults delegate to `embed` — additive, existing consumers untouched). + `PrefixedEmbeddingModel` + `EmbeddingModelProfiles` apply retrieval instruction prefixes + per repo id (E5 `query: `/`passage: `, BGE query instruction); `fromHuggingFace` wires + them automatically, `fromSafeTensors` accepts explicit `prefixes`. + - **Integer checkpoint buffers no longer break loads.** BGE-style snapshots persist an + I64 `embeddings.position_ids` buffer; the interim `FloatSafeTensorsLoader` skips + non-float buffers (the index-free encoder never needs them). Drop when the engine's + loader gains a tensor filter ([SKaiNET#822](https://github.com/SKaiNET-developers/SKaiNET/issues/822)). + - Design + traceable plan: `docs/specs/embedding-model-coverage.md` (E5 multilingual + follows in Phase 2 — Unigram tokenizer). + ## [0.36.0] — 2026-07-12 Ships against **SKaiNET engine 0.36.0**. Headline: **BERT is now completely defined on the DSL diff --git a/docs/specs/embedding-model-coverage.md b/docs/specs/embedding-model-coverage.md new file mode 100644 index 00000000..f25bbe04 --- /dev/null +++ b/docs/specs/embedding-model-coverage.md @@ -0,0 +1,115 @@ +# Embedding model coverage: BGE + multilingual E5 + +**Status:** Phase 1 in progress · **Owner:** @michalharakal · **Created:** 2026-07-13 + +Extend the BERT embedding stack beyond LEAF to the two most-used compact retrieval +models, establishing the model matrix for the upcoming embedding **performance +benchmarks** (`llm-performance` will compare models × execution modes on one harness): + +| Target model | Dims | Size | Blocking gaps | +|---|---|---|---| +| `BAAI/bge-small-en-v1.5` | 384 | 130 MB | CLS pooling; query instruction prefix | +| `intfloat/multilingual-e5-small` | 384 | 118 MB | Unigram (XLM-R) tokenizer; `query:`/`passage:` prefixes | + +Verified facts (HF configs, 2026-07-13): both are plain 12-layer BERTs (hidden 384, +heads 12, FFN 1536, absolute positions, `type_vocab_size` 2, **no** `2_Dense` head, +modules `Transformer → Pooling → Normalize`). `bertNetwork()` runs both **unchanged** — +every gap is peripheral (tokenizer, pooling, text prefixes). + +## Design + +### D1. Pooling modes (BGE) + +`BertEncoderRuntime` hard-codes masked mean pooling. Add: + +```kotlin +public enum class BertPooling { MEAN, CLS } +``` + +- Constructor/`createBertEncoderRuntime` parameter, default `MEAN` (source- and + behavior-compatible; BCV dumps regenerate for the new signatures). +- `encode()`: `CLS` takes hidden-state row 0; `MEAN` keeps the existing masked mean. + Pooling stays **outside** the traced encoder graph (unchanged export/OPTIMIZED path). +- Detection: sentence-transformers `1_Pooling/config.json` (`pooling_mode_cls_token` + / `pooling_mode_mean_tokens`) parsed next to `BertConfigParser`; both factories wire + it through. Absent file → `MEAN` (status quo). + +### D2. Query/document asymmetry (E5 requires, BGE recommends) + +Retrieval models embed queries and documents differently (instruction prefixes). + +- **SPI (additive):** `EmbeddingModel` gains `embedQuery(text)` and + `embedDocument(text)` / `embedDocuments(texts)` with defaults delegating to + `embed(...)` — existing implementations and consumers are untouched. +- **`PrefixedEmbeddingModel`** decorator (llm-providers): prepends per-role prefixes; + `embed(...)` uses the document role (indexing is the common bulk path). +- **`EmbeddingModelProfiles`** registry keyed by repo-id prefix: + `intfloat/multilingual-e5-*` → `("query: ", "passage: ")`; + `BAAI/bge-*-en-*` → `("Represent this sentence for searching relevant passages: ", "")`. + `fromHuggingFace` applies the profile automatically; `fromSafeTensors` accepts + explicit prefix parameters (no repo id to key on). + +### D3. Unigram tokenizer (E5) — Phase 2 + +`HuggingFaceTokenizer` is WordPiece-only with hard-coded `[CLS]`/`[SEP]`. E5 needs +SentencePiece **Unigram** (Viterbi over a 250k scored vocab) plus XLM-R special tokens +(``/``, ids 0/2). + +- Source of truth: `tokenizer.json` (embeds the scored vocab + post-processor — + no protobuf `.model` parsing). +- Normalizer: XLM-R uses a `Precompiled` charsmap; we approximate with NFKC and + **quantify drift** with a multilingual parity corpus vs HF `tokenizers` (CP-3). +- Placement decision at CP-3 (see checkpoints): transformers-side + (`llm-inference/bert`, no engine release needed) vs engine + (`sk.ainet.io.tokenizer`, where SentencePiece-BPE lives, requires engine release). + Default: transformers-side first, upstream to the engine later. + +### Out of scope + +Max/sqrt-len pooling modes, two-segment (cross-encoder) inputs, matryoshka dims, +serving-side batching. + +## Traceable implementation plan + +Gitflow: `feature/*` → `develop` → `release/x.y.z` → tag (publishes to Maven Central). + +| ID | Work item | Repo / branch | Release | Status | +|---|---|---|---|---| +| E5BGE-0 | This design doc | SK-tr `feature/embedding-pooling-profiles` | 0.37.0 | in progress | +| E5BGE-1 | `BertPooling` (MEAN/CLS) + `1_Pooling` detection + tests | SK-tr, same branch | 0.37.0 | in progress | +| E5BGE-2 | SPI `embedQuery`/`embedDocument` + `PrefixedEmbeddingModel` + profiles | SK-tr, same branch | 0.37.0 | in progress | +| E5BGE-3 | BGE end-to-end verification (CLS, 384 dims, prefix) + smoke entry | SK-tr, same branch | 0.37.0 | pending | +| E5BGE-4 | Unigram tokenizer spike → placement decision | SK-tr (or engine per CP-3) | 0.38.0 | pending | +| E5BGE-5 | Unigram impl + multilingual parity + special-token generalization | per CP-3 | 0.38.0 | pending | +| E5BGE-6 | E5 end-to-end verification + docs + benchmark matrix entry | SK-tr | 0.38.0 | pending | +| E5BGE-7 | leaf-cli: `embedQuery`/`embedDocument` wiring + dim recorded in index | SK-leaf `feature/*` → `develop` | after 0.37.0 | pending | + +## Checkpoints (upstream-change gates) + +- **CP-1 — Phase 1 code complete** (E5BGE-1..2): full build + `apiCheck` green, + pooling/prefix unit tests pass. *Engine changes required: none.* +- **CP-2 — BGE verified** (E5BGE-3): `fromHuggingFace("BAAI/bge-small-en-v1.5")` + produces 384-dim CLS-pooled embeddings; parity vs sentence-transformers reference + vectors; PR to `develop`; ship as **0.37.0**. *Gate: confirm no engine release is on + the critical path — if anything surfaced, stop and file engine issues first.* + **Gate outcome (2026-07-14):** one engine gap surfaced — `SafeTensorsParametersLoader` + cannot skip BGE's persisted I64 `embeddings.position_ids` buffer + ([SKaiNET#822](https://github.com/SKaiNET-developers/SKaiNET/issues/822)). Bridged by + the interim `FloatSafeTensorsLoader` in llm-providers (permute-handler pattern from + 0.36.0: local, documented, dropped when the engine fix ships). **No engine release on + the 0.37.0 critical path.** +- **CP-3 — Tokenizer placement decision** (E5BGE-4): spike Unigram-from-tokenizer.json; + measure normalizer drift on a multilingual corpus. **Decision recorded here**: + transformers-side vs engine-side. *This is the explicit "are upstream-engine changes + required?" gate for Phase 2.* +- **CP-4 — E5 verified** (E5BGE-5..6): parity ≤ 1e-5 vs sentence-transformers on the + parity corpus (drift from the normalizer approximation documented), smoke green; + ship as **0.38.0**. + +## Benchmark baseline hook + +Once CP-2 lands, the embedding benchmark matrix becomes +`{mdbr-leaf-ir, bge-small-en-v1.5} × {DIRECT, OPTIMIZED}`, extended by +`multilingual-e5-small` at CP-4 — measured with the existing `llm-performance` +harness plus the `PhaseProfile` diagnostic (branch `perf/llama-packed-load-memory`) +for the non-matmul tail. diff --git a/llm-api/api/jvm/llm-api.api b/llm-api/api/jvm/llm-api.api index 3207bc29..271be6c8 100644 --- a/llm-api/api/jvm/llm-api.api +++ b/llm-api/api/jvm/llm-api.api @@ -117,6 +117,9 @@ public abstract interface class sk/ainet/llm/api/EmbeddingModel : java/lang/Auto public fun close ()V public fun embed (Ljava/lang/String;)[F public fun embed (Ljava/util/List;)Ljava/util/List; + public fun embedDocument (Ljava/lang/String;)[F + public fun embedDocuments (Ljava/util/List;)Ljava/util/List; + public fun embedQuery (Ljava/lang/String;)[F public abstract fun getDimensions ()I } diff --git a/llm-api/src/commonMain/kotlin/sk/ainet/llm/api/EmbeddingModel.kt b/llm-api/src/commonMain/kotlin/sk/ainet/llm/api/EmbeddingModel.kt index 625bb58b..910ccf90 100644 --- a/llm-api/src/commonMain/kotlin/sk/ainet/llm/api/EmbeddingModel.kt +++ b/llm-api/src/commonMain/kotlin/sk/ainet/llm/api/EmbeddingModel.kt @@ -20,6 +20,20 @@ public interface EmbeddingModel : AutoCloseable { public fun embed(texts: List): List = call(EmbeddingRequest(texts)).embeddings.sortedBy { it.index }.map { it.vector } + /** + * Embed a *search query*. Retrieval-tuned models embed queries and + * documents asymmetrically (instruction prefixes — E5's `query: `, BGE's + * `Represent this sentence for searching relevant passages: `); models + * without that distinction inherit this default, which is plain [embed]. + */ + public fun embedQuery(text: String): FloatArray = embed(text) + + /** Embed a *document / passage* for indexing. See [embedQuery]. */ + public fun embedDocument(text: String): FloatArray = embed(text) + + /** Batch [embedDocument], vectors in input order. */ + public fun embedDocuments(texts: List): List = embed(texts) + /** Output vector dimensionality. */ public val dimensions: Int diff --git a/llm-inference/bert/api/jvm/bert.api b/llm-inference/bert/api/jvm/bert.api index 57b3bad3..16fd09ae 100644 --- a/llm-inference/bert/api/jvm/bert.api +++ b/llm-inference/bert/api/jvm/bert.api @@ -6,6 +6,7 @@ public final class sk/ainet/models/bert/BertConfigParser { public static final field INSTANCE Lsk/ainet/models/bert/BertConfigParser; public final fun parse (Ljava/lang/String;Ljava/lang/String;)Lsk/ainet/models/bert/BertModelConfig; public static synthetic fun parse$default (Lsk/ainet/models/bert/BertConfigParser;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lsk/ainet/models/bert/BertModelConfig; + public final fun parsePooling (Ljava/lang/String;)Lsk/ainet/models/bert/BertPooling; } public final class sk/ainet/models/bert/BertEmbeddings : sk/ainet/lang/nn/Module, sk/ainet/lang/nn/topology/ModuleParameters { @@ -17,8 +18,8 @@ public final class sk/ainet/models/bert/BertEmbeddings : sk/ainet/lang/nn/Module } public final class sk/ainet/models/bert/BertEncoderRuntime { - public fun (Lsk/ainet/lang/nn/Module;Lsk/ainet/models/bert/BertModelConfig;Lsk/ainet/context/ExecutionContext;Lkotlin/reflect/KClass;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/models/bert/BertExecutionMode;)V - public synthetic fun (Lsk/ainet/lang/nn/Module;Lsk/ainet/models/bert/BertModelConfig;Lsk/ainet/context/ExecutionContext;Lkotlin/reflect/KClass;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/models/bert/BertExecutionMode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lsk/ainet/lang/nn/Module;Lsk/ainet/models/bert/BertModelConfig;Lsk/ainet/context/ExecutionContext;Lkotlin/reflect/KClass;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/models/bert/BertExecutionMode;Lsk/ainet/models/bert/BertPooling;)V + public synthetic fun (Lsk/ainet/lang/nn/Module;Lsk/ainet/models/bert/BertModelConfig;Lsk/ainet/context/ExecutionContext;Lkotlin/reflect/KClass;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/lang/tensor/Tensor;Lsk/ainet/models/bert/BertExecutionMode;Lsk/ainet/models/bert/BertPooling;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun compile (I)Ljava/util/List; public static synthetic fun compile$default (Lsk/ainet/models/bert/BertEncoderRuntime;IILjava/lang/Object;)Ljava/util/List; public final fun encode ([I[I)[F @@ -73,6 +74,14 @@ public final class sk/ainet/models/bert/BertNetworkLoader { public final fun normalizeTensorNames (Ljava/util/List;)Ljava/util/List; } +public final class sk/ainet/models/bert/BertPooling : java/lang/Enum { + public static final field CLS Lsk/ainet/models/bert/BertPooling; + public static final field MEAN Lsk/ainet/models/bert/BertPooling; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lsk/ainet/models/bert/BertPooling; + public static fun values ()[Lsk/ainet/models/bert/BertPooling; +} + public final class sk/ainet/models/bert/HuggingFaceTokenizer : sk/ainet/apps/llm/Tokenizer { public static final field Companion Lsk/ainet/models/bert/HuggingFaceTokenizer$Companion; public fun decode (I)Ljava/lang/String; diff --git a/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertConfig.kt b/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertConfig.kt index a130a16f..ac1aeb55 100644 --- a/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertConfig.kt +++ b/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertConfig.kt @@ -67,4 +67,23 @@ public object BertConfigParser { projectionDim = projectionDim, ) } + + /** + * Parses a sentence-transformers `1_Pooling/config.json` into a + * [BertPooling] mode. `pooling_mode_cls_token: true` selects + * [BertPooling.CLS]; everything else (including `null` for snapshots + * without the file) falls back to [BertPooling.MEAN] — the pre-existing + * behavior. Max and sqrt-len pooling are rejected explicitly rather than + * silently mis-pooled. + */ + public fun parsePooling(poolingConfigJson: String?): BertPooling { + if (poolingConfigJson == null) return BertPooling.MEAN + fun flag(key: String) = + Regex("\"$key\"\\s*:\\s*(true|false)").find(poolingConfigJson)?.groupValues?.get(1) == "true" + + require(!flag("pooling_mode_max_tokens") && !flag("pooling_mode_mean_sqrt_len_tokens")) { + "Unsupported pooling mode in 1_Pooling/config.json (max / mean_sqrt_len)" + } + return if (flag("pooling_mode_cls_token")) BertPooling.CLS else BertPooling.MEAN + } } diff --git a/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertEncoderRuntime.kt b/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertEncoderRuntime.kt index 1c4df175..dc345f2c 100644 --- a/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertEncoderRuntime.kt +++ b/llm-inference/bert/src/commonMain/kotlin/sk/ainet/models/bert/BertEncoderRuntime.kt @@ -19,6 +19,7 @@ import sk.ainet.lang.tensor.div import sk.ainet.lang.tensor.matmul import sk.ainet.lang.tensor.mean import sk.ainet.lang.tensor.plus +import sk.ainet.lang.tensor.reshape import sk.ainet.lang.tensor.sqrt import sk.ainet.lang.tensor.sum import sk.ainet.lang.tensor.t @@ -36,6 +37,20 @@ public enum class BertExecutionMode { OPTIMIZED, } +/** + * Sentence-pooling strategy applied by [BertEncoderRuntime.encode] on top of + * the encoder's hidden states — the sentence-transformers `1_Pooling` module. + * Pooling stays outside the traced graph, so the choice has no effect on the + * OPTIMIZED path or the StableHLO export. + */ +public enum class BertPooling { + /** Mask-weighted mean over token positions (LEAF, E5, MiniLM). */ + MEAN, + + /** Hidden state of the first token — `[CLS]` (BGE, GTE). */ + CLS, +} + /** * Encoder runtime for BERT sentence embeddings on the DSL path. * @@ -61,6 +76,7 @@ public class BertEncoderRuntime( private val projectionWeight: Tensor? = null, private val projectionBias: Tensor? = null, private val mode: BertExecutionMode = BertExecutionMode.DIRECT, + private val pooling: BertPooling = BertPooling.MEAN, ) { /** Output dimensionality of [encode]: projection out-features when present, else hidden size. */ @@ -79,32 +95,37 @@ public class BertEncoderRuntime( } /** - * Encode tokens into a single embedding vector: forward → mean pooling - * (mask-weighted when [attentionMask] is given) → optional dense - * projection → L2 normalization. + * Encode tokens into a single embedding vector: forward → pooling + * ([BertPooling.MEAN], mask-weighted when [attentionMask] is given, or + * [BertPooling.CLS]) → optional dense projection → L2 normalization. * * @param tokenIds token IDs including `[CLS]` and `[SEP]` - * @param attentionMask 1 for real tokens, 0 for padding; affects pooling only + * @param attentionMask 1 for real tokens, 0 for padding; affects MEAN pooling only * @return normalized vector of size [dimensions] */ public fun encode(tokenIds: IntArray, attentionMask: IntArray? = null): FloatArray { val hiddenStates = forward(tokenIds) val seqLen = tokenIds.size - var pooled = if (attentionMask != null) { - require(attentionMask.size == seqLen) { - "BertEncoderRuntime: attentionMask size ${attentionMask.size} != tokenIds size $seqLen" + var pooled = when (pooling) { + BertPooling.CLS -> + // First row of [L, hidden] — the [CLS] position — as a [hidden] vector. + ctx.ops.narrow(hiddenStates, 0, 0, 1).reshape(Shape(config.hiddenSize)) + BertPooling.MEAN -> if (attentionMask != null) { + require(attentionMask.size == seqLen) { + "BertEncoderRuntime: attentionMask size ${attentionMask.size} != tokenIds size $seqLen" + } + val maskTensor = ctx.fromFloatArray( + Shape(seqLen, 1), dtype, + FloatArray(seqLen) { attentionMask[it].toFloat() } + ) + val masked = hiddenStates * maskTensor + val summed = masked.sum(dim = 0) + val count = attentionMask.sumOf { it }.toFloat().coerceAtLeast(1f) + summed / count + } else { + hiddenStates.mean(dim = 0) } - val maskTensor = ctx.fromFloatArray( - Shape(seqLen, 1), dtype, - FloatArray(seqLen) { attentionMask[it].toFloat() } - ) - val masked = hiddenStates * maskTensor - val summed = masked.sum(dim = 0) - val count = attentionMask.sumOf { it }.toFloat().coerceAtLeast(1f) - summed / count - } else { - hiddenStates.mean(dim = 0) } if (projectionWeight != null) { @@ -295,6 +316,7 @@ public inline fun createBertEncoderRuntime( resolver: WeightNameResolver = BertSafeTensorsNameResolver(), mode: BertExecutionMode = BertExecutionMode.DIRECT, debug: Boolean = false, + pooling: BertPooling = BertPooling.MEAN, ): BertEncoderRuntime { val model = bertNetwork(config) @@ -337,5 +359,6 @@ public inline fun createBertEncoderRuntime( projectionWeight = projectionWeight, projectionBias = projectionBias, mode = mode, + pooling = pooling, ) } diff --git a/llm-inference/bert/src/commonTest/kotlin/sk/ainet/models/bert/BertPoolingConfigTest.kt b/llm-inference/bert/src/commonTest/kotlin/sk/ainet/models/bert/BertPoolingConfigTest.kt new file mode 100644 index 00000000..f133032c --- /dev/null +++ b/llm-inference/bert/src/commonTest/kotlin/sk/ainet/models/bert/BertPoolingConfigTest.kt @@ -0,0 +1,52 @@ +package sk.ainet.models.bert + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** [BertConfigParser.parsePooling] against real sentence-transformers `1_Pooling/config.json` shapes. */ +class BertPoolingConfigTest { + + @Test + fun clsPooling_bgeShape() { + val json = """ + { + "word_embedding_dimension": 384, + "pooling_mode_cls_token": true, + "pooling_mode_mean_tokens": false, + "pooling_mode_max_tokens": false, + "pooling_mode_mean_sqrt_len_tokens": false + } + """.trimIndent() + assertEquals(BertPooling.CLS, BertConfigParser.parsePooling(json)) + } + + @Test + fun meanPooling_e5Shape() { + val json = """ + { + "word_embedding_dimension": 384, + "pooling_mode_cls_token": false, + "pooling_mode_mean_tokens": true, + "pooling_mode_max_tokens": false, + "pooling_mode_mean_sqrt_len_tokens": false + } + """.trimIndent() + assertEquals(BertPooling.MEAN, BertConfigParser.parsePooling(json)) + } + + @Test + fun missingFile_defaultsToMean() { + assertEquals(BertPooling.MEAN, BertConfigParser.parsePooling(null)) + } + + @Test + fun unsupportedModes_areRejectedNotSilentlyMispooled() { + assertFailsWith { + BertConfigParser.parsePooling("""{"pooling_mode_max_tokens": true}""") + } + assertFailsWith { + BertConfigParser.parsePooling("""{"pooling_mode_mean_sqrt_len_tokens": true}""") + } + } +} diff --git a/llm-inference/bert/src/jvmTest/kotlin/sk/ainet/models/bert/BertEncoderRuntimeTest.kt b/llm-inference/bert/src/jvmTest/kotlin/sk/ainet/models/bert/BertEncoderRuntimeTest.kt index 2fa39153..d3097505 100644 --- a/llm-inference/bert/src/jvmTest/kotlin/sk/ainet/models/bert/BertEncoderRuntimeTest.kt +++ b/llm-inference/bert/src/jvmTest/kotlin/sk/ainet/models/bert/BertEncoderRuntimeTest.kt @@ -187,4 +187,43 @@ class BertEncoderRuntimeTest { fun encode_emptyInputIsRejected() { assertFailsWith { runtime().encode(intArrayOf()) } } + + @Test + fun encode_clsPooling_takesFirstTokenRow() { + val cfg = config() + val rt = createBertEncoderRuntime(cfg, syntheticTensors(cfg), ctx, pooling = BertPooling.CLS) + val tokens = intArrayOf(1, 2, 3) + + // Expected: hidden-state row 0, L2-normalized (no projection configured). + val hidden = rt.forward(tokens) + val row = FloatArray(h) { c -> hidden.data[0, c] } + val norm = sqrt(row.sumOf { (it * it).toDouble() }).toFloat() + + val got = rt.encode(tokens) + assertEquals(h, got.size) + for (i in 0 until h) { + assertTrue(abs(row[i] / norm - got[i]) < 1e-5f, "CLS row mismatch at $i") + } + } + + @Test + fun encode_clsPooling_differsFromMeanOnMultiTokenInput() { + val cfg = config() + val cls = createBertEncoderRuntime(cfg, syntheticTensors(cfg), ctx, pooling = BertPooling.CLS) + .encode(intArrayOf(1, 2, 3)) + val mean = runtime().encode(intArrayOf(1, 2, 3)) + val diff = cls.indices.sumOf { abs(cls[it] - mean[it]).toDouble() } + assertTrue(diff > 1e-4, "CLS and MEAN pooling must differ on varied hidden states: diff=$diff") + } + + @Test + fun encode_clsPooling_ignoresAttentionMask() { + val cfg = config() + val rt = createBertEncoderRuntime(cfg, syntheticTensors(cfg), ctx, pooling = BertPooling.CLS) + val plain = rt.encode(intArrayOf(1, 2, 0)) + val masked = rt.encode(intArrayOf(1, 2, 0), attentionMask = intArrayOf(1, 1, 0)) + for (i in plain.indices) { + assertTrue(abs(plain[i] - masked[i]) < 1e-6f, "CLS pooling must not depend on the mask") + } + } } diff --git a/llm-providers/api/llm-providers.api b/llm-providers/api/llm-providers.api index 7d502d39..27077552 100644 --- a/llm-providers/api/llm-providers.api +++ b/llm-providers/api/llm-providers.api @@ -9,7 +9,42 @@ public final class sk/ainet/llm/providers/BertEmbeddingModel { public static final fun fromSafeTensors (Ljava/nio/file/Path;Lsk/ainet/context/ExecutionContext;)Lsk/ainet/llm/api/EmbeddingModel; public static final fun fromSafeTensors (Ljava/nio/file/Path;Lsk/ainet/context/ExecutionContext;Lsk/ainet/models/bert/BertExecutionMode;)Lsk/ainet/llm/api/EmbeddingModel; public static final fun fromSafeTensors (Ljava/nio/file/Path;Lsk/ainet/context/ExecutionContext;Lsk/ainet/models/bert/BertExecutionMode;Ljava/lang/String;)Lsk/ainet/llm/api/EmbeddingModel; - public static synthetic fun fromSafeTensors$default (Ljava/nio/file/Path;Lsk/ainet/context/ExecutionContext;Lsk/ainet/models/bert/BertExecutionMode;Ljava/lang/String;ILjava/lang/Object;)Lsk/ainet/llm/api/EmbeddingModel; + public static final fun fromSafeTensors (Ljava/nio/file/Path;Lsk/ainet/context/ExecutionContext;Lsk/ainet/models/bert/BertExecutionMode;Ljava/lang/String;Lsk/ainet/llm/providers/EmbeddingPrefixes;)Lsk/ainet/llm/api/EmbeddingModel; + public static synthetic fun fromSafeTensors$default (Ljava/nio/file/Path;Lsk/ainet/context/ExecutionContext;Lsk/ainet/models/bert/BertExecutionMode;Ljava/lang/String;Lsk/ainet/llm/providers/EmbeddingPrefixes;ILjava/lang/Object;)Lsk/ainet/llm/api/EmbeddingModel; +} + +public final class sk/ainet/llm/providers/EmbeddingModelProfiles { + public static final field INSTANCE Lsk/ainet/llm/providers/EmbeddingModelProfiles; + public final fun apply (Lsk/ainet/llm/api/EmbeddingModel;Ljava/lang/String;)Lsk/ainet/llm/api/EmbeddingModel; + public final fun forRepo (Ljava/lang/String;)Lsk/ainet/llm/providers/EmbeddingPrefixes; +} + +public final class sk/ainet/llm/providers/EmbeddingPrefixes { + public static final field Companion Lsk/ainet/llm/providers/EmbeddingPrefixes$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lsk/ainet/llm/providers/EmbeddingPrefixes; + public static synthetic fun copy$default (Lsk/ainet/llm/providers/EmbeddingPrefixes;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lsk/ainet/llm/providers/EmbeddingPrefixes; + public fun equals (Ljava/lang/Object;)Z + public final fun getDocument ()Ljava/lang/String; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class sk/ainet/llm/providers/EmbeddingPrefixes$Companion { + public final fun getNONE ()Lsk/ainet/llm/providers/EmbeddingPrefixes; +} + +public final class sk/ainet/llm/providers/PrefixedEmbeddingModel : sk/ainet/llm/api/EmbeddingModel { + public fun (Lsk/ainet/llm/api/EmbeddingModel;Lsk/ainet/llm/providers/EmbeddingPrefixes;)V + public fun call (Lsk/ainet/llm/api/EmbeddingRequest;)Lsk/ainet/llm/api/EmbeddingResponse; + public fun close ()V + public fun embedDocument (Ljava/lang/String;)[F + public fun embedDocuments (Ljava/util/List;)Ljava/util/List; + public fun embedQuery (Ljava/lang/String;)[F + public fun getDimensions ()I } public final class sk/ainet/llm/providers/SkaiNetChatModel : sk/ainet/llm/api/StreamingChatModel { diff --git a/llm-providers/src/main/kotlin/sk/ainet/llm/providers/BertEmbeddingModel.kt b/llm-providers/src/main/kotlin/sk/ainet/llm/providers/BertEmbeddingModel.kt index a7c5dadc..d0f0a284 100644 --- a/llm-providers/src/main/kotlin/sk/ainet/llm/providers/BertEmbeddingModel.kt +++ b/llm-providers/src/main/kotlin/sk/ainet/llm/providers/BertEmbeddingModel.kt @@ -10,7 +10,6 @@ import sk.ainet.data.source.DataSourceException import sk.ainet.data.source.DataSourceRequest import sk.ainet.data.source.JvmDataSourceResolver import sk.ainet.io.JvmRandomAccessSource -import sk.ainet.io.safetensors.SafeTensorsParametersLoader import sk.ainet.llm.api.EmbeddingModel import sk.ainet.models.bert.BertConfigParser import sk.ainet.models.bert.BertExecutionMode @@ -59,6 +58,10 @@ public object BertEmbeddingModel { * @param mode eager [BertExecutionMode.DIRECT] (default) or traced/fused * [BertExecutionMode.OPTIMIZED] * @param modelId reported via [EmbeddingModel] response metadata + * @param prefixes query/document instruction prefixes for retrieval-tuned + * models; local snapshots carry no repo id to look up in + * [EmbeddingModelProfiles], so pass them explicitly (or use + * [fromHuggingFace], which applies the profile automatically) */ @JvmStatic @JvmOverloads @@ -67,6 +70,7 @@ public object BertEmbeddingModel { ctx: ExecutionContext = PooledExecutionContext(DirectCpuExecutionContext()), mode: BertExecutionMode = BertExecutionMode.DIRECT, modelId: String? = modelDir.fileName?.toString(), + prefixes: EmbeddingPrefixes = EmbeddingPrefixes.NONE, ): EmbeddingModel { val weightsFile = resolveWeightsFile(modelDir) val denseWeights = modelDir.resolve("2_Dense").resolve("model.safetensors") @@ -79,23 +83,32 @@ public object BertEmbeddingModel { if (denseConfig.exists()) denseConfig.readText() else null, ) + val poolingConfig = modelDir.resolve("1_Pooling").resolve("config.json") + val pooling = BertConfigParser.parsePooling( + if (poolingConfig.exists()) poolingConfig.readText() else null, + ) + val tokenizer = loadTokenizer(modelDir) + // FloatSafeTensorsLoader (not the engine's SafeTensorsParametersLoader): + // BGE-style checkpoints persist an I64 `embeddings.position_ids` buffer + // that the engine loader can't skip and our index-free encoder never needs. val loaders = buildList { - add(SafeTensorsParametersLoader(sourceProvider = { JvmRandomAccessSource.open(weightsFile.toString()) })) + add(FloatSafeTensorsLoader(sourceProvider = { JvmRandomAccessSource.open(weightsFile.toString()) })) if (denseWeights.exists()) { - add(SafeTensorsParametersLoader(sourceProvider = { JvmRandomAccessSource.open(denseWeights.toString()) })) + add(FloatSafeTensorsLoader(sourceProvider = { JvmRandomAccessSource.open(denseWeights.toString()) })) } } val tensors = runBlocking { BertNetworkLoader.loadWeightTensors(loaders, ctx, sk.ainet.lang.types.FP32::class) } - val runtime = createBertEncoderRuntime(config, tensors, ctx, mode = mode) + val runtime = createBertEncoderRuntime(config, tensors, ctx, mode = mode, pooling = pooling) - return SkaiNetEmbeddingModel( + val model: EmbeddingModel = SkaiNetEmbeddingModel( runtime = runtime, tokenizer = tokenizer, modelId = modelId, ) + return if (prefixes == EmbeddingPrefixes.NONE) model else PrefixedEmbeddingModel(model, prefixes) } /** @@ -116,7 +129,10 @@ public object BertEmbeddingModel { mode: BertExecutionMode = BertExecutionMode.DIRECT, ): EmbeddingModel { val modelDir = downloadSnapshot(repoId, revision) - return fromSafeTensors(modelDir, ctx, mode, modelId = repoId) + return fromSafeTensors( + modelDir, ctx, mode, modelId = repoId, + prefixes = EmbeddingModelProfiles.forRepo(repoId), + ) } // ------------------------------------------------------------------ @@ -128,6 +144,7 @@ public object BertEmbeddingModel { private val OPTIONAL_FILES = listOf( "vocab.txt", "tokenizer.json", + "1_Pooling/config.json", "2_Dense/config.json", "2_Dense/model.safetensors", ) diff --git a/llm-providers/src/main/kotlin/sk/ainet/llm/providers/FloatSafeTensorsLoader.kt b/llm-providers/src/main/kotlin/sk/ainet/llm/providers/FloatSafeTensorsLoader.kt new file mode 100644 index 00000000..e6346c45 --- /dev/null +++ b/llm-providers/src/main/kotlin/sk/ainet/llm/providers/FloatSafeTensorsLoader.kt @@ -0,0 +1,76 @@ +package sk.ainet.llm.providers + +import sk.ainet.context.ExecutionContext +import sk.ainet.io.ParametersLoader +import sk.ainet.io.RandomAccessSource +import sk.ainet.io.model.DataType +import sk.ainet.io.safetensors.StreamingSafeTensorsReader +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.Tensor +import sk.ainet.lang.types.DType +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.reflect.KClass + +/** + * [ParametersLoader] over a SafeTensors file that loads *float weights only*, + * silently skipping integer buffers. + * + * Why this exists: HF BERT checkpoints exported by older `transformers` + * versions persist non-weight buffers — `embeddings.position_ids` (I64 + * `arange`) in BGE among others. The engine's `SafeTensorsParametersLoader` + * has no tensor filter and fails the whole load on the I64 → FP32 conversion. + * Our DSL `BertEmbeddings` is index-free, so these buffers are never needed. + * + * Interim: remove once the engine's loader accepts a tensor-name filter + * (SKaiNET-developers/SKaiNET#822) and route BERT loads back through + * `SafeTensorsParametersLoader`. + */ +internal class FloatSafeTensorsLoader( + private val sourceProvider: () -> RandomAccessSource, +) : ParametersLoader { + + @Suppress("UNCHECKED_CAST") + override suspend fun load( + ctx: ExecutionContext, + dtype: KClass, + onTensorLoaded: (name: String, tensor: Tensor) -> Unit, + ) { + StreamingSafeTensorsReader.open(sourceProvider()).use { reader -> + for (info in reader.tensors) { + val floats = when (info.dataType) { + DataType.FLOAT32 -> f32(reader.loadTensorData(info)) + DataType.FLOAT64 -> f64(reader.loadTensorData(info)) + DataType.FLOAT16 -> f16(reader.loadTensorData(info)) + DataType.BFLOAT16 -> bf16(reader.loadTensorData(info)) + // Non-float buffers (position_ids & friends) are never + // BERT weights — skip instead of failing the load. + else -> continue + } + val shape = Shape(*info.shape.map { it.toInt() }.toIntArray()) + val tensor = ctx.fromFloatArray(shape, dtype, floats) as Tensor + onTensorLoaded(info.name, tensor) + } + } + } + + private fun f32(bytes: ByteArray): FloatArray { + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + return FloatArray(buf.remaining()).also { buf.get(it) } + } + + private fun f64(bytes: ByteArray): FloatArray { + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asDoubleBuffer() + return FloatArray(buf.remaining()) { buf.get(it).toFloat() } + } + + private fun f16(bytes: ByteArray): FloatArray { + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() + return FloatArray(buf.remaining()) { java.lang.Float.float16ToFloat(buf.get(it)) } + } + + private fun bf16(bytes: ByteArray): FloatArray { + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer() + return FloatArray(buf.remaining()) { Float.fromBits(buf.get(it).toInt() shl 16) } + } +} diff --git a/llm-providers/src/main/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModel.kt b/llm-providers/src/main/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModel.kt new file mode 100644 index 00000000..1a4e155a --- /dev/null +++ b/llm-providers/src/main/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModel.kt @@ -0,0 +1,89 @@ +package sk.ainet.llm.providers + +import sk.ainet.llm.api.EmbeddingModel +import sk.ainet.llm.api.EmbeddingRequest +import sk.ainet.llm.api.EmbeddingResponse + +/** + * Per-role instruction prefixes for retrieval-tuned embedding models — the + * asymmetry between "embed this query" and "embed this passage" that models + * like E5 *require* and BGE recommend for short queries. + */ +public data class EmbeddingPrefixes( + val query: String, + val document: String, +) { + public companion object { + /** No prefixes — symmetric models (LEAF, MiniLM). */ + public val NONE: EmbeddingPrefixes = EmbeddingPrefixes("", "") + } +} + +/** + * Decorates an [EmbeddingModel] with per-role prefixes: [embedQuery] prepends + * [EmbeddingPrefixes.query], [embedDocument]/[embedDocuments] prepend + * [EmbeddingPrefixes.document]. Untyped calls ([call], [embed]) use the + * *document* role — indexing is the bulk path, and an unprefixed E5 embedding + * is wrong for both roles, so defaulting to document keeps `embed(chunks)` + * correct. + */ +public class PrefixedEmbeddingModel( + private val delegate: EmbeddingModel, + private val prefixes: EmbeddingPrefixes, +) : EmbeddingModel { + + override fun call(request: EmbeddingRequest): EmbeddingResponse = + delegate.call(request.copy(inputs = request.inputs.map { prefixes.document + it })) + + override fun embedQuery(text: String): FloatArray = + delegate.embed(prefixes.query + text) + + override fun embedDocument(text: String): FloatArray = + delegate.embed(prefixes.document + text) + + override fun embedDocuments(texts: List): List = + delegate.embed(texts.map { prefixes.document + it }) + + override val dimensions: Int get() = delegate.dimensions + + override fun close(): Unit = delegate.close() +} + +/** + * Known per-model prefix profiles, keyed by Hugging Face repo-id prefix + * (case-insensitive). [BertEmbeddingModel.fromHuggingFace] consults this + * registry automatically; local-snapshot loads pass prefixes explicitly. + */ +public object EmbeddingModelProfiles { + + private val profiles: List> = listOf( + // E5: both roles REQUIRE prefixes (intfloat model card). + "intfloat/multilingual-e5" to EmbeddingPrefixes(query = "query: ", document = "passage: "), + "intfloat/e5" to EmbeddingPrefixes(query = "query: ", document = "passage: "), + // BGE v1.5 English: query instruction recommended for short queries; passages plain. + "BAAI/bge-small-en" to EmbeddingPrefixes( + query = "Represent this sentence for searching relevant passages: ", + document = "", + ), + "BAAI/bge-base-en" to EmbeddingPrefixes( + query = "Represent this sentence for searching relevant passages: ", + document = "", + ), + "BAAI/bge-large-en" to EmbeddingPrefixes( + query = "Represent this sentence for searching relevant passages: ", + document = "", + ), + ) + + /** Prefixes for [repoId], or [EmbeddingPrefixes.NONE] when unknown. */ + public fun forRepo(repoId: String): EmbeddingPrefixes = + profiles.firstOrNull { (prefix, _) -> repoId.startsWith(prefix, ignoreCase = true) } + ?.second + ?: EmbeddingPrefixes.NONE + + /** Wrap [model] with the profile for [repoId]; returns [model] unchanged when none applies. */ + public fun apply(model: EmbeddingModel, repoId: String): EmbeddingModel { + val prefixes = forRepo(repoId) + return if (prefixes == EmbeddingPrefixes.NONE) model else PrefixedEmbeddingModel(model, prefixes) + } +} diff --git a/llm-providers/src/test/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModelTest.kt b/llm-providers/src/test/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModelTest.kt new file mode 100644 index 00000000..d059a1c3 --- /dev/null +++ b/llm-providers/src/test/kotlin/sk/ainet/llm/providers/PrefixedEmbeddingModelTest.kt @@ -0,0 +1,67 @@ +package sk.ainet.llm.providers + +import sk.ainet.llm.api.Embedding +import sk.ainet.llm.api.EmbeddingModel +import sk.ainet.llm.api.EmbeddingRequest +import sk.ainet.llm.api.EmbeddingResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class PrefixedEmbeddingModelTest { + + /** Records every text handed to the delegate. */ + private class RecordingModel : EmbeddingModel { + val seen = mutableListOf() + override fun call(request: EmbeddingRequest): EmbeddingResponse { + seen += request.inputs + return EmbeddingResponse( + request.inputs.mapIndexed { i, _ -> Embedding(i, floatArrayOf(1f)) }, + ) + } + override val dimensions: Int = 1 + } + + private val e5 = EmbeddingPrefixes(query = "query: ", document = "passage: ") + + @Test + fun embedQuery_prependsQueryPrefix() { + val delegate = RecordingModel() + PrefixedEmbeddingModel(delegate, e5).embedQuery("what is a pangram?") + assertEquals(listOf("query: what is a pangram?"), delegate.seen) + } + + @Test + fun embedDocument_andBatch_prependDocumentPrefix() { + val delegate = RecordingModel() + val model = PrefixedEmbeddingModel(delegate, e5) + model.embedDocument("a") + model.embedDocuments(listOf("b", "c")) + assertEquals(listOf("passage: a", "passage: b", "passage: c"), delegate.seen) + } + + @Test + fun untypedCalls_useDocumentRole() { + val delegate = RecordingModel() + val model = PrefixedEmbeddingModel(delegate, e5) + model.embed("a") + model.call(EmbeddingRequest(listOf("b"))) + assertEquals(listOf("passage: a", "passage: b"), delegate.seen) + } + + @Test + fun profiles_e5AndBgeResolve_caseInsensitive() { + assertEquals("query: ", EmbeddingModelProfiles.forRepo("intfloat/multilingual-e5-small").query) + assertEquals("passage: ", EmbeddingModelProfiles.forRepo("INTFLOAT/Multilingual-E5-Large").document) + val bge = EmbeddingModelProfiles.forRepo("BAAI/bge-small-en-v1.5") + assertEquals("Represent this sentence for searching relevant passages: ", bge.query) + assertEquals("", bge.document) + } + + @Test + fun profiles_unknownRepo_returnsModelUnwrapped() { + assertEquals(EmbeddingPrefixes.NONE, EmbeddingModelProfiles.forRepo("MongoDB/mdbr-leaf-ir")) + val delegate = RecordingModel() + assertSame(delegate, EmbeddingModelProfiles.apply(delegate, "MongoDB/mdbr-leaf-ir")) + } +}