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.
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.
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.
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
- Sentence Transformers documentation, sbert.net
- BAAI/bge-base-en-v1.5 model card, huggingface.co
- Hugging Face Text Embeddings Inference, GitHub