Skip to content

bug: getEmbeddingFor() never prepends BOS for UGM-vocab models, even when the GGUF sets add_bos_token = true (breaks bge-m3 CLS pooling) #683

Open

Description

@amanoyu

Summary

For models whose vocabulary type is ugm (e.g. BAAI/bge-m3, an XLM-RoBERTa model converted with tokenizer.ggml.model = "t5"), LlamaEmbeddingContext.getEmbeddingFor(text) appends EOS (</s>) but never prepends BOS (<s>), even though the GGUF has tokenizer.ggml.add_bos_token = true and model.tokens.shouldPrependBosToken is true.

bge-m3 uses CLS pooling (bert.pooling_type = CLS), so the first token's output is the sentence embedding. Without <s>, the first word of the text gets pooled instead, and the embedding drifts away from the reference.

llama.cpp's own tokenizer does add BOS for UGM when add_bos is set (llama-vocab.cpp, case LLAMA_VOCAB_TYPE_UGM: if (add_special && add_bos) output.push_back(special_bos_id);), so node-llama-cpp currently diverges from llama.cpp here.

Cause

src/utils/tokenizerUtils.ts → resolveBeginningTokenToPrepend() returns null for ugm unconditionally, before checking tokens.shouldPrependBosToken:

if (vocabularyType === LlamaVocabularyType.ugm)
    return null;

This looks like it was written for T5 models (whose GGUFs have add_bos_token = false), but XLM-R-based embedding models like bge-m3 are also converted as UGM and do need BOS.

Reproduction

  • node-llama-cpp 3.22.2 (also 3.20.0), macOS arm64 (Metal), Node 26
  • Model: hf:ggml-org/bge-m3-Q8_0-GGUF/bge-m3-q8_0.gguf (same result with an F16 bge-m3 GGUF)
import {getLlama, resolveModelFile} from "node-llama-cpp";

const modelPath = await resolveModelFile("hf:ggml-org/bge-m3-Q8_0-GGUF/bge-m3-q8_0.gguf");
const llama = await getLlama();
const model = await llama.loadModel({modelPath});
// batchSize = contextSize to keep the batch-split issue (#674) out of the picture
const ctx = await model.createEmbeddingContext({contextSize: 2048, batchSize: 2048});

const cos = (a, b) => {
    let d = 0, x = 0, y = 0;
    for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; x += a[i] ** 2; y += b[i] ** 2; }
    return d / Math.sqrt(x * y);
};
const vec = async (input) => Array.from((await ctx.getEmbeddingFor(input)).vector);

console.log(model.vocabularyType, model.tokens.shouldPrependBosToken, model.tokens.shouldAppendEosToken);

const text = "The quick brown fox jumps over the lazy dog. ".repeat(12);
const tokens = model.tokenize(text);
console.log(cos(await vec(text), await vec([model.tokens.bos, ...tokens, model.tokens.eos])));
console.log(cos(await vec(text), await vec([...tokens, model.tokens.eos])));

Output:

ugm true true
0.9891   <- string input vs. [<s>, ...tokens, </s>]
1.0000   <- string input is exactly [...tokens, </s>] (no BOS)

The gap is much larger on real documents. On Japanese Markdown chunks of 670–830 tokens (same context, batchSize: 2048), cos(string, [<s>, ...tokens, </s>]) was 0.52–0.62. Passing [<s>, ...tokens] (and letting getEmbeddingFor append </s>) gives 1.0000 in every case.

[<s>, ...tokens, </s>] is the correct reference here:

  • Its token IDs match the official BAAI/bge-m3 tokenizer.json.
  • Its vectors match Ollama's bge-m3 (/api/embed) at cosine ≈ 1.00.

Impact

We hit this through qmd, which calls getEmbeddingFor(string). We ran a 60-query retrieval test over ~3,450 chunks with bge-m3 Q8_0:

Setup MRR
String input (this bug), together with the 512 batch split from #674 0.09
Passing [bos, ...tokenize(text), eos] and batchSize = contextSize 0.69
Ollama bge-m3 0.69

We did not measure the retrieval effect of the BOS bug on its own, without #674.

Suggested fix

In resolveBeginningTokenToPrepend(), let ugm fall through to the shouldPrependBosToken check (i.e. drop the ugm → null branch). This is what llama.cpp's tokenizer does. As far as I can tell, T5 GGUFs are converted with add_bos_token = false, so their behavior should stay the same (I have not checked every T5 conversion).

// before
if (vocabularyType === LlamaVocabularyType.ugm)
    return null;

// after: remove the branch above, so UGM falls through to
if (tokens.shouldPrependBosToken)
    return tokens.bos;

As a workaround, users can call getEmbeddingFor([model.tokens.bos, ...model.tokenize(text)]).

Related, but a separate problem: #674 / #678 (non-causal embeddings silently diverge when the input is longer than batchSize). bge-m3 hits both. This issue is only about the missing BOS.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions