feat: LFM2.5 text-embedding & ColBERT (MLX/XNNPACK) with prompts and multi-vector output#1269
feat: LFM2.5 text-embedding & ColBERT (MLX/XNNPACK) with prompts and multi-vector output#1269NorbertKlockiewicz wants to merge 14 commits into
Conversation
…xSim
Add the LFM2.5-Embedding-350M and LFM2.5-ColBERT-350M models, served from
HuggingFace (MLX on iOS, XNNPACK on Android / iOS simulator).
Text embeddings are unified into one runner and one hook: the native
TextEmbeddings model returns the raw [numTokens, embeddingDim] matrix
(numTokens === 1 for pooled models, the full sequence for multi-vector /
late-interaction models like ColBERT), plus the input token ids. The TS
layer reduces it — toVector() for the single-vector case, getTokenVectors()
and maxSim() for late interaction.
Models trained with asymmetric query/document prompts (LFM uses query:/
document:, ColBERT uses [Q] /[D] ) carry a "prompts" config; forward then
requires a role argument ('query' | 'document') that auto-prepends the
prompt. The role is type-enforced: required for prompted models, forbidden
for plain ones.
Also: tokenizer post_processor is now applied for text embeddings so the
BOS special token is added (CLS-pooled models depend on it), and the
text-to-image Encoder reads the new EmbeddingResult.
Example app gains a semantic-search screen and a ColBERT late-interaction
search screen demonstrating MaxSim.
Authored with Claude.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b1f5bdd to
50e80e1
Compare
- Migrate the segment-anything (SAM) screen to toVector(forward()) — its CLIP-text path broke when forward started returning EmbeddingResult. - Update the C++ TextEmbeddings integration test for the EmbeddingResult return type (was still using the old OwningArrayBuffer pointer API). - Guard the per-token invariant: throw InvalidModelOutput if output rows != input token count (pooled numTokens==1 exempt), so skiplist masking can't silently misalign if a graph pads/truncates. - Dedup encode()/encodeWithSpecialTokens() into a shared encodeImpl. - Drop the redundant Float32Array copy at the JSI boundary; document the getTokenVectors view lifetime; remove dead BaseEmbeddings::postprocess. Authored with Claude. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
forward(text) returns a single pooled Float32Array again for standard models — restoring the original API, so MiniLM/MPNet/CLIP/SAM consumers need no migration. The reduction (row 0 of the native [numTokens, embeddingDim] matrix) happens in the TS module, not at the call site. Multi-vector (late-interaction) models opt in via a `multiVector: true` config flag; for those, forward returns the full per-token EmbeddingResult so MaxSim/skiplist work. Return type is discriminated by the flag, and the role argument by `prompts` (required when prompted, none when not). Authored with Claude. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ents Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| fontSize: 14, | ||
| fontWeight: '600', | ||
| color: '#0F172A', | ||
| fontVariant: ['tabular-nums'], |
There was a problem hiding this comment.
add nums to cspell or exclude demo apps from cspell.
| const maxSim = ( | ||
| query: EmbeddingResult, | ||
| doc: EmbeddingResult, | ||
| skip: number[] = [] | ||
| ) => { | ||
| const dim = query.embeddingDim; | ||
| const skipped = new Set(skip); | ||
| let score = 0; | ||
| for (let qi = 0; qi < query.numTokens; qi++) { | ||
| const qOff = qi * dim; | ||
| let best = -Infinity; | ||
| for (let di = 0; di < doc.numTokens; di++) { | ||
| if (skipped.has(doc.tokenIds[di])) continue; | ||
| const dOff = di * dim; | ||
| let dot = 0; | ||
| for (let k = 0; k < dim; k++) { | ||
| dot += query.vectors[qOff + k] * doc.vectors[dOff + k]; | ||
| } | ||
| if (dot > best) best = dot; | ||
| } | ||
| if (best !== -Infinity) score += best; | ||
| } | ||
| return score; |
There was a problem hiding this comment.
I saw you used exactly the same function in demo app. Don't we want to expose it as a helper?
There was a problem hiding this comment.
It probably won't hurt to expose it as a util, right now I did it the same way a dotProduct function is done to be consistent, but we can expose those two as a helper for text embeddings.
There was a problem hiding this comment.
I know that we need to be reactive and upload the newest models to our lib to be available immediately, but I don't like the mechanism that it goes via patches. We should think of something better for the future to not edit released documentation etc.
There was a problem hiding this comment.
I agree, it's especially hard when there's the need to update docs. Users on versions before the patch will look at v0.9.0 docs and it will differ from the things that library ships for them. I don't really have an idea for that so here we are to make a decision if we ship it in the patch or defer to 0.10.0.
There was a problem hiding this comment.
are docs the problem or the entire flow? I dont think there is an option to avoid patches if we need to make code changes alongside. Waiting and batching models in a regular update makes it unnecessarily slow
There was a problem hiding this comment.
I would make them pre-releases of the next version, not the patches of the already released version. Till release of v.0.10, we can keep this flow, but after that, we should release pre-0.11 or so, do not update versioned docs and make it smoother.
| export const maxSim = ( | ||
| query: EmbeddingResult, | ||
| doc: EmbeddingResult, | ||
| skipListIds: number[] = [] | ||
| ) => { | ||
| const dim = query.embeddingDim; | ||
| const skip = new Set(skipListIds); | ||
| let score = 0; | ||
| for (let qi = 0; qi < query.numTokens; qi++) { | ||
| const qOff = qi * dim; | ||
| let best = -Infinity; | ||
| for (let di = 0; di < doc.numTokens; di++) { | ||
| if (skip.has(doc.tokenIds[di]!)) continue; | ||
| const dOff = di * dim; | ||
| let dot = 0; | ||
| for (let k = 0; k < dim; k++) { | ||
| dot += (query.vectors[qOff + k] ?? 0) * (doc.vectors[dOff + k] ?? 0); | ||
| } | ||
| if (dot > best) best = dot; | ||
| } | ||
| if (best !== -Infinity) score += best; | ||
| } | ||
| return score; | ||
| }; |
There was a problem hiding this comment.
wouldnt it make sense to have it in the lib?
| - **Pooled models** (the default, e.g. MiniLM, MPNet, LFM2.5-Embedding) resolve to a single `Float32Array` — one normalized vector for the whole input. | ||
| - **Multi-vector models** (`multiVector: true`, e.g. LFM2.5-ColBERT) resolve to an [`EmbeddingResult`](../../06-api-reference/interfaces/EmbeddingResult.md) with the per-token vectors (`vectors`, `numTokens`, `embeddingDim`, `tokenIds`). |
There was a problem hiding this comment.
maybe we could add a link to something explaining it what is the difference? maybe to liquid blog
| const maxSim = ( | ||
| query: EmbeddingResult, | ||
| doc: EmbeddingResult, | ||
| skip: number[] = [] | ||
| ) => { | ||
| const dim = query.embeddingDim; | ||
| const skipped = new Set(skip); | ||
| let score = 0; | ||
| for (let qi = 0; qi < query.numTokens; qi++) { | ||
| const qOff = qi * dim; | ||
| let best = -Infinity; | ||
| for (let di = 0; di < doc.numTokens; di++) { | ||
| if (skipped.has(doc.tokenIds[di])) continue; | ||
| const dOff = di * dim; | ||
| let dot = 0; | ||
| for (let k = 0; k < dim; k++) { | ||
| dot += query.vectors[qOff + k] * doc.vectors[dOff + k]; | ||
| } | ||
| if (dot > best) best = dot; | ||
| } | ||
| if (best !== -Infinity) score += best; | ||
| } | ||
| return score; | ||
| }; |
There was a problem hiding this comment.
If we decide to move it inside our lib this is to be removed
There was a problem hiding this comment.
are docs the problem or the entire flow? I dont think there is an option to avoid patches if we need to make code changes alongside. Waiting and batching models in a regular update makes it unnecessarily slow
| const LFM_COLBERT_SKIP_LIST = [ | ||
| 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, | ||
| 535, 536, 537, 538, 539, 540, 541, 568, 569, 570, 571, 572, 573, 600, 601, | ||
| 602, 603, | ||
| ]; | ||
|
|
||
| const LFM_COLBERT_PROMPTS = { query: '[Q] ', document: '[D] ' }; |
There was a problem hiding this comment.
I dont think models specific things should be here, move them outside like it is done for tts or ocr
Description
Adds two LFM2.5 retrieval models from Liquid AI and the API needed to use them, through the existing
useTextEmbeddingshook — one native runner, one hook, no new public surface beyond optional model-config fields:query:/document:prompts.Linear(1024→128)per token). Trained with[Q]/[D]prompts.Both run on MLX on iOS (physical device) and XNNPACK on Android, quantized (MLX int4, XNNPACK 8da4w).
To support them without breaking the existing API, the model config grew three optional fields and
forwardbecame config-driven:prompts— when present,forwardrequires arole('query' | 'document') and auto-prepends the matching prompt.multiVector— whentrue,forwardreturns a per-tokenEmbeddingResult(vectors,numTokens,embeddingDim,tokenIds); otherwise it returns a single pooledFloat32Arrayas before.skipListIds— punctuation token ids the consumer excludes from MaxSim scoring.The library auto-applies the role prompts (the matching
query:/[Q]prefix is prepended inforward), but late-interaction scoring (MaxSim) stays the consumer's concern — it runs wherever the vectors are stored. The example app demonstrates one way to score (its own localmaxSim), and the ColBERT demo is folded into the unified text-embeddings screen, picking the scorer from the model's config.Native side:
TextEmbeddings::generatereturns the raw[numTokens, embeddingDim]matrix as anEmbeddingResult; the TS layer reduces it. The emptyBaseEmbeddingsbase class was removed (TextEmbeddingsnow extendsBaseModeldirectly), and output-shape validation was extracted intoTextEmbeddings::buildResult.Review order: start with the TS types (
types/textEmbeddings.ts—ForwardFn/ForwardReturndiscriminated on the model config), then the module/hook (TextEmbeddingsModule.ts,useTextEmbeddings.ts), then the nativeTextEmbeddings.cpp/Types.h, then the registry/URLs and the example screen.Introduces a breaking change?
forwardstays non-breaking: pooled models still returnFloat32Array. The new return type androlerequirement only apply to models that opt in via config.Type of change
Tested on
Testing instructions
text-embeddingsexample app.C++ unit tests:
TextEmbeddingsTests(incl. newEmbeddingResultmetadata /tokenIdsassertions) compiles and links under the Android NDK toolchain. The suite is cross-compiled, so it is not executed on the host in this setup.Related issues
Checklist
Additional notes
MLX requires a physical iOS device — the MLX delegate does not run on the simulator (use XNNPACK there). The two models are hosted on the Software Mansion Hugging Face org; docs are updated for both
nextand the0.9.xversioned set.