Skip to content

Same Model, Different Embeddings: Debugging Python and Core ML

, 11 min read

What I learned reproducing an embedding pipeline across Python, Core ML, and Swift instead of stopping when the model simply ran.

When I started experimenting with RAG in Swift, embeddings were one of the first parts I wanted to understand properly.

At that point, I already understood the basic idea: turn a query and a set of documents into vectors, compare them, and retrieve the pieces that are closest to the question. What I had not thought much about was what happens when the embedding model itself has to leave the Python ecosystem.

Most of the examples I had seen started from a model that was already easy to call from Python. My situation was different because I wanted the retrieval pipeline to run locally on a Mac. That meant the embedding model eventually had to work inside a Swift application, not just inside a notebook.

My first choice was EmbeddingGemma.

It worked normally through its Python implementation, so I initially assumed the next step would be fairly mechanical: export the model to Core ML, load it in Swift, and continue building the retrieval pipeline.

That assumption did not last very long.

The Hugging Face implementation did not convert cleanly through the Core ML path I was trying. Some of the operations used by the model were not straightforward to represent through the conversion process, and I spent some time trying to understand whether the problem was my conversion code, the model architecture, or something else around the pipeline.

That was the first moment where my idea of "the model works" started to feel incomplete.

The model worked in Python.

That did not necessarily mean I could deploy it in the environment where I actually wanted to use it.

Getting the model to run was only the first check

Rather than keep pushing on the same conversion problem immediately, I switched to bge-small-en-v1.5.

BGE was smaller and, more importantly for what I was trying to learn, much easier to convert successfully. That let me separate two problems that I had initially mixed together.

The first problem was whether I could convert and run an embedding model through Core ML.

The second was more interesting: whether the embedding I got after all of that was still behaving like the original Python implementation.

  1. SentenceTransformers
  2. Manual PyTorch pipeline
  3. Core ML
  4. Swift

At first I thought of the model mostly as its weights. Once I started looking more closely at what SentenceTransformers was doing, that stopped being a useful mental model.

For the BGE setup I was using, the transformer was only one part of the path. The text was first formatted differently depending on whether it was a query or a document. It had to go through the correct tokenizer. The transformer output then had to be pooled in the expected way, and the resulting vector had to be normalized.

All of those steps were part of what eventually became the embedding.

That meant I could successfully load a Core ML model, pass tensors into it, and receive a valid array of floats without actually knowing whether I had reproduced the same embedding behavior.

Once I realized that, I stopped trying to validate the whole migration at once.

I started working through it one boundary at a time.

Reproducing the Python reference first

Before Core ML entered the picture, I wanted to make sure I could reproduce the BGE embedding in Python without relying on the high-level SentenceTransformer.encode() call.

That gave me a known reference on one side and a pipeline I could inspect on the other.

The manual version used the tokenizer and transformer directly, took the first token representation for CLS pooling, and then L2-normalized the result.

inputs = tokenizer(
    text,
    return_tensors="pt",
    padding=True,
    truncation=True,
)

with torch.no_grad():
    outputs = transformer(**inputs)

embedding = outputs.last_hidden_state[:, 0]

embedding = F.normalize(
    embedding,
    p=2,
    dim=1,
)

This part was useful because the code itself was not particularly complicated, but it forced me to understand what the higher-level library had been doing for me.

I then compared the manually generated vector with the SentenceTransformers reference.

cosine_similarity = F.cosine_similarity(
    reference_embedding.unsqueeze(0),
    manual_embedding.unsqueeze(0),
)

max_absolute_difference = (
    reference_embedding - manual_embedding
).abs().max()

I was not trying to invent a universal threshold for what counted as a correct embedding.

At this stage, I only wanted to answer a simpler question: did I actually reconstruct the same pipeline?

If those two Python implementations already produced meaningfully different results, there would be no point debugging Core ML yet. The problem would still be somewhere in how I understood the original model.

That sounds obvious in hindsight, but initially I had been treating model conversion as the starting point.

Using the Python implementation as a reference gave me something much more concrete to work from.

