Custom Embeddings

Building Custom Embeddings for Better Semantic Search

⏱ 18 min

Lesson 1 named the second genuine reason for custom model work: a generic embedding model that ranks the wrong content as relevant, no matter how well-tuned the rest of a RAG pipeline is. This lesson is about the embedding model itself, not the generation model Lessons 3 and 4 covered, and like fine-tuning a language model, this happens entirely outside WordPress and PHP. The real, well-known tool for this specific job is sentence-transformers, an open-source library built for exactly this kind of contrastive fine-tuning, distinct from the general-purpose transformers library Lesson 3 used.

What you'll learn in this lesson
When to fine-tune an embedding model vs pick a better off-the-shelf one
A real decision, since a stronger open general-purpose embedding model sometimes closes the gap without training anything.
sentence-transformers, the real tool for this job
A dedicated open-source library for training and fine-tuning embedding models, distinct from transformers.
Contrastive fine-tuning on domain pairs
Training on pairs of text your domain considers related, using a real loss function built for this.
Serving the resulting model behind an HTTP endpoint
The same self-hosting shape Lessons 3 and 4 established, applied to an embedding model instead of a generation model.
Prerequisites

Lesson 1’s decision made honestly, in favor of a genuine embedding-ranking problem, not a knowledge or retrieval-tuning problem Course 10 could still fix. A GPU-equipped machine and Python environment, the same setup Lesson 3 used.

This lesson's training code runs outside WordPress and PHP

Like Lesson 3, everything here is Python run on its own machine. WordPress’s role starts again only in Lesson 6, once the resulting embedding model is served behind an endpoint.

Step 1: Try a better off-the-shelf model before training one

Before fine-tuning anything, confirm a stronger existing open embedding model doesn’t already close the gap. Real, well-known open options like BAAI/bge-base-en-v1.5 or larger variants in the same family are frequently a meaningful step up from a smaller default embedding model, with zero training required:

# File: try_baseline_model.py
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-base-en-v1.5")
embeddings = model.encode([
    "How do I reset my two-factor authentication device?",
    "Configuring 2FA recovery codes",
])

If swapping in a stronger general-purpose model, evaluated with Lesson 7’s methodology, already closes most of your ranking gap, stop here. Training your own embedding model is real, added infrastructure and maintenance, worth it only when a better off-the-shelf option genuinely isn’t enough.

Step 2: Install sentence-transformers

# File: terminal, on a GPU-equipped machine
pip install sentence-transformers

sentence-transformers is a real, independently-maintained open-source library built specifically for training and fine-tuning embedding models, it is not part of, and doesn’t require, the transformers-plus-peft fine-tuning stack Lesson 3 used for a text-generation model.

Step 3: Build domain pairs, the real training data for embeddings

Embedding fine-tuning doesn’t use prompt/completion pairs the way Lesson 2’s export did. It needs pairs of text your domain considers genuinely related, and ideally a third, explicitly unrelated example alongside each pair:

# File: prepare_embedding_pairs.py
from sentence_transformers import InputExample

training_pairs = [
    InputExample(texts=[
        "How do I reset my two-factor authentication device?",
        "Lost your 2FA device? Here's how to regain account access.",
    ]),
    InputExample(texts=[
        "Configuring webhook retry behavior",
        "What happens when a webhook delivery fails repeatedly",
    ]),
]

A realistic source for these pairs is your own support history or documentation: a support ticket’s subject line paired with the knowledge-base article that actually resolved it, or a search query paired with the page a user clicked and stayed on. This is real, domain-specific signal a generic embedding model was never trained on.

Step 4: Fine-tune with a contrastive loss

MultipleNegativesRankingLoss is sentence-transformers’ real, standard loss function for exactly this case: given a batch of related pairs, it treats every other pair in the same batch as an implicit negative, pushing genuinely related text closer together in the embedding space and everything else further apart:

# File: train_embeddings.py
from sentence_transformers import SentenceTransformer, losses
from torch.utils.data import DataLoader

model = SentenceTransformer("BAAI/bge-base-en-v1.5")

train_dataloader = DataLoader(training_pairs, shuffle=True, batch_size=16)
train_loss = losses.MultipleNegativesRankingLoss(model)

model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    output_path="./site-embeddings-model",
)

Starting from BAAI/bge-base-en-v1.5 rather than training an embedding model from scratch matters, this is fine-tuning an already-strong general embedding space toward your domain’s specific notion of relatedness, not building embeddings from nothing.

Step 5: Serve the resulting model behind an HTTP endpoint

The output in ./site-embeddings-model is a real, standalone sentence-transformers model directory. Hugging Face’s Text Embeddings Inference, a real, dedicated serving tool for exactly this kind of model, exposes it behind an OpenAI-compatible embeddings endpoint:

# File: terminal, on the machine serving the embedding model
docker run --gpus all -p 8080:80 \
  -v $(pwd)/site-embeddings-model:/data/model \
  ghcr.io/huggingface/text-embeddings-inference:latest \
  --model-id /data/model

This produces an /v1/embeddings endpoint accepting the same request shape OpenAI’s own embeddings API uses, {"input": "...", "model": "..."}, returning {"data": [{"embedding": [...]}]}. That shape is exactly what Lesson 6 wires into Course 10’s pipeline.

Test it: compare rankings before and after, informally

# File: sanity_check.py
from sentence_transformers import SentenceTransformer, util

base_model = SentenceTransformer("BAAI/bge-base-en-v1.5")
tuned_model = SentenceTransformer("./site-embeddings-model")

query = "2FA device lost"
candidates = [
    "Lost your 2FA device? Here's how to regain account access.",
    "Setting up your billing address for the first time",
]

for label, model in [("base", base_model), ("tuned", tuned_model)]:
    query_emb = model.encode(query)
    candidate_embs = model.encode(candidates)
    scores = util.cos_sim(query_emb, candidate_embs)
    print(label, scores)

You’re looking for the tuned model to separate the genuinely-related candidate from the unrelated one more clearly than the base model does, a larger score gap between the two. This is an informal sanity check, not the rigorous evaluation, Lesson 7 covers doing this properly against a real held-out set.

A different embedding model means a different vector space entirely

Vectors from your fine-tuned embedding model and vectors from whatever generic model produced your existing stored embeddings are not comparable, cosine similarity between them is meaningless even though both are arrays of floats of similar length. Switching embedding models means every previously-stored chunk needs to be re-embedded and re-stored from scratch, there’s no partial migration path. Lesson 6 covers this directly.

Recap

A generic embedding model is sometimes the real bottleneck in an otherwise well-tuned RAG pipeline, and the fix is either a stronger off-the-shelf open model or, when that isn’t enough, contrastive fine-tuning with sentence-transformers on real domain pairs pulled from your own support history or search logs. MultipleNegativesRankingLoss against a strong starting point like BAAI/bge-base-en-v1.5 is the real, standard approach, and the result serves behind an OpenAI-compatible endpoint through a tool like Hugging Face’s Text Embeddings Inference, ready for the next lesson to wire into storage.

Resources & further reading

← Serving a Fine-Tuned Model Back to WordPress Wiring Custom Embeddings Into the RAG Pipeline →