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())