Skip to main content

Book 3 · The Senior AI Engineer · 8 min read

How to Fine-Tune bge-small-en for a Custom Domain

A general-purpose embedding model does not know that 'mirrorwork' and 'mirror-work' are the same, or that 'chikankari' clusters near 'Lucknowi embroidery'. Fine-tuning on 3,480 domain triplets moved Context Recall from 0.78 to 0.85 — at ₹0 per query instead of ₹0.015.

How to Fine-Tune bge-small-en for a Custom Domain

A general-purpose embedding model was trained on web text, not on fashion product catalogs. It does not know that "mirrorwork" and "mirror-work" are the same thing, or that "voile" clusters near "breathable summer fabric" in the Indian fashion market. Fine-tuning teaches it your domain.

The Valid Failure

After Book 2, ShopBot's embedding model was sentence-transformers/all-MiniLM-L6-v2. It was good at general semantic similarity. It was not good at the specific vocabulary of Indian fashion e-commerce:

  • "Supplex performance fabric" → low similarity to "quick-dry sportswear" (it had no training exposure to that brand name)
  • "mirrorwork lehenga" → low similarity to "mirror embroidered lehenga" (hyphenation variation)
  • "chikankari kurta" → low similarity to "Lucknowi embroidery kurta" (regional terminology)

These were not retrieval threshold problems. Lowering the threshold returned unrelated chunks. These were embedding model problems: the model had not learned the vocabulary patterns of this domain.

The team considered switching to text-embedding-3-large (OpenAI, 3072 dimensions, API cost). Cost at ShopBot's production volume: ₹0.015 per query × 38,000 queries/day = ₹570/day. The fine-tuned bge-small-en-v1.5 at 384 dimensions on ONNX: ₹0 per query, 2ms latency.

Why bge-small-en-v1.5

BAAI/bge-small-en-v1.5 is the fine-tuning target for three reasons:

  1. 384 dimensions — small enough for fast inference, large enough for domain discrimination
  2. Architecture designed for fine-tuning — trained with a contrastive objective that makes triplet fine-tuning straightforward
  3. Strong baseline — top-tier on MTEB benchmarks before any fine-tuning; the fine-tuning adds margin, not capability

Step 1: Build Training Triplets

Fine-tuning with triplet loss requires triplets: (anchor, positive, negative).

  • Anchor: a query phrased as a user would ask it
  • Positive: the correct chunk that answers the anchor
  • Negative: a chunk that seems relevant but is not the right answer
# Example triplets for a fashion catalog
triplets = [
    {
        "anchor": "lightweight fabric for hot weather",
        "positive": "Cotton voile — featherweight and breathable, ideal for summer wear. Skin-friendly and quick to dry.",
        "negative": "Warm woollen shawl in winter hues — perfect for cold evenings and festive occasions.",
    },
    {
        "anchor": "mirrorwork lehenga",
        "positive": "Mirror embroidered lehenga in ivory — intricate shisha mirror work on georgette. Festive occasion wear.",
        "negative": "Cotton printed anarkali — floral block print, suitable for casual daywear.",
    },
]

The team generated 3,480 triplets from 100 manually labeled queries by:

  1. Taking each labeled query and its correct chunk (100 anchor-positive pairs)
  2. Generating 34–35 paraphrase variants per query using GPT-4o-mini
  3. Hard-mining negatives: finding chunks that scored 0.65–0.80 (near-misses, not obvious negatives)

Step 2: Fine-Tune with sentence-transformers

from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader

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

train_examples = [
    InputExample(texts=[t["anchor"], t["positive"], t["negative"]])
    for t in triplets
]

train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=32)

train_loss = losses.TripletLoss(
    model=model,
    distance_metric=losses.TripletDistanceMetric.COSINE,
    triplet_margin=0.5,
)

model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    output_path="shopbot-bge-small-finetuned",
)

Training time on an M2 MacBook Pro (CPU): 47 minutes for 3,480 triplets × 3 epochs. On a free Google Colab GPU: 8 minutes.

Step 3: Evaluate Before Committing

Before re-ingesting the catalog with the new model, compare retrieval scores on a held-out evaluation set:

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

base_model = SentenceTransformer("BAAI/bge-small-en-v1.5")
finetuned_model = SentenceTransformer("shopbot-bge-small-finetuned")

eval_pairs = [
    ("lightweight fabric for hot weather", "Cotton voile — featherweight and breathable"),
    ("mirrorwork lehenga", "Mirror embroidered lehenga in ivory"),
    ("chikankari kurta", "Lucknowi embroidery kurta — hand-crafted white threadwork"),
]

for query, target in eval_pairs:
    base_score = cosine_similarity(
        [base_model.encode(query)], [base_model.encode(target)]
    )[0][0]
    ft_score = cosine_similarity(
        [finetuned_model.encode(query)], [finetuned_model.encode(target)]
    )[0][0]
    print(f"Base: {base_score:.3f} | Fine-tuned: {ft_score:.3f} | {query}")

Results from Book 3:

Query Base score Fine-tuned score
"lightweight fabric for hot weather" 0.71 0.88
"mirrorwork lehenga" 0.64 0.86
"chikankari kurta" 0.58 0.84
"something for a beach wedding" 0.62 0.81

Every query that was failing or borderline on the base model is above threshold on the fine-tuned model.

Step 4: Re-Ingest the Catalog

The fine-tuned model produces different vectors than the base model. You must re-ingest all chunks:

# Re-ingest with fine-tuned model
finetuned_model = SentenceTransformer("shopbot-bge-small-finetuned")

client.recreate_collection(
    collection_name=COLLECTION,
    vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)

for chunk in all_chunks:
    vector = finetuned_model.encode(chunk["text"]).tolist()
    client.upsert(collection_name=COLLECTION, points=[...])

Re-ingestion of 850 products × ~4 chunks each = 3,400 chunks. Time: 94 seconds.

RAGAS After Fine-Tuning

Metric Before fine-tuning After fine-tuning
Faithfulness 0.83 0.87
Context Precision 0.86 0.91
Context Recall 0.78 0.85

Context Recall improved most significantly — the fine-tuned model retrieved relevant chunks that the base model had missed on domain-specific vocabulary.

The Valid Knowledge

  • 3,480 triplets from 100 labeled queries is sufficient: you do not need thousands of labeled queries. You need enough to cover the vocabulary patterns specific to your domain.
  • Hard negatives are more valuable than random negatives: triplets where the negative scores 0.65–0.80 (near-miss) teach the model finer discrimination than triplets where the negative scores 0.20 (obvious wrong answer).
  • Re-ingest after fine-tuning: the fine-tuned model and base model produce incomparable vectors. Mixing them in the same collection produces broken retrieval.
  • Evaluate before re-ingesting: if the fine-tuned model does not improve scores on the held-out eval pairs, do not re-ingest. Check the training data quality.
  • ₹0 per query vs ₹0.015 per query: the fine-tuned local model costs nothing to run after training. At production volume, this is the most significant operational cost reduction in the entire Book 3 arc.

This is the concept. The book is the system.

Book 3: The Senior AI Engineer

Articles give you understanding. The book gives you a working system — full production code, RAGAS evaluation scores, and the patterns that hold up at 11 PM when something breaks.