← All courses ← Course home

Lesson 04 of 04

A recall test and the speed trap

You cannot see embedding quality by looking at numbers. A small labelled test tells you if a change made search better or worse.

Agent brief (llms.md)

Environment setup

Use the same local-lab folder from lesson 01 (uv, Python 3.11+, and uv add numpy). If you skipped it, do the setup in lesson 01 first.

Install llama.cpp. On macOS: brew install llama.cpp. On Linux or Windows, download a release from github.com/ggml-org/llama.cpp/releases. Check it with llama-server --version.

A recall test

Write pairs of a real user query and the document that should come first. Embed both, rank, and count how often the right document is first (recall@1).

"""Recall test for local embeddings served by llama-server (--embedding)."""
import json
import urllib.request

import numpy as np

URL = "http://127.0.0.1:8094/v1/embeddings"
QUERY_PREFIX = "task: search result | query: "
DOC_PREFIX = "title: none | text: "

DOCS = {
    "refund": "You can ask for a refund within 30 days. Open Orders, pick the order, and tap Request refund.",
    "password": "To reset your password, tap Forgot password on the sign-in screen and follow the email link.",
    "shipping": "Standard shipping takes 3 to 5 working days. Express shipping arrives the next working day.",
    "battery": "If the battery drains fast, turn off background refresh and lower the screen brightness.",
    "export": "You can export your notes as PDF or Markdown from Settings, then Export.",
    "delete": "To delete your account, go to Settings, Privacy, and tap Delete account. This cannot be undone.",
    "offline": "The app works offline. Changes sync the next time you are online.",
    "language": "Change the app language in Settings, General, Language.",
}
QUERIES = [
    ("how do I get my money back", "refund"),
    ("I forgot my login", "password"),
    ("when will my parcel arrive", "shipping"),
    ("phone dies quickly", "battery"),
    ("save notes as a pdf file", "export"),
    ("remove my profile for good", "delete"),
    ("does it work without internet", "offline"),
    ("switch to Spanish", "language"),
]


def embed(texts):
    body = json.dumps({"input": texts}).encode()
    req = urllib.request.Request(URL, body, {"Content-Type": "application/json"})
    data = json.load(urllib.request.urlopen(req))["data"]
    return np.array([row["embedding"] for row in data], dtype=np.float32)


def truncate(vectors, dim, renormalize):
    out = vectors[:, :dim]
    if renormalize:
        out = out / np.linalg.norm(out, axis=1, keepdims=True)
    return out


def recall_at_1(q, d, ids):
    scores = q @ d.T
    hits = [ids[int(np.argmax(row))] == want for row, (_, want) in zip(scores, QUERIES)]
    return sum(hits) / len(hits)


def run():
    ids = list(DOCS)
    docs_plain = embed([DOCS[i] for i in ids])
    qs_plain = embed([q for q, _ in QUERIES])
    docs_pref = embed([DOC_PREFIX + DOCS[i] for i in ids])
    qs_pref = embed([QUERY_PREFIX + q for q, _ in QUERIES])

    print("norm of a full vector:", round(float(np.linalg.norm(docs_pref[0])), 4))
    print("recall@1 no prefixes:  ", recall_at_1(qs_plain, docs_plain, ids))
    print("recall@1 with prefixes:", recall_at_1(qs_pref, docs_pref, ids))
    for dim in (512, 256, 128):
        d_raw = truncate(docs_pref, dim, renormalize=False)
        print(f"{dim}d norm before renormalize:", round(float(np.linalg.norm(d_raw[0])), 4))
        print(f"{dim}d recall@1 renormalized:", recall_at_1(
            truncate(qs_pref, dim, True), truncate(docs_pref, dim, True), ids))


if __name__ == "__main__":
    run()
norm of a full vector: 1.0
recall@1 no prefixes:   1.0
recall@1 with prefixes: 1.0
512d norm before renormalize: 0.8626
512d recall@1 renormalized: 1.0
256d norm before renormalize: 0.6799
256d recall@1 renormalized: 1.0
128d norm before renormalize: 0.5944
128d recall@1 renormalized: 1.0

Every number is 1.0 on this toy set of 8 documents. That is a real result, and it shows a real lesson: a small, easy set cannot tell setups apart. Use 50 or more real queries from your app, including hard ones where two documents are close. Then the test shows a difference when you change the prefix, the dimension, or the model.

Run the test in CI whenever you change the model, prefixes, dimension, or runtime. For a larger eval setup, see the eval harness course.

The speed trap in llama.cpp

llama.cpp #30398: with flash attention on (-fa on) on CUDA, EmbeddingGemma 2 was about 10 times slower when the input length was not a multiple of 256 tokens. 3,840 tokens took 0.17 s and 3,841 took 1.96 s. It was fixed by PR #30416, merged on 12 October 2026. Use a build from after that date, or -fa off on older builds.

Time your own setup at lengths just below and above a multiple of 256:

"""Time embeddings at token counts just below and above a multiple of 256."""
import json
import time
import urllib.request

URL = "http://127.0.0.1:8094"


def post(path, body):
    req = urllib.request.Request(URL + path, json.dumps(body).encode(), {"Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(req))


text = " ".join(["The quick brown fox jumps over the lazy dog."] * 500)
tokens = post("/tokenize", {"content": text})["tokens"]
post("/embedding", {"content": tokens[:256], "cache_prompt": False})  # warm-up
for n in [1024, 1025, 2048, 2049, 2048, 2049]:
    start = time.perf_counter()
    # cache_prompt false: otherwise slot reuse hides the difference
    post("/embedding", {"content": tokens[:n], "cache_prompt": False})
    print(f"{n} tokens: {time.perf_counter() - start:.3f}s")

On the test box (CPU only, build b11624, -fa on) the gap was much smaller: 2,048 tokens took about 2.1 s and 2,049 took about 3.1 s. 1,024 and 1,025 were close. Your GPU and build will differ, so measure.

Sources

Key concepts

  • Recall@1 counts how often the right document ranks first.
  • A small, easy test set cannot show differences between setups.
  • llama.cpp #30398 made inputs that are not a multiple of 256 tokens much slower on CUDA with flash attention.
  • Speed depends on build, backend, and input length.

Takeaways

  • Build a recall set of 50 or more real queries, with hard cases.
  • Run it in CI on every model, prefix, dimension, or runtime change.
  • Time embeddings at lengths around multiples of 256 on your own hardware.