Sentence Similarity
sentence-transformers
English
zephyr
zephyr-rtos
rag
retrieval
faiss
documentation
embedded
qwen
offline
Instructions to use eoinedge/zephyrproject with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use eoinedge/zephyrproject with sentence-transformers:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("eoinedge/zephyrproject") sentences = [ "That is a happy person", "That is a happy dog", "That is a very happy person", "Today is a sunny day" ] embeddings = model.encode(sentences) similarities = model.similarity(embeddings, embeddings) print(similarities.shape) # [4, 4] - Notebooks
- Google Colab
- Kaggle
File size: 7,807 Bytes
1a4588e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """Domain-adapt the retrieval embedding model on the Zephyr corpus.
Why this is worth doing
----------------------
`all-MiniLM-L6-v2` is trained on general web text. Zephyr's vocabulary is not
general: "west", "overlay", "binding", "shim", "work queue", "SYS_INIT",
"CONFIG_" all carry meanings the base model has never seen in this sense. A
query about "a binding" retrieves prose about contracts and obligations before
it retrieves devicetree.
Fine-tuning on in-domain pairs pulls those senses apart.
What it trains on
-----------------
No hand-labelled data, and no synthetic questions from a generator that would
just teach the retriever a generator's phrasing. The pairs are mined from the
documents' own structure:
(section heading in context) <-> (that section's body)
A heading is what a reader would type to find the body under it. That is
exactly the query/passage relationship retrieval needs, and it is already
written by the Zephyr doc authors.
`MultipleNegativesRankingLoss` supplies the negatives: every other passage in
the batch. No mining pass, and the harder the batch, the better the signal.
Honest scope
------------
This trains the *retriever*, not the generator. It does not make Qwen Coder
know more about Zephyr — it makes the passages Qwen is handed more likely to be
the right ones. Those are different problems and both matter; see the README
for how they fit together.
python scripts/train_embeddings.py --epochs 1
python scripts/train_embeddings.py --eval-only # baseline, no training
"""
from __future__ import annotations
import argparse
import json
import random
import sys
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INDEX = ROOT / "data" / "index"
DEFAULT_OUT = ROOT / "data" / "embedding-model"
BASE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
# A heading has to carry some meaning to work as a query. "Overview",
# "Introduction" and "Example" appear hundreds of times against unrelated
# bodies, so training on them teaches the model that those words mean nothing —
# or worse, that every "Overview" body is interchangeable.
GENERIC_HEADINGS = {
"overview", "introduction", "example", "examples", "usage", "notes", "note",
"summary", "description", "requirements", "configuration", "background",
"api reference", "references", "see also", "limitations", "implementation",
"samples", "building", "running", "testing", "troubleshooting",
}
def load_chunks(index_dir: Path) -> list[dict]:
path = index_dir / "chunks.jsonl"
if not path.exists():
raise SystemExit(f"no chunks at {path} - run scripts/build_index.py first")
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
def mine_pairs(chunks: list[dict]) -> list[tuple[str, str]]:
"""(query, passage) pairs from heading/body structure."""
pairs: list[tuple[str, str]] = []
for chunk in chunks:
heading = (chunk.get("section") or "").strip()
if not heading or heading.lower() in GENERIC_HEADINGS or len(heading) < 4:
continue
# A bare heading is ambiguous across subsystems: "Configuration" under
# Bluetooth and under Kconfig are different queries. Qualify it with the
# document title, which is what a reader searching would supply anyway.
title = (chunk.get("title") or "").strip()
query = f"{title}: {heading}" if title and title != heading else heading
pairs.append((query, chunk["text"]))
return pairs
def recall_at_k(model, queries: list[str], passages: list[str], k: int = 5) -> float:
"""Share of queries whose own passage is in the top k of the whole pool."""
import numpy as np
query_vectors = model.encode(
queries, convert_to_numpy=True, normalize_embeddings=True, batch_size=64
)
passage_vectors = model.encode(
passages, convert_to_numpy=True, normalize_embeddings=True, batch_size=64
)
similarity = query_vectors @ passage_vectors.T
top = np.argsort(-similarity, axis=1)[:, :k]
hits = sum(row_index in row for row_index, row in enumerate(top))
return hits / len(queries)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--index", type=Path, default=DEFAULT_INDEX)
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
parser.add_argument("--base", default=BASE_MODEL)
parser.add_argument("--epochs", type=int, default=1)
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--eval-size", type=int, default=400)
parser.add_argument("--seed", type=int, default=13)
parser.add_argument("--eval-only", action="store_true")
args = parser.parse_args()
chunks = load_chunks(args.index)
pairs = mine_pairs(chunks)
print(f"{len(chunks)} chunks -> {len(pairs)} training pairs")
if len(pairs) < 200:
raise SystemExit("too few usable pairs - check the corpus")
random.Random(args.seed).shuffle(pairs)
held_out = pairs[: args.eval_size]
train_pairs = pairs[args.eval_size :]
eval_queries = [q for q, _ in held_out]
eval_passages = [p for _, p in held_out]
from sentence_transformers import SentenceTransformer
print(f"\nBaseline: {args.base}")
baseline_model = SentenceTransformer(args.base)
baseline = recall_at_k(baseline_model, eval_queries, eval_passages)
print(f" recall@5 over {len(held_out)} held-out pairs: {baseline:.3f}")
if args.eval_only:
return 0
from sentence_transformers import InputExample, losses
from torch.utils.data import DataLoader
model = SentenceTransformer(args.base)
examples = [InputExample(texts=[query, passage]) for query, passage in train_pairs]
loader = DataLoader(examples, shuffle=True, batch_size=args.batch_size, drop_last=True)
# In-batch negatives: every other passage in the batch is a negative for
# this query. No mining pass, and it scales with batch size.
loss = losses.MultipleNegativesRankingLoss(model)
print(f"\nTraining on {len(examples)} pairs, {args.epochs} epoch(s)...")
model.fit(
train_objectives=[(loader, loss)],
epochs=args.epochs,
warmup_steps=int(len(loader) * 0.1),
show_progress_bar=True,
)
tuned = recall_at_k(model, eval_queries, eval_passages)
delta = tuned - baseline
print(f"\n baseline recall@5: {baseline:.3f}")
print(f" tuned recall@5: {tuned:.3f} ({delta:+.3f})")
if delta <= 0:
# Say so rather than shipping a model that is worse than the thing it
# replaces. A negative result here is a real result.
print("\n Tuning did not improve retrieval. Not writing the model.")
print(" Try more epochs, a larger batch, or better pairs before shipping this.")
return 1
args.out.mkdir(parents=True, exist_ok=True)
model.save(str(args.out))
(args.out / "eval.json").write_text(
json.dumps(
{
"base_model": args.base,
"train_pairs": len(examples),
"eval_pairs": len(held_out),
"epochs": args.epochs,
"batch_size": args.batch_size,
"recall_at_5_baseline": round(baseline, 4),
"recall_at_5_tuned": round(tuned, 4),
},
indent=2,
),
encoding="utf-8",
)
print(f"\nSaved to {args.out}")
print("Rebuild the index with it:")
print(f" python scripts/build_index.py --model {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())
|