Lab: First embedding
Set up the project, embed the Harbor Lane corpus whole-document, run first searches — and meet the failure that motivates chunking.
You need two API accounts: Voyage AI for embeddings (get a key at the Voyage dashboard — it's a separate company from Anthropic) and Anthropic for generation (console.anthropic.com, used from Module 4 on). Both give free credit to start, and the entire course costs well under $1 if you follow the cheap-first notes. You'll also need Python 3.10+, pip install numpy requests anthropic, and both keys set in your shell (VOYAGE_API_KEY and ANTHROPIC_API_KEY — see Step 1, and the .env note there for making them stick across sessions).
A complete, offline-tested version of everything you build here ships at `course-assets/harbordocs/` — the same file names the snippets below use (embeddings.py, chunker.py, build_index.py, search.py, answer.py, verify.py, metrics.py, plus data/corpus.txt and a labeled eval set). Read it or run it if you get stuck. Its data/corpus.txt is an 18-article reference sample (you'll generate your own ~20 in the lab). The retrieval and grounding math is genuinely tested (python3 tests/test_offline.py — no keys, no network); the embed and generate steps are correct-by-construction but need your own Voyage and Anthropic keys to run. Build it yourself first — the kit is the reference, not a shortcut past the learning.
Hands on keyboard: you'll stand up the HarborDocs project, embed real text, and run your first semantic searches — deliberately the naive way (whole documents), so Module 2's lesson lands with force.
Step 1 — Project setup
mkdir harbordocs && cd harbordocs
python -m venv .venv && source .venv/bin/activate
pip install numpy requests anthropic
export VOYAGE_API_KEY=... # or your embedding provider
export ANTHROPIC_API_KEY=... # used from Module 4 onAn export only lasts one terminal session — close the tab and it's gone. For a multi-week project, put both keys in a .env file and load them (e.g. pip install python-dotenv, then from dotenv import load_dotenv; load_dotenv() at the top of your scripts), or just re-export each session. The .env is two lines:
VOYAGE_API_KEY=your-voyage-key-here
ANTHROPIC_API_KEY=your-anthropic-key-hereStep 2 — Generate the corpus
Use your assistant to fabricate the knowledge base (this is also a Foundations-style structured-generation exercise). The prompt below pins specific facts that later labs test against — keep every named item so your corpus resolves the questions the course asks of it:
Generate 20 help-center articles for Harbor Lane, a home-goods retailer. Topics: returns (3 articles: standard, damaged items, holiday), shipping (3), warranty (2), product care (4: wood, wool, ceramic, cast iron), assembly (2), account & orders (3), gift cards (1), trade program (2). Each: a title line, then 150-250 words with 2-3 subsections. Realistic, specific (durations, thresholds, steps), occasionally overlapping — real help centers repeat themselves. Save-friendly format: one article per block separated by '==='. Required facts these later exercises depend on — include all of them explicitly: - A **standard returns** article that states a **15% restocking fee** on certain returns (and which categories are exempt). - A **holiday returns** article: orders placed Nov 15–Dec 24 may be returned through **January 31** (an extended window beyond the standard one). - A **damaged-items returns** article that requires a **photo** of the damage to start a claim. - A **trade program** article describing return-policy differences for **trade customers** vs. regular customers. - Use **HL-#### order identifiers** (e.g. `HL-1042`) in the account & orders articles, and at least one real product name (e.g. the **Juniper Throw**). - Do NOT mention price matching or shipping to Canada — later labs use those as deliberately *unanswerable* questions, so they must be absent.
Save as corpus.txt. Overlap between articles is deliberate — disambiguating near-duplicates is exactly what you'll tune for later. The pinned facts above are load-bearing: labs in Modules 2–6 ask about the restocking fee, the Jan 31 holiday deadline, damaged-item photos, trade returns, and HL-#### lookups.
Step 3 — The embedding wrapper + index
import os, requests, numpy as np
def embed(texts: list[str]) -> np.ndarray:
"""One function between you and the provider — swap freely."""
r = requests.post(
"https://api.voyageai.com/v1/embeddings",
headers={"Authorization": f"Bearer {os.environ['VOYAGE_API_KEY']}"},
json={"model": "voyage-3.5-lite", "input": texts},
)
r.raise_for_status()
data = sorted(r.json()["data"], key=lambda d: d["index"])
vecs = np.array([d["embedding"] for d in data])
return vecs / np.linalg.norm(vecs, axis=1, keepdims=True) # normalizevoyage-3.5-lite is a small, cheap embedding model (check Voyage's docs for the current model name — provider model lists change). Keep the same model for documents and queries; that's rule one from the embeddings lesson.
- 1Write
build_index.py: splitcorpus.txton===, embed each whole article, savenp.save('index.npy', ...)plus a parallel list of titles indocs.json. - 2Write
ask.py: take a query from the command line, embed it, runtop_kfrom the lesson, print the top-3 titles with scores. - 3Run your three predicted queries from the last lesson. Score your predictions.
Try assembling these two scripts yourself first — they use nothing but embed() and top_k() from the lessons above. Here's one correct version of each if you get stuck:
import json
import numpy as np
from embeddings import embed
# One article per block, separated by a line of '==='. First non-empty
# line of each block is the title; the whole block is what we embed.
raw = open("corpus.txt", encoding="utf-8").read()
articles = [a.strip() for a in raw.split("===") if a.strip()]
titles = [a.splitlines()[0].strip() for a in articles]
vectors = embed(articles) # (n_articles, dim), normalized
np.save("index.npy", vectors)
with open("docs.json", "w", encoding="utf-8") as f:
json.dump({"titles": titles, "texts": articles}, f)
print(f"Indexed {len(articles)} articles -> index.npy + docs.json")import json
import sys
import numpy as np
from embeddings import embed
from search import top_k
query = " ".join(sys.argv[1:])
if not query:
sys.exit("usage: python ask.py <your question>")
vectors = np.load("index.npy")
docs = json.load(open("docs.json", encoding="utf-8"))
titles = docs["titles"]
query_vec = embed([query])[0] # embed() takes a list; take row 0
idx, scores = top_k(query_vec, vectors, k=3)
for rank, (i, score) in enumerate(zip(idx, scores), 1):
print(f"{rank}. {score:.3f} {titles[i]}")Step 4 — Find the crack
- 1Ask something answered by one paragraph inside a long article: 'do I need a photo to return a damaged item?' Note the top hit's score — respectable — then open the article and count how much of it is irrelevant to the question.
- 2Ask something that spans two articles ('is the holiday return window different for damaged items?'). Watch whole-document retrieval force a choice between them.
- 3Write down both observations in
LAB-NOTES.md. They are the entire case for Module 2.
In the workbook: given five query/document pairs with their cosine scores across two different embedding models, answer: which comparisons are legitimate, which are meaningless, and which model looks better for that corpus — and what single additional measurement you'd demand before deciding.