File size: 2,625 Bytes
5733f37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Build-time: encode ``data/catalog.json`` into ``data/embeddings.npy``.

Responsibilities (CLAUDE.md §4, §7):
- Load the catalog, build a text representation per assessment (via
  ``app.retrieval.assessment_text`` so it matches the BM25 documents exactly),
  and encode it with sentence-transformers ``all-MiniLM-L6-v2``.
- Save the L2-normalized dense embedding matrix to ``data/embeddings.npy`` and
  the aligned id order to ``data/embeddings_ids.json`` (both shipped in the repo)
  so nothing large downloads at cold start and ids map to embedding rows.

Build-time only — NOT a runtime dependency of the API.

    python scripts/build_embeddings.py
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import numpy as np

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from app.retrieval import (  # noqa: E402  (after sys.path insert)
    EMBEDDING_MODEL,
    assessment_text,
)

DEFAULT_CATALOG = ROOT / "data" / "catalog.json"
DEFAULT_EMB = ROOT / "data" / "embeddings.npy"
DEFAULT_IDS = ROOT / "data" / "embeddings_ids.json"


def build(catalog_path: Path, emb_path: Path, ids_path: Path) -> None:
    catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
    ids = [r["id"] for r in catalog]
    texts = [assessment_text(r) for r in catalog]
    print(f"Loaded {len(catalog)} assessments from {catalog_path.name}")

    from sentence_transformers import SentenceTransformer

    print(f"Encoding with {EMBEDDING_MODEL} ...")
    model = SentenceTransformer(EMBEDDING_MODEL)
    embeddings = model.encode(
        texts,
        normalize_embeddings=True,   # unit vectors → dot product == cosine
        show_progress_bar=True,
        batch_size=64,
    ).astype(np.float32)

    emb_path.parent.mkdir(parents=True, exist_ok=True)
    np.save(emb_path, embeddings)
    ids_path.write_text(json.dumps(ids, indent=2), encoding="utf-8")

    print(f"Saved {embeddings.shape[0]}x{embeddings.shape[1]} matrix -> {emb_path}")
    print(f"Saved {len(ids)} ids -> {ids_path}")
    # Sanity: rows should be unit-norm.
    norms = np.linalg.norm(embeddings, axis=1)
    print(f"Row-norm min/max: {norms.min():.4f} / {norms.max():.4f} (expect ~1.0)")


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
    ap.add_argument("--out", type=Path, default=DEFAULT_EMB)
    ap.add_argument("--ids", type=Path, default=DEFAULT_IDS)
    args = ap.parse_args()
    build(args.catalog, args.out, args.ids)


if __name__ == "__main__":
    main()