Moving the same behavior into Core ML

Once the manual PyTorch path behaved the way I expected, I moved on to Core ML.

One thing I wanted to avoid was converting only the transformer and then accidentally implementing the rest of the embedding pipeline differently elsewhere.

For BGE, I therefore kept the pooling and normalization as part of the wrapper being converted.

token_embeddings = outputs[0]

embedding = token_embeddings[:, 0]

embedding = F.normalize(
    embedding,
    p=2,
    dim=-1,
)

The Core ML model accepted input_ids and an attention_mask with a fixed sequence length of 128.

At this point I still did not immediately open Xcode.

I first ran the converted Core ML model from Python using the same tokenized input and compared that result with the original reference.

This gave me a much clearer chain to debug:

  1. SentenceTransformers against my manual PyTorch implementation.
  2. The manual implementation against the converted Core ML model.
  3. The Core ML model against the Swift integration.

The important part for me was not the number of tests. It was that each comparison had a much smaller surface area.

If SentenceTransformers and the manual implementation differed, I could look at pooling, formatting, or normalization.

If PyTorch and Core ML differed, I could focus on conversion.

If Core ML behaved correctly in Python but not in the app, then Swift, tokenization, or input construction became the more likely place to look.

That was much easier than treating everything from Hugging Face to Xcode as a single black box.

Reproducing the input in Swift

Getting the .mlpackage into Xcode finally brought me to the part I had originally expected to start with.

But by then, I was looking at it differently.

The Swift code did not just need to call Core ML. It needed to reproduce the input that the reference implementation expected.

For BGE, query embeddings use an instruction before the actual query text:

func embedQuery(_ text: String) async throws -> [Float] {
    let instruction =
        "Represent this sentence for searching relevant passages: "

    let input = tokenizer.encode(instruction + text)

    return try model.embed(
        inputIDs: input.inputIDs,
        attentionMask: input.attentionMask
    )
}

Documents do not receive that same query instruction, so they use a separate path.

The tokenizer also has to obey the same sequence-length assumptions as the converted model. In my Swift implementation, the tokens are truncated to 128 positions, the remaining positions are padded, and an attention mask is created before the values are passed into Core ML.

This was another small thing that changed how I thought about the model.

The .mlpackage was not really "the embedding implementation."

It was one part of it.

The behavior I cared about stretched from the text formatting and tokenizer all the way through the model, pooling, normalization, and the vector that eventually entered retrieval.

To check the Swift side, I used the same query I had been using throughout the experiment:

Why does my Wi-Fi keep disconnecting?

The Python and Swift paths both produced 384-dimensional embeddings with an L2 norm close to 1.

I also printed the first few values from both implementations and compared them directly. They were extremely close, with only the small numerical differences I expected from running through different implementations.

That result was much more meaningful to me than simply seeing:

Embedding shape: 384

The shape only told me that Core ML had returned something with the expected dimensions.

The comparison gave me evidence that it was returning the representation I thought I had deployed.

Matching vectors was not the whole story

Once the Python and Swift outputs were lining up, I could have stopped there.

But embeddings are not useful because their individual numbers match a reference. They are useful because of the relationships they create between different pieces of text.

So I added a deliberately small similarity test.

I kept the same Wi-Fi question and compared it with four short sentences. Two were clearly related to the question. The other two were intentionally unrelated.

For BGE, I got:

DocumentBGE similarity
Restart the router and move closer to improve Wi-Fi stability.0.709
Your wireless connection may drop because of weak signal or router interference.0.707
Bananas are a good source of potassium.0.345
SwiftUI uses a declarative approach to build user interfaces.0.339

The exact values were not what interested me most.

What mattered was the ordering.

Both Wi-Fi-related sentences were clearly above the unrelated examples. For such a tiny test, that was enough to tell me the embedding was at least behaving in a way that made sense for retrieval.

I later ran the same small example using EmbeddingGemma through a Core ML integration:

DocumentEmbeddingGemma similarity
Your wireless connection may drop because of weak signal or router interference.0.569
Restart the router and move closer to improve Wi-Fi stability.0.544
SwiftUI uses a declarative approach to build user interfaces.0.064
Bananas are a good source of potassium.0.061

It would be tempting to look at those numbers and say that one model separated the relevant and irrelevant documents better.

I do not think this experiment supports that conclusion.

These are different embedding models, so their raw similarity scores are not directly comparable. More importantly, four hand-written sentences are nowhere near enough to evaluate how either model would behave over a real document collection.

The test was useful for a much narrower reason.

It let me move from "the vectors look numerically correct" to "the vectors also produce a sensible relationship on an example I can understand by inspection."

Those are different checks.

That distinction later became useful when I started thinking about the rest of the RAG pipeline.

A retrieval result can look wrong even when the embedding implementation is perfectly correct. At that point the problem might be the model choice, chunking, query formulation, or the corpus itself.

Without checking the embedding pipeline first, all of those failures are much harder to separate.

The same model can still behave differently

While I was doing these experiments, I also tried EmbeddingGemma through MLX, this time using an 8-bit quantized version of the model. The same Wi-Fi documents still ranked above the unrelated examples, but the similarity values were different from the Core ML version.

My first instinct was to treat that difference as something that needed to be fixed. But the two setups were not actually identical. The MLX version was quantized, the runtime was different, and the integration around the model was different as well. Even though both had "EmbeddingGemma" in the name, I was no longer comparing exactly the same numerical system.

That made the phrase "same model" feel much less precise than it had when I started. The model family might stay the same while the precision changes. The weights might be equivalent while tokenization or query formatting differs. Pooling, normalization, or even the way query and document tasks are represented can also change between implementations.

So when I compare two deployments now, I try to be more explicit about what is actually being held constant. The model name alone tells me surprisingly little about those details.

There was also a slightly ironic part to the EmbeddingGemma story. I had originally moved away from it because my own Core ML conversion attempt was becoming difficult. Later, I found that libraries such as CoreML-LLM already provided a practical way to load and run EmbeddingGemma through Core ML.

At first, that made some of my earlier work feel unnecessary. I do not see it that way anymore. Trying to reproduce the pipeline myself made the abstraction much less mysterious. When a library later handled the deployment path for me, I had a better idea of what I was trusting it to do, and which parts I would want to inspect if the retrieval behavior ever looked wrong.

What I have not answered yet

The small tests were enough for what I was trying to learn at the time, but they are not enough to tell me which embedding model is actually better. That needs a different experiment.

I would want a larger, fixed collection of queries and documents where I know which documents should be relevant to each query. Then I could run the same dataset through the different implementations and compare the retrieval rankings rather than judging them from a few similarity scores.

That would also let me evaluate deployment trade-offs alongside retrieval quality. A model with slightly better retrieval might still be a poor choice for an on-device application if it is significantly harder to deploy, uses much more memory, or adds enough latency to hurt the experience. On the other hand, an easy integration is not useful if the model does not retrieve the right evidence.

I have not done enough evaluation yet to make that choice confidently. For now, the useful outcome of this experiment was not picking a winner between BGE and EmbeddingGemma. It was understanding what I would actually need to test before making that decision.

Model selection became a deployment question

I started this work thinking mainly about model quality.

I wanted an embedding model, I wanted to put it into the RAG pipeline, and I expected the interesting questions to begin once retrieval started.

Instead, deployment became part of the model-selection problem itself.

A model can work perfectly well in Python and still be awkward to reproduce in Core ML. A converted model can execute successfully while some part of the surrounding embedding pipeline behaves differently. A quantized version can have the same model name while no longer being numerically identical to another deployment.

None of those automatically makes the model bad.

They just mean that "it works" is not a very useful stopping point.

What I care about now is whether I can understand and reproduce the embedding behavior in the environment where the model actually has to run, and whether that implementation continues to retrieve the evidence I expect once it becomes part of a larger system.

That is a much slower question to answer than simply getting an embedding back.

But after going through this experiment, it is the question I trust more.