Back to course overview
Module 1Embeddings fundamentals 20 min

Lab: First embedding

Set up the project, embed the Harbor Lane corpus whole-document, run first searches — and meet the failure that motivates chunking.

Before you start — two accounts, one small budget

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

Runnable starter kit: course-assets/harbordocs/

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

terminalbash
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 on
Make your keys stick

An 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:

.envbash
VOYAGE_API_KEY=your-voyage-key-here
ANTHROPIC_API_KEY=your-anthropic-key-here

Step 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:

Prompt to try

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

embeddings.pypython
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)  # normalize

voyage-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.

  1. 1Write build_index.py: split corpus.txt on ===, embed each whole article, save np.save('index.npy', ...) plus a parallel list of titles in docs.json.
  2. 2Write ask.py: take a query from the command line, embed it, run top_k from the lesson, print the top-3 titles with scores.
  3. 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:

build_index.pypython
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")
ask.pypython
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

  1. 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.
  2. 2Ask something that spans two articles ('is the holiday return window different for damaged items?'). Watch whole-document retrieval force a choice between them.
  3. 3Write down both observations in LAB-NOTES.md. They are the entire case for Module 2.
Problem set 1

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.