feat(embed-eval): leave-one-out kNN benchmark for embedding models #1237

Merged
k.renner merged 3 commits from feat/embed-knn-bench into master 2026-10-10 20:00:49 +00:00
2 changed files with 254 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Leave-one-out nearest-neighbour benchmark for an embedding model.
Embeds a labelled corpus of Paperless documents and, for each document, predicts
its correspondent and document type from the label of its nearest neighbour
(cosine similarity, the document itself excluded). Accuracy is the share of
documents whose predicted label equals the filed one.
Corpus: JSON lines {"id", "correspondent", "doctype", "text"}, exported
read-only from the paperless database, for example on the CNPG primary:
select json_build_object('id', d.id, 'correspondent', d.correspondent_id,
'doctype', d.document_type_id,
'text', left(regexp_replace(d.content, '\\s+', ' ', 'g'), 3000))
from documents_document d
where d.correspondent_id is not null and d.document_type_id is not null
and length(d.content) > 200
order by md5(d.id::text || '<seed>') limit 400
Ceiling is the share of documents whose label occurs on at least one other
document; no nearest-neighbour method can exceed it. Floor is the share of the
most common label.
Usage:
knn-bench.py --corpus corpus.jsonl -e https://api.mistral.ai/v1/embeddings \\
-m mistral-embed --key-env MISTRAL_API_KEY --batch 8
knn-bench.py --corpus corpus.jsonl -m bge-m3 \\
-e http://llama-embed.ai.svc.cluster.local:8080/v1/embeddings \\
--via-pod "deploy/llama-server -n ai"
Standard library only. Vectors are cached per model next to the corpus.
"""
import argparse
import json
import math
import os
import re
import shlex
import subprocess
import sys
import time
import urllib.error
import urllib.request
from collections import Counter
def post(endpoint, payload, key, via_pod):
body = json.dumps(payload).encode()
if via_pod:
cmd = ["kubectl", "exec", "-i", *shlex.split(via_pod), "--", "curl", "-sS", "-m", "300",
"-H", "Content-Type: application/json", "-d", "@-", endpoint]
proc = subprocess.run(cmd, input=body, capture_output=True, check=False)
if proc.returncode != 0:
raise RuntimeError(f"kubectl exec failed: {proc.stderr.decode()}")
return json.loads(proc.stdout)
headers = {"Content-Type": "application/json"}
if key:
headers["Authorization"] = f"Bearer {key}"
req = urllib.request.Request(endpoint, data=body, headers=headers)
for attempt in range(6):
try:
with urllib.request.urlopen(req, timeout=300) as resp:
return json.load(resp)
except urllib.error.HTTPError as err:
if err.code == 429 and attempt < 5:
time.sleep(2 ** attempt)
continue
raise RuntimeError(f"HTTP {err.code}: {err.read().decode()}") from err
raise RuntimeError("retries exhausted")
def embed_all(docs, args, key):
cache = f"{args.corpus}.{args.model}.{args.max_chars}.vecs.json"
if os.path.exists(cache):
with open(cache) as fh:
vecs = json.load(fh)
if len(vecs) == len(docs):
print(f"vectors loaded from {cache}")
return vecs
vecs = []
shrunk = 0
start = time.time()
for i in range(0, len(docs), args.batch):
chunk = [d["text"] for d in docs[i:i + args.batch]]
for _ in range(6):
try:
out = post(args.endpoint, {"model": args.model, "input": chunk}, key, args.via_pod)
break
except RuntimeError as err:
# Mistral reports "Input id N has T tokens, exceeding max M tokens": cut that text to fit.
hit = re.search(r"Input id (\d+) has (\d+) tokens, exceeding max (\d+)", str(err))
if not hit:
raise
idx, have, limit = (int(g) for g in hit.groups())
chunk[idx] = chunk[idx][:int(len(chunk[idx]) * limit / have * 0.95)]
shrunk += 1
else:
raise RuntimeError(f"documents {i}-{i + len(chunk) - 1} still over the token limit")
if "data" not in out:
raise RuntimeError(f"no embeddings in response for documents {i}-{i + len(chunk) - 1}: {json.dumps(out)[:500]}")
data = sorted(out["data"], key=lambda item: item["index"])
if len(data) != len(chunk):
raise RuntimeError(f"asked for {len(chunk)} vectors, got {len(data)}")
vecs.extend(item["embedding"] for item in data)
done = len(vecs)
if done % 50 < args.batch:
print(f" embedded {done}/{len(docs)} ({time.time() - start:.0f}s)", flush=True)
if shrunk:
print(f" {shrunk} over-limit texts were shortened to fit the model's token limit")
with open(cache, "w") as fh:
json.dump(vecs, fh)
return vecs
def normalise(vec):
norm = math.sqrt(sum(x * x for x in vec)) or 1.0
return [x / norm for x in vec]
def nearest_neighbours(vecs):
n = len(vecs)
best = [(-2.0, -1)] * n
for i in range(n):
vi = vecs[i]
for j in range(i + 1, n):
s = sum(a * b for a, b in zip(vi, vecs[j]))
if s > best[i][0]:
best[i] = (s, j)
if s > best[j][0]:
best[j] = (s, i)
return [j for _, j in best]
def score(docs, neighbours, field):
labels = [d[field] for d in docs]
counts = Counter(labels)
hits = sum(labels[i] == labels[neighbours[i]] for i in range(len(docs)))
ceiling = sum(counts[label] > 1 for label in labels)
floor = max(counts.values())
n = len(docs)
return 100 * hits / n, 100 * ceiling / n, 100 * floor / n, len(counts)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--corpus", required=True)
ap.add_argument("-e", "--endpoint", required=True)
ap.add_argument("-m", "--model", required=True)
ap.add_argument("--key-env", default="", help="environment variable holding the API key")
ap.add_argument("--via-pod", default="", help='run curl in this pod, e.g. "deploy/llama-server -n ai"')
ap.add_argument("--batch", type=int, default=1)
ap.add_argument("--max-chars", type=int, default=0, help="truncate each text to this many characters (0 = as exported)")
args = ap.parse_args()
key = os.environ.get(args.key_env, "") if args.key_env else ""
if args.key_env and not key:
sys.exit(f"{args.key_env} is not set")
with open(args.corpus) as fh:
docs = [json.loads(line) for line in fh if line.strip()]
if args.max_chars:
for d in docs:
d["text"] = d["text"][:args.max_chars]
print(f"model={args.model} documents={len(docs)} max_chars={args.max_chars} endpoint={args.endpoint}")
start = time.time()
vecs = [normalise(v) for v in embed_all(docs, args, key)]
embed_s = time.time() - start
neighbours = nearest_neighbours(vecs)
print(f"dimensions={len(vecs[0])} embed+load={embed_s:.0f}s")
for field in ("correspondent", "doctype"):
acc, ceiling, floor, classes = score(docs, neighbours, field)
print(f"{field:<14} accuracy={acc:5.1f}% ceiling={ceiling:5.1f}% floor={floor:5.1f}% labels={classes}")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Paired comparison of two embedding models scored by knn-bench.py.
Both runs must cover the same documents in the same order. For each label field
it reports how the per-document outcomes overlap, an exact two-sided McNemar
test on the documents where exactly one model is right, and how often the two
models pick the same nearest neighbour.
Usage:
knn-compare.py --corpus-a a.jsonl --vecs-a a.vecs.json \\
--corpus-b b.jsonl --vecs-b b.vecs.json
The vectors files are the caches knn-bench.py writes next to its corpus.
Standard library only.
"""
import argparse
import importlib.util
import json
import math
import os
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("knn_bench", os.path.join(HERE, "knn-bench.py"))
bench = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bench)
def load(corpus, vecs):
with open(corpus) as fh:
docs = [json.loads(line) for line in fh if line.strip()]
with open(vecs) as fh:
vectors = [bench.normalise(v) for v in json.load(fh)]
if len(docs) != len(vectors):
raise SystemExit(f"{vecs}: {len(vectors)} vectors for {len(docs)} documents")
return docs, bench.nearest_neighbours(vectors)
def mcnemar_exact(only_a, only_b):
"""Two-sided exact p-value for a fair coin over the discordant pairs."""
n = only_a + only_b
if n == 0:
return 1.0
k = min(only_a, only_b)
tail = sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n
return min(1.0, 2 * tail)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
for side in "ab":
ap.add_argument(f"--corpus-{side}", required=True)
ap.add_argument(f"--vecs-{side}", required=True)
args = ap.parse_args()
docs_a, nn_a = load(args.corpus_a, args.vecs_a)
docs_b, nn_b = load(args.corpus_b, args.vecs_b)
if [d["id"] for d in docs_a] != [d["id"] for d in docs_b]:
raise SystemExit("the two corpora do not hold the same documents in the same order")
n = len(docs_a)
same_nn = sum(a == b for a, b in zip(nn_a, nn_b))
print(f"documents={n} same nearest neighbour in both models: {same_nn} ({100 * same_nn / n:.1f}%)")
for field in ("correspondent", "doctype"):
ok_a = [docs_a[i][field] == docs_a[nn_a[i]][field] for i in range(n)]
ok_b = [docs_b[i][field] == docs_b[nn_b[i]][field] for i in range(n)]
both = sum(a and b for a, b in zip(ok_a, ok_b))
only_a = sum(a and not b for a, b in zip(ok_a, ok_b))
only_b = sum(b and not a for a, b in zip(ok_a, ok_b))
neither = n - both - only_a - only_b
p = mcnemar_exact(only_a, only_b)
print(f"{field:<14} both right={both} only A={only_a} only B={only_b} both wrong={neither} "
f"McNemar p={p:.3f}")
if __name__ == "__main__":
main()