CourseRetrieval-Augmented Generation: The Complete Course · Module 1: RAG Foundations · part 10 of 95
Part 10 · Module 1: RAG Foundations

Lesson 4: The course stack: every tool, one question

19 min read·13 Oct 2026
Module 1 · Lesson 4 · About 45 minutes · Needs: Python basics and a Groq or Gemini key (course-guide/02); no earlier lesson's code

The situation

Before anyone writes ingestion code, Kwame asks: which tools? His prototype used whatever the first tutorial used, and he could not say which parts sent Hollin's documents somewhere else.

This lesson lays out the course's stack and runs every piece once on Ellie Shaw's question: "Is a repeated E7 motor overheating fault on the X200 blender covered by the warranty?" The Hollin data is simulated.

A real case, first

On 14 July 2023 Max Woolf, a former senior data scientist at BuzzFeed, published "The Problem With LangChain" (minimaxir.com, 2023). At BuzzFeed he had built a recipe chatbot for the Tasty brand: recipes "converted to embeddings and saved in a vector store", the nearest ones "fed to ChatGPT as added context". That is retrieval-augmented generation, and LangChain was "by-far the popular tool of choice for RAG".

WOOLF, TASTY RECIPE BOT, 2023 RAG bot to build a month on LangChain lower-level flow "wasted a month" "immediately outperformed" recipes as embeddings
Woolf spent a month on LangChain for a RAG recipe bot (red: the failure), then a plain lower-level flow did better (green).

It went badly. "I wasted a month learning and testing LangChain." The team "went back to a lower-level ReAct flow, which immediately outperformed my LangChain implementation in conversation quality and accuracy." His summary: it "makes simple things relatively complex", and its "prompt engineering is just f-strings". This is one engineer's account of LangChain in 2023; LangChain has since reached version 1.0. The lesson for this course still holds: build each step in plain Python first, so you know what a framework is doing for you.

What you will be able to do

  • Name the tool for each step of Hollin Answers and say what runs locally.
  • Set up keys safely and switch model provider by changing one word.
  • Embed, rerank and store Hollin's documents with local open models.
  • Spot a mismatched embedding model that fails without an error.
  • Build the same retrieval in plain Python and in LangChain, and read the prompt each sends.

1. Course stack at a glance (Essential)

You will meet a dozen libraries. Knowing which step each serves stops them blurring together.

HOLLIN ANSWERS: ONE TOOL PER STEP load plain Python chunk text splitters embed bge-small store Chroma, FAISS search BM25 + dense rerank cross-encoder answer 3 providers evaluate own harness
Every step of Hollin Answers has one chosen tool (blue: your system; amber: the language model).

A RAG system is a chain of steps: load, chunk, embed, store, search, rerank, answer. Each has one tool in this course, chosen because it is open source and runs in Colab. Modules 2 to 12 build each step properly; this lesson runs a small version of most of them once.

INSIDE THE NOTEBOOK embed rerank store Hollin text stays here model API Groq or Gemini prompt leaves with Ollama, the model also runs locally
Only the answer step sends text out (amber: the model); embedding, reranking and storage run on Colab's CPU.

The important line is the one around your notebook. Embedding, reranking and storage run on Colab's processor; only the answer step sends text to a provider. Farah, who asks what leaves the building, now has a one-line answer.

THIS LESSON'S LIBRARIES sentence-transformers 6.1.0 pinned chromadb 1.5.9 pinned langchain-huggingface 1.2.3 pinned langchain latest (-U) setup cell langchain-groq latest (-U) setup cell
The extras are pinned to versions checked on PyPI (green); the setup cell's provider libraries take the latest (grey).

The libraries beyond the setup cell are pinned: installed at an exact version that was checked on PyPI, so a lesson behaves the same next month. The setup cell's own libraries take the latest version, because provider connectors must keep up with the providers. This lesson needs three extras.

python
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollama
python
%pip install -q sentence-transformers==6.1.0 chromadb==1.5.9 langchain-huggingface==1.2.3

Code walkthrough

  • The first cell is the standard install; -U takes the newest versions. The second pins this lesson's extras from course-guide/02.

2. Project setup and environment keys (Essential)

An API key is a password that spends money. Most leaked keys are leaked by accident, pasted into a notebook that someone later shares.

Colab Secrets .env file (local) os.environ provider client key in a cell shared notebook anyone can spend it
Keys reach the client through Colab Secrets or a local .env file; a key typed into a cell is shared with the notebook (red: danger).

In Colab the key lives in the Secrets panel, named exactly GROQ_API_KEY or GOOGLE_API_KEY; on your own computer, in a .env file that Git ignores. Either way the code reads it from an environment variable, a named value handed to the running program. The key never appears in a cell.

PROVIDER "groq" MODELS groq:openai/gpt-oss-20b KEY GROQ_API_KEY init_chat_model builds llm
One word, PROVIDER, picks the model string and the key name; init_chat_model turns them into llm.

The setup cell is the same in every lesson that calls a model. One word, PROVIDER, picks the model name and the key name; the cell copies the key into the environment and builds llm.

python
import os
from langchain.chat_models import init_chat_model

PROVIDER = "groq"   # "groq" or "gemini" in Colab; "ollama" on your own computer
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEY = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}[PROVIDER]
if KEY and KEY not in os.environ:
    from google.colab import userdata   # keys live in Colab's Secrets panel
    os.environ[KEY] = userdata.get(KEY)
llm = init_chat_model(MODELS[PROVIDER], temperature=0, max_retries=6)

Code walkthrough

  • MODELS is the one place model names appear. Both were listed by their providers on 12 October 2026: openai/gpt-oss-20b as a Groq production model, gemini-3.5-flash-lite as a stable Gemini model. Names change often; if one fails with "model not found", replace it here.
  • userdata.get(KEY) reads the secret; os.environ[KEY] = ... puts it where the provider library looks.
  • max_retries=6 waits and retries when the provider says "too many requests".

Check what you have without printing the key:

python
from importlib.metadata import version

for name in ("GROQ_API_KEY", "GOOGLE_API_KEY"):
    value = os.environ.get(name, "")
    print(f"{name:15} {'set, ' + str(len(value)) + ' characters' if value else 'not set'}")
print("provider:", PROVIDER, "->", MODELS[PROVIDER])
for pkg in ("langchain", "langchain-groq", "langchain-google-genai", "sentence-transformers", "chromadb"):
    print(f"{pkg:22} {version(pkg)}")

Code walkthrough

  • The loop reports each key as set or not set, and its length, never its value. In Colab only the chosen provider's key is set.
  • version(pkg) reads the installed version, so you can compare it with course-guide/02 when something behaves differently.

3. LLM providers behind one setting (Essential)

Providers change prices, limits and model names. If the provider is one setting, any change costs you one edited word.

llm.invoke() ChatGroq Gemini connector ChatOllama groq: google_genai: ollama: prefix decides
The prefix before the colon picks the connector (amber); your code only ever calls llm.

init_chat_model reads the part of the model string before the colon (groq, google_genai, ollama) and builds the matching connector, the small library that speaks that provider's API. Every connector offers the same methods, so the rest of the lesson talks to llm and never names a provider again. Ollama runs the model on your own machine, so nothing leaves it.

invoke error invoke again reply (None, provider_error) next step handles it no crash
ask() tries once more after an error (red), and after a second failure returns a named reason instead of crashing (grey).

Providers sometimes reject a reply even though your request was fine. The course wraps every model call in a guard: try once more, and after a second failure return no reply with a named reason, provider_error, which the next step handles instead of crashing.

python
def ask(model, messages):
    """Returns (reply, None), or (None, reason) after two refusals."""
    for attempt in range(2):                     # one retry: the next reply is usually fine
        try:
            return model.invoke(messages), None
        except Exception as err:
            last = err
    return None, f"provider_error: {type(last).__name__}"

reply, reason = ask(llm, "Reply with the single word: ready")
print(reply.text if reply else reason)

Code walkthrough

  • model.invoke(messages) sends the request; except catches a failure and the loop tries once more.
  • The last line prints the reply (look for ready), or the reason if both attempts failed.
SAME CALL, DIFFERENT STRING init_chat_model(...) "groq:openai/gpt-oss-20b" "gpt-6-astra" (OpenAI key) "claude-sonnet-5-5" (Anthropic key) grey: shown from docs, not run here
Closed models use the same call with another string and package; they need paid keys, so they are shown, not run (grey).

Closed models plug into the same line. The snippet below comes from LangChain's documentation; it needs paid keys, so it is not run here.

text
# not run here: needs paid keys. Call shape from https://docs.langchain.com/oss/python/langchain/models;
# model ids from https://developers.openai.com/api/docs/models and
# https://platform.claude.com/docs/en/about-claude/models/overview (all checked 12 October 2026)
# pip install -U "langchain[openai]" "langchain[anthropic]"
from langchain.chat_models import init_chat_model

llm = init_chat_model("openai:gpt-6-astra")            # reads OPENAI_API_KEY
llm = init_chat_model("anthropic:claude-sonnet-5-5")   # reads ANTHROPIC_API_KEY

Code walkthrough

  • Each provider needs its own connector package and its own key; the call that builds llm is unchanged.
  • Ids checked 12 October 2026 on each provider's models page; they change often.

4. Embedding model options (Essential)

Retrieval quality starts with the embedding model, and changing it later means re-embedding everything. It is worth knowing what you picked and why.

The data: the date helper and the policies and manuals blocks from the shared data, 14 documents in all.

python
from datetime import date

COURSE_DATE = date(2026, 10, 9)   # "today" in every lesson; never use date.today()
SNAPSHOT = date(2026, 10, 2)      # the day the corpus below was exported

SOURCE = {"policy": "help-centre", "faq": "help-centre", "notice": "help-centre",
          "manual": "manuals", "bulletin": "service-portal", "ticket": "ticket-system",
          "partner": "partner-portal", "internal": "finance-share", "forum": "community-forum"}
AUTHORITY = {"policy": 1.0, "notice": 1.0, "manual": 0.9, "bulletin": 0.9, "partner": 0.8,
             "internal": 0.8, "faq": 0.7, "ticket": 0.3, "forum": 0.1}

def doc(id, type, title, updated, text, acl=("public",), products=(), status="current", authority=None):
    """One document record. acl lists the groups allowed to see it."""
    return {"id": id, "type": type, "title": title, "updated": updated, "text": text,
            "acl": list(acl), "products": list(products), "status": status,
            "source": SOURCE[type], "authority": AUTHORITY[type] if authority is None else authority}

DOCS = []

Code walkthrough

  • doc(...) builds one record: ID, type, title, date, text, access list (acl) and status.
python
DOCS += [
    doc("pol-warranty-2026", "policy", "Warranty terms (2026)", "2026-03-01",
        "Every Hollin appliance has a 2-year warranty from the date of purchase. Blender motors on the X200 "
        "and X300 Pro are covered for 5 years. The warranty covers manufacturing faults. It does not cover "
        "blade wear, cracked jars, limescale damage or accidental damage. You need proof of purchase to claim."),
    doc("pol-warranty-2024", "policy", "Warranty terms (2024, archived)", "2024-02-10",
        "Every Hollin appliance has a 1-year warranty from the date of purchase. Blender motors are covered "
        "for 2 years. The warranty covers manufacturing faults and cracked jars. You need proof of purchase "
        "to claim.", status="archived"),
    doc("pol-repairs", "policy", "Repairs and turnaround", "2026-05-12",
        "We aim to repair and return an appliance within 10 working days of receiving it. Collection by "
        "courier is free for appliances under warranty. Out-of-warranty repairs carry a £25 inspection fee, "
        "which is deducted from the repair price if you go ahead."),
    doc("pol-spare-parts", "policy", "Ordering spare parts", "2026-04-20",
        "Spare parts are sold at parts.hollin.example. Standard delivery takes 3 to 5 working days and costs "
        "£3.95. Delivery is free on parts orders over £30."),
    doc("pol-privacy", "policy", "Your account data", "2026-02-01",
        "You can download or delete your account data from Settings, then Privacy. Deletion requests are "
        "completed within 30 days."),
    doc("pol-registration", "policy", "Registering your appliance", "2025-11-02",
        "Register your appliance at hollin.example/register so we can contact you about safety notices. "
        "You do not need to register to claim under the warranty."),
]

Code walkthrough

  • Six policies. pol-warranty-2026 holds Ellie's answer; pol-warranty-2024 is archived and says motors get 2 years.
python
DOCS += [
    doc("man-x200-specs", "manual", "X200 Blender specifications", "2025-06-01",
        "The X200 blender has a 1,200 W motor, a 2-litre Tritan jar and three speeds. High speed reaches "
        "22,000 RPM. Model number HL-X200-UK.", products=["X200"]),
    doc("man-x200-errors", "manual", "X200 error codes", "2025-06-01",
        "E4 means the jar lid is not locked: twist the lid until it clicks. E7 means the motor has overheated: "
        "unplug the blender for 20 minutes. E9 means a sensor fault: contact Hollin support.", products=["X200"]),
    doc("man-x200-cleaning", "manual", "X200 cleaning and care", "2025-06-01",
        "Wash the jar and blades in warm soapy water or in the dishwasher. Never put the motor base in water; "
        "wipe it with a damp cloth.", products=["X200"]),
    doc("man-x200-blades", "manual", "X200 blade assembly", "2025-06-01",
        "A grinding noise, or food left in chunks, usually means the blades are worn. Replace the blade "
        "assembly, part BLD-X200, about once a year with daily use.", products=["X200"]),
    doc("man-x300-specs", "manual", "X300 Pro Blender specifications", "2026-04-10",
        "The X300 Pro blender has a 1,500 W motor, a vacuum blending mode and ten speeds. High speed reaches "
        "30,000 RPM. Model number HL-X300-UK.", products=["X300"]),
    doc("man-x300-errors", "manual", "X300 Pro error codes", "2026-04-10",
        "E4 means the vacuum lid seal is loose: take the seal out and press it back into its groove. E12 means "
        "the vacuum pump has failed: contact Hollin support.", products=["X300"]),
    doc("man-k50-kettle", "manual", "K50 Smart Kettle", "2025-09-09",
        "The K50 smart kettle boils 1.7 litres in about four minutes and can keep water warm for up to two "
        "hours. Descale it once a month with citric acid.", products=["K50"]),
    doc("man-k50-app", "manual", "Pairing the K50 with the Hollin Home app", "2025-09-09",
        "To pair the K50 with the Hollin Home app, hold the temperature dial down for 5 seconds until the ring "
        "light flashes white, then choose Add appliance in the app.", products=["K50"]),
]
STANDALONE = "Is a repeated E7 motor overheating fault on the X200 blender covered by the warranty?"

Code walkthrough

  • Eight manual pages. man-x200-errors says E7 means the motor overheated: the other half of Ellie's answer.
  • STANDALONE is Ellie's question, copied from the shared data.
Ellie's question "Is a repeated E7 ..." bge-small -0.057 0.025 0.081 ... 384 numbers, length 1
bge-small (amber: a model) turns Ellie's question into 384 numbers, starting -0.057, 0.025, 0.081, scaled to length 1.

An embedding model turns text into a list of numbers, a vector, so that texts with similar meaning get similar vectors. The course uses BAAI/bge-small-en-v1.5: small (about 130 MB), English, free, and run locally through sentence-transformers. Each text becomes 384 numbers, scaled so the vector has length 1.

EMBEDDING OPTIONS bge-small-en-v1.5 local, 384 course default all-MiniLM-L6-v2 local, 384 small, older gemini-embedding-2 hosted key, text leaves text-embedding-3-small hosted key, text leaves
Local models keep Hollin's text on the machine; hosted ones need a key and send every document out (grey: named, not used here).

Local open models run on your machine and need no key. Hosted models, such as Google's gemini-embedding-2 (gemini-embedding-2-preview on Google's models page, 12 October 2026) or OpenAI's text-embedding-3-small, need a key and every document goes out. Module 4 compares them. bge-small is the default because Hollin's text stays in the notebook.

python
from sentence_transformers import SentenceTransformer

embedder = SentenceTransformer("BAAI/bge-small-en-v1.5")
D = embedder.encode([d["text"] for d in DOCS], normalize_embeddings=True)
q = embedder.encode(STANDALONE, normalize_embeddings=True)
print("documents:", D.shape, " question:", q.shape, " first numbers:", q[:3].round(3))

scores = D @ q
top5 = scores.argsort()[::-1][:5]
for i in top5:
    print(f"{scores[i]:.3f}  {DOCS[i]['id']:18} {DOCS[i]['status']}")

Code walkthrough

  • SentenceTransformer(...) downloads the model once and runs it on the CPU.
  • normalize_embeddings=True gives every vector length 1, so D @ q, one dot product per document, is the cosine similarity.
  • argsort()[::-1][:5] takes the five highest scores. Shapes print as (14, 384) and (384,).
BGE-SMALL, ELLIE'S QUESTION, TOP 5 pol-warranty-2026 0.788 man-x200-errors 0.741 man-x200-specs 0.739 pol-warranty-2024 0.725 archived man-x300-specs 0.684
bge-small ranks the two useful pages first (green), but the archived 2024 terms (red) sit only 0.063 behind the top.

The current warranty comes first at 0.788 and the E7 page second at 0.741. The archived 2024 terms sit 4th at 0.725, very close behind. Similarity knows nothing about dates or status: that is Modules 5 and 6's work.

Break it: query with a different model

Documents and questions must be embedded by the same model. Swap only the question's model for another small one, all-MiniLM-L6-v2, which also makes 384 numbers.

python
other = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
q_wrong = other.encode(STANDALONE, normalize_embeddings=True)
wrong = D @ q_wrong                      # bge documents, MiniLM question: shapes still match
for i in wrong.argsort()[::-1][:5]:
    print(f"{wrong[i]:.3f}  {DOCS[i]['id']}")

Code walkthrough

  • other.encode(...) makes a 384-number vector from a different model, so D @ q_wrong runs without any error.
TOP SCORE, SAME 14 DOCUMENTS bge question 0.788 MiniLM question 0.216 cut-off 0.5 no error raised: both are 384 numbers
Querying bge vectors with a MiniLM question raises no error, but the top score falls from 0.788 to 0.216 (red: silently wrong).

Nothing failed, yet the numbers are meaningless: every score falls to about 0.2 (0.216 at the top), and pol-repairs enters the top 5. The two models put meaning in different places. A cut-off of 0.5 tuned for bge would now return nothing, which is why Module 4 stores the model name with every vector.

5. Cross-encoder rerankers from Hugging Face (Recommended)

Similarity search is fast because it compares vectors made separately. A slower second reader can reorder its few candidates.

BI-ENCODER CROSS-ENCODER question document model model cos question + document one score fast, separate slow, together
A bi-encoder embeds separately and compares vectors; a cross-encoder reads question and document together for one score.

A cross-encoder reads the question and one document together and returns one relevance score, so "covered" in the question can meet "covered for 5 years" in the document. It is too slow for a whole corpus, so it reranks only the top few. The course uses cross-encoder/ms-marco-MiniLM-L6-v2 from Hugging Face, about 90 MB.

python
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
candidates = [DOCS[i] for i in top5]
ranked = reranker.rank(STANDALONE, [d["text"] for d in candidates])
for r in ranked:
    print(f"{r['score']:6.2f}  {candidates[r['corpus_id']]['id']}")

Code walkthrough

  • candidates are bge's top 5 documents.
  • reranker.rank(question, texts) scores each pair and returns them best first; corpus_id points back into candidates.
BGE ORDER RERANKED pol-warranty-2026 man-x200-errors man-x200-specs pol-warranty-2024 man-x300-specs man-x200-errors 3.48 pol-warranty-2026 3.25 pol-warranty-2024 -0.35 man-x200-specs -2.48 man-x300-specs -4.57
The reranker puts the E7 page and the 2026 terms on top (green) and drops the archived terms below zero (red).

The reranker puts the E7 page first (3.48) and the 2026 terms second (3.25): together, exactly the two documents Ellie's answer needs. The archived terms drop to -0.35, and the two specification pages fall well below zero.

CROSS-ENCODER SCORES ARE LOGITS -4 -2 0 2 4 E7 page 3.48 sink q01: right page -2.59 a cut-off at 0 would drop q01's answer
Scores are logits with no fixed range: q01's correct page scores -2.59 and still ranks first, so a cut-off at 0 would drop it.

These scores are logits: raw numbers with no fixed range, not probabilities. The Sentence Transformers documentation says MS MARCO models "return logits rather than scores between 0 and 1". Only the order is reliable.

Break it: treat the score as a probability

python
q01 = "can I put the blender base in the sink"       # labelled q01 in the shared data
cands = [DOCS[i] for i in (D @ embedder.encode(q01, normalize_embeddings=True)).argsort()[::-1][:5]]
best = reranker.rank(q01, [d["text"] for d in cands])[0]
print(cands[best["corpus_id"]]["id"], round(float(best["score"]), 2), "kept" if best["score"] > 0 else "dropped by a cut-off at 0")

Code walkthrough

  • The same retrieve-then-rerank, on another labelled question. best is the reranker's first choice.

The right page, man-x200-cleaning, comes first with -2.59. A cut-off at 0, picked because it "looks like" a probability boundary, would throw away a correct answer. Module 8 sets thresholds from measured questions.

6. Vector database options (Recommended)

With 14 documents a NumPy array is enough. Hollin has thousands of pages, several users and documents that change hourly, and that needs a store.

WHERE VECTORS LIVE NumPy array this lesson Chroma, FAISS notebooks pgvector, Qdrant the service hosted DBs named only small shared, large
The course moves from a NumPy array to Chroma and FAISS in notebooks and pgvector or Qdrant for the service; hosted databases are named only (grey).

A vector store keeps vectors with their IDs and metadata, and finds the nearest ones to a query. This course uses Chroma and FAISS in notebooks (Module 5), and pgvector, the PostgreSQL extension, or Qdrant for the capstone service. Hosted vector databases are named only.

ONE CHROMA RECORD id pol-warranty-2026 embedding 384 numbers document the text metadata status: current query: Ellie's vector ids + distances (1 - cos)
A Chroma record is an ID, a vector, the text and metadata; a query returns IDs and distances, where distance is 1 minus cosine.

In Chroma each record is an ID, a vector, the text and metadata, extra fields such as status that a query can filter on. Passing your own vectors keeps bge-small in charge; otherwise Chroma would embed with its own default model.

python
import chromadb

client = chromadb.Client()                                   # in memory: gone when the notebook stops
col = client.get_or_create_collection("hollin_docs", configuration={"hnsw": {"space": "cosine"}})
col.add(ids=[d["id"] for d in DOCS], embeddings=D.tolist(), documents=[d["text"] for d in DOCS],
        metadatas=[{"status": d["status"], "type": d["type"]} for d in DOCS])

for where in (None, {"status": "current"}):
    res = col.query(query_embeddings=[q.tolist()], n_results=4, where=where)
    print(where, [(i, round(1 - dist, 3)) for i, dist in zip(res["ids"][0], res["distances"][0])])

Code walkthrough

  • chromadb.Client() starts an in-memory store; {"space": "cosine"} makes distance equal to 1 minus cosine similarity.
  • col.add(...) stores IDs, our vectors, texts and two metadata fields.
  • where={"status": "current"} filters before ranking. 1 - dist turns distance back into similarity.

Without the filter, the top 4 match the NumPy scores and include the archived terms. With it, pol-warranty-2024 is gone and man-x300-specs takes 4th place.

7. Frameworks: plain Python, LangChain, LlamaIndex (Recommended)

Woolf lost a month to a framework he could not see into. The course's rule is plain Python first, then the framework, so you always know what it does for you.

SAME PIPELINE, THREE WAYS plain Python you write each step you see everything LangChain common interfaces print the prompt LlamaIndex folder to answer most is hidden this course: plain first, then LangChain
Plain Python shows every step (green); LangChain gives common interfaces; LlamaIndex, shown as an example only (grey), hides the most.

LangChain offers one interface for models, embeddings, prompts and vector stores; the setup cell already uses it. LlamaIndex is built around indexing and querying documents. Both are open source. This course uses LangChain (and LangGraph for loops) and shows LlamaIndex as an example only.

man-x200-errors pol-warranty-2026 rules + question prompt printed first answer with [ids]
The prompt is an f-string built from the reranker's top 2 (green), printed before it is sent, so nothing is hidden.

First the plain version. The prompt is an f-string you can print, built from the reranker's top 2.

python
def build_prompt(question, docs):
    sources = "\n".join(f"[{d['id']}] {d['text']}" for d in docs)
    return ("Answer using only the sources below. Cite the source id in square brackets after each "
            "claim. If the sources do not answer the question, say you do not know.\n\n"
            f"Sources:\n{sources}\n\nQuestion: {question}")

top2 = [candidates[r["corpus_id"]] for r in ranked[:2]]
prompt = build_prompt(STANDALONE, top2)
print(prompt, "\n")

reply, reason = ask(llm, prompt)
answer = reply.text.strip() if reply else ""
print(answer or {"answer": None, "reason": reason or "empty reply"})

Code walkthrough

  • build_prompt joins each source with its ID in brackets, then adds the rules and the question.
  • ask(llm, prompt) sends it through the guard. answer or {...} prints a clear "no answer" result when the provider failed or replied with nothing.

Look for "yes", the 5-year motor cover, and the IDs [pol-warranty-2026] and [man-x200-errors]. Wording varies by provider.

HuggingFace Embeddings (bge) InMemory VectorStore retriever k = 3 prompt template llm template | llm is the chain same top 3 as NumPy
LangChain wraps the same bge model in an embeddings class and a store; its retriever returns the same top 3 (green).

Now LangChain. HuggingFaceEmbeddings wraps the same bge model, InMemoryVectorStore stores the vectors, and a prompt template piped into llm makes a chain: a sequence of steps run with one call.

python
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate

lc_embed = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5", encode_kwargs={"normalize_embeddings": True})
store = InMemoryVectorStore(lc_embed)
store.add_documents([Document(page_content=d["text"], metadata={"id": d["id"]}) for d in DOCS])
hits = store.as_retriever(search_kwargs={"k": 3}).invoke(STANDALONE)
print("LangChain top 3:", [h.metadata["id"] for h in hits])

template = ChatPromptTemplate.from_template("Answer using only these sources, citing ids in brackets.\n{sources}\n\nQuestion: {question}")
inputs = {"sources": "\n".join(f"[{h.metadata['id']}] {h.page_content}" for h in hits), "question": STANDALONE}
print(template.invoke(inputs).to_messages()[0].content[:160], "...")
reply, reason = ask(template | llm, inputs)
print(reply.text.strip() if reply and reply.text.strip() else {"answer": None, "reason": reason or "empty reply"})

Code walkthrough

  • store.as_retriever(...).invoke(question) embeds the question and returns the 3 nearest Document objects. They print as pol-warranty-2026, man-x200-errors, man-x200-specs: the same as NumPy.
  • template.invoke(inputs) fills the template; printing it shows the exact text the chain will send.
  • template | llm is the chain; ask sends it the inputs dictionary.

This chain skips the reranker, so the X200 specification page goes along as a third source; adding a reranker is a step you can now write yourself. LlamaIndex packs the same steps into fewer lines. Its documentation (not run here) shows:

text
# not run here: LlamaIndex is shown as an example only.
# From https://developers.llamaindex.ai/python/framework/getting_started/starter_example/ (checked 12 October 2026)
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5")
documents = SimpleDirectoryReader("data").load_data()
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine()
response = await query_engine.aquery(query)

Code walkthrough

  • SimpleDirectoryReader loads a folder, VectorStoreIndex embeds and stores, as_query_engine retrieves and answers. The default model is OpenAI's, per its documentation.

In production

LANGCHAIN VERSIONING, CHECKED 12 OCT 2026 1.0 LTS: active 2.0 (no date) 1.0: maintenance breaking changes only in majors at least 1 year pin versions; keep model names in one place
LangChain 1.0 is a long-term support release; after 2.0 it gets at least a year of maintenance, and breaking changes wait for a major version.

Teams pin versions and keep model names in one place, as this course does. LangChain's versioning page (checked 12 October 2026) says LangChain and LangGraph 1.0 "are designated as LTS releases", that breaking changes come "only in major releases", and that after 2.0, 1.0 enters maintenance "for at least 1 year". What is likely to change: model names on all three providers, which models are free, and integration packages. The langchain-community repository now says it "has been sunset" and was archived on 19 June 2026, so this course uses partner packages such as langchain-huggingface. Check course-guide/02 before assuming a name still works.

What you added to Hollin Answers

  • A stack map: one tool per step, with a clear line where text leaves the notebook.
  • The setup cell, a key check that never prints a key, and the ask() guard.
  • bge-small embeddings, a cross-encoder reranker and a Chroma collection over 14 documents.
  • One grounded, cited answer to Ellie, built in plain Python and again in LangChain.

Check yourself

  1. Which steps in this lesson send Hollin's text to an outside service?

Only the answer step, which calls the language model. Embedding, reranking and the Chroma store all run in the notebook. With Ollama, nothing leaves the machine at all.

  1. You switch from Groq to Gemini. What do you change?

One word: PROVIDER = "gemini", plus a GOOGLE_API_KEY secret. MODELS already holds the model name and init_chat_model builds the right connector.

  1. Why did the mismatched query in "Break it" not raise an error?

Both models make 384 numbers, so the shapes match and the dot product runs. The numbers mean different things in each model, so the scores dropped to about 0.2 and the ranking changed.

  1. The reranker scores a document -2.59. Is it irrelevant?

Not necessarily. MS MARCO cross-encoders return logits, not probabilities. For "can I put the blender base in the sink" the correct page scored -2.59 and still came first.

Exercise

Write retrieve(question, k=5, keep=2): bge-small top k from D, reranked with reranker, returning the IDs of the best keep. Then run the check.

python
def check(retrieve):
    got = retrieve("is blade wear covered for the X200")      # q23 in the shared data
    assert got[0] == "pol-warranty-2026", got
    assert retrieve(STANDALONE) == ["man-x200-errors", "pol-warranty-2026"], retrieve(STANDALONE)
    print("passed:", got)

# check(retrieve)      # uncomment once the function is written

Code walkthrough

  • The first check is q23: bge-small ranks the blade manual above the warranty, and the reranker should fix it. The second repeats Ellie's question.

Key terms

TermMeaning
Pinned versionA library installed at an exact version, so code behaves the same later
Environment variableA named value given to a running program; where keys are read from
ConnectorThe library that speaks one provider's API behind LangChain's common interface
GuardA wrapper that retries a model call once, then returns a named reason
Embedding modelA model that turns text into a vector of numbers
Cosine similarityHow closely two vectors point the same way; a dot product for unit vectors
Cross-encoderA model that reads a question and a document together and scores the pair
LogitA raw model score with no fixed range; only its order is meaningful here
Vector storeA store of vectors, IDs and metadata that finds the nearest vectors to a query
ChainLangChain steps joined with `` and run with one call

Sources

  • Max Woolf, "The Problem With LangChain", 2023. https://minimaxir.com/2023/07/langchain-problem/
  • LangChain, "Models" and "Versioning", documentation, checked 12 October 2026. https://docs.langchain.com/oss/python/langchain/models and https://docs.langchain.com/oss/python/versioning
  • Groq, "Supported models", checked 12 October 2026. https://console.groq.com/docs/models
  • OpenAI, "Models", checked 12 October 2026. https://developers.openai.com/api/docs/models
  • Anthropic, "Models overview", checked 12 October 2026. https://platform.claude.com/docs/en/about-claude/models/overview
  • Google, "Gemini models" and "Embeddings", checked 12 October 2026. https://ai.google.dev/gemini-api/docs/models and https://ai.google.dev/gemini-api/docs/embeddings
  • Sentence Transformers, "Cross Encoder usage", checked 12 October 2026. https://sbert.net/docs/cross_encoder/usage/usage.html
  • Chroma, "Getting started", checked 12 October 2026. https://docs.trychroma.com/docs/overview/getting-started
  • LangChain, langchain-community repository README, archived 19 June 2026. https://github.com/langchain-ai/langchain-community
  • LlamaIndex, "Starter tutorial", checked 12 October 2026. https://developers.llamaindex.ai/python/framework/getting_started/starter_example/