A frame to help you reason through the assignment — concepts, diagrams, and the API map. It deliberately does not contain the answers or filled-in activity code. Instead it gives you the questions to ask yourself and the cells to go inspect. The work — and the learning — is in running the cells, comparing what actually comes back, and writing your own conclusions.
Notebook:
multimodal_rag.ipynbCorpus:./data— six synthetic ACME Robotics FY2024 charts + six short Markdown docs + one ~45s narrated-slide video (all CC0, auto-generated if missing). The notebook tells you where the real numbers live and why that matters — pay attention to that design note in Section 3.
You already know text RAG: chunk, embed, retrieve, answer. Session 14 begins exactly
where that instinct breaks. This corpus hides its real numbers — "$27M in Q4", "240ms
in APAC" — only in the chart pixels, in no source text file anywhere (Section 3 says so out
loud). Be precise about which text, though: the VLM parse step (Cell 4.2) later reads those numbers
off the pixels and writes them into a generated caption — so once you've run Section 4, the numbers
do live in some text (the Chunk.content). "Only in the pixels" is a claim about the shipped
corpus, not about what your own pipeline produces — and the gap between those two is exactly what
Question #3 turns on. So before you write a line of code, sit with the question the whole session answers:
If every fact you needed already lived in the text of your documents, would you need a vision model at all?
Three tempting shortcuts — feel the limit of each as you run the notebook:
- "Just caption every image once, then do text RAG" (that's Strategy A). A caption is written once, before anyone asks a question. What happens when the question needs a number the caption never wrote down?
- OCR / table-extraction. Great when a value is printed text. What does it do with a number that lives in a bar's height or a pie slice's angle — a shape, not a string?
- Pure pixel matching (CLIP). It can find the right chart from a text query with no caption at all — but can it read the exact number off it?
Keep two verbs separate all session: find and read. For every component you build, ask which job it is doing — locate the right chart, or read the value off it. The whole pipeline is an answer to which tool does which. (Name the costs too: pixels are token-expensive, and CLIP has a "modality gap" — you'll meet both.)
This single split — find the source, then read the evidence — is the spine of the whole pipeline, and it's also how you diagnose a wrong answer: which half broke?
flowchart TD
Q["Question"] --> F["FIND the source<br/>captions / CLIP / Qdrant / RRF"]
F --> S["Selected chart"]
S --> R["READ the evidence<br/>original pixels + VLM"]
R --> A["Grounded answer"]
F --- FF["retrieval failure:<br/>correct chart never selected"]
R --- RF["interpretation failure:<br/>chart selected, value misread"]
classDef fail fill:#fdecea,stroke:#c0392b,color:#611a15;
class FF,RF fail;
ASCII fallback:
Question
│
▼
┌───────────────────┐ ✗ RETRIEVAL failure:
│ FIND the source │ ── the correct chart was never selected
│ captions·CLIP· │
│ Qdrant·RRF │
└─────────┬─────────┘
▼
Selected chart
│
▼
┌───────────────────┐ ✗ INTERPRETATION failure:
│ READ the evidence │ ── the chart was selected, but its
│ original pixels + │ value was read incorrectly
│ VLM │
└─────────┬─────────┘
▼
Grounded answer
When an answer is wrong, locate the break: did retrieval hand over the wrong chart (a find failure, fixed by Strategy choice / filtering / fusion), or did the model misread a chart it was given (a read failure, fixed by sending real pixels to the VLM)? The two failures live in different halves and have different fixes — that's why the whole session keeps them apart.
You are not starting from zero — three pieces here have cohort history. Notice them as they reappear:
- Qdrant has been your vector store since Session 1; here it just runs in
:memory:mode. - Reciprocal Rank Fusion (Strategy C) is the same fusion you hand-wrote in Session 7 to combine BM25 + dense results — the formula didn't change, only why the two lists don't compare did. (More on this at Question #4 below.)
- The
recall@k-style + gold-set eval (Section 8) is the harness you built in Session 7 (retrieval_eval.py, which computed true recall@k and MRR) and the lightweight cousin of Session 6's RAGAS. (Heads-up: the S14 version actually computes hit-rate@k — §4 has the note.)
The rest of this sheet points you at where to look; the conclusions are yours to write.
You are extending text RAG, not replacing it. Every row below is something you already built; the third column is the only thing Session 14 adds. Read it as "same muscle, one new joint."
| Earlier learning | Reused here (barely changed) | New multimodal wrinkle |
|---|---|---|
| Text chunks (S1) | one unified Chunk list |
some chunks are captions that point back to pixels via source_path |
| Text embeddings (S1) | OpenAI text-embedding-3-small |
it can't embed an image — you need a caption (A) or CLIP (B/C) |
| Qdrant retrieval (S1) | same QdrantVectorStore API, now :memory: |
one mixed collection + optional modality filter, or separate modality stores + RRF |
| BM25 + dense RRF (S7) | the same 1/(k+rank) fusion, unchanged |
the two lists now come from different-modality embedders, not two scorers over one text |
| Gold-set eval (S6/S7) | same question → correct-source-id mapping | a correct source can now be a chart image; the same gold-set pattern could later be extended to time-windowed video chunks |
| Retrieve-then-answer (all RAG) | same structure, one prompt | generation is handed the original pixels, not just retrieved text |
Keep this table open as you run: every time something feels new, find its row and notice that only the third column actually changed.
Same idea as a pipeline, so you can see where the additions land. The green lane is the text RAG you already built; the blue lane is Session 14. Only the CAPS stages are new or extended:
flowchart LR
subgraph TEXT["Text RAG — what you already built"]
direction LR
T1["documents"] --> T2["chunk"] --> T3["text<br/>embedding"] --> T4["retrieve<br/>(cosine)"] --> T5["LLM<br/>answer"]
end
subgraph MM["Session 14 — multimodal RAG"]
direction LR
M1["text + IMAGES"] --> M2["chunk<br/>+ VLM CAPTION"] --> M3["text embed<br/>OR CLIP PIXELS"] --> M4["CROSS-MODAL<br/>retrieve"] --> M5["VLM answer<br/>+ ORIGINAL PIXELS"]
end
classDef same fill:#e8f5e9,stroke:#2e7d32,color:#1b3d1c;
classDef ext fill:#e3f2fd,stroke:#1565c0,color:#0d2b4e;
class T1,T2,T3,T4,T5 same;
class M1,M2,M3,M4,M5 ext;
ASCII fallback (CAPS = new or extended; everything else is your existing text-RAG stage):
stage: source chunk embed retrieve answer
─────────────────────────────────────────────────────────────────────────────────────────────────
text RAG: documents split text text embedding cosine top-k LLM (text)
Session 14: text + IMAGES split + VLM CAPTION text emb OR CLIP CROSS-MODAL top-k VLM + ORIGINAL PIXELS
└ extended └ new step └ extended └ extended └ extended
The genuinely new roles are the VLM as ingestion parser (the CAPTION step), CLIP as the image-capable embedder (the PIXELS path), and the VLM as answer-time reader of the original pixels. Everything else is a familiar RAG stage extended to additional modalities.
If any answer is fuzzy, revisit the linked earlier session first; this session assumes all four.
- Text RAG loop (S1): can you name the five stages — chunk → embed → store → retrieve → answer — and say what each produces?
- Embeddings vs. similarity (S1): why does cosine over text vectors put a query near its answer, and what is an embedding of?
- RRF (S7): can you write
1/(k+rank)from memory and say why you fuse by rank instead of raw score? - Retrieval eval (S6/S7): what does a gold set contain, and what does "a correct id landed in the top-k" measure?
| You want to… | Reach for | One-liner |
|---|---|---|
| Load key + build the VLM | init_chat_model |
vlm = init_chat_model(VLM_MODEL, temperature=0) — one line swaps vision-capable providers (see §3 caveat) |
| Parse a chart into a searchable chunk | VLM as ingestion parser | vlm_parse_image(path) → strict JSON → folded into Chunk.content |
| Send an image to any chat call | image_block / multimodal_message |
{"type":"image","source_type":"base64","data":...,"mime_type":...} |
| Strategy A — caption→text | one Qdrant text collection | store_A = QdrantVectorStore.from_documents(docs_A, text_embedder, ...) |
| Strategy B — unified CLIP | shared text+image vector space | clip.embed_images(paths) + qdrant_store_from_vectors(...) |
| Strategy B, image-only slice | Qdrant metadata filter | retrieve_B(q, modality="image") |
| Strategy C — separate stores, fused | Reciprocal Rank Fusion (Session 7's fusion, hand-written) | reciprocal_rank_fusion([text_hits, img_hits]) |
| One retriever, strategy-swappable | MultiModalRetriever |
MultiModalRetriever(strategy="C", k=3).retrieve(query) |
| Generate a grounded answer | answer() |
answer(q, strategy="C") |
| Measure retrieval quality | recall_at_k |
recall_at_k("A", k=3) → hit-rate@3 (see §4 note) against a hand-written gold dict |
| Video RAG | transcript + keyframes → time-windowed chunks | build_video_chunks(...) → answer_video(q) cites [MM:SS] |
One sentence to keep in mind while you read the notebook: LangChain's Embeddings interface is
text-only, but chat models are multimodal — that single fact is the reason Section 5 needs three
different strategies at all. Everything else you're asked to explain traces back to it or to a
consequence of it. Don't take that on faith — go find the evidence for it yourself in Sections 0 and 5.
flowchart TD
IMG["6 chart PNGs"] -->|"VLM parses<br/>Cell 4.2"| CAP["JSON caption per chart"]
TXT["6 text docs"] --> CHUNKS["unified Chunk list"]
CAP --> CHUNKS
CHUNKS --> A["Strategy A<br/>caption-to-text embed<br/>one Qdrant store"]
CHUNKS --> B["Strategy B<br/>unified CLIP<br/>pixels + text, one space"]
CHUNKS --> C["Strategy C<br/>text store + image store<br/>fused by RRF"]
A --> RET["MultiModalRetriever"]
B --> RET
C --> RET
RET --> GEN["answer()<br/>reloads image file<br/>calls the VLM"]
RET --> EVAL["hit-rate@3 harness<br/>gold dict, A/B/C compared"]
VID["video: transcript + keyframes"] -->|"reuses Strategy C pattern"| VRET["retrieve_video to RRF"]
VRET --> VANS["answer_video()<br/>cites [MM:SS]"]
ASCII fallback:
6 charts ─VLM parse─► captions ─┐
6 docs ───────────────────────► ├─► chunks ─┬─► A (caption→text) ─┐
├─► B (unified CLIP) ├─► retriever ─► answer()
└─► C (text+img, RRF) ─┘ └─► hit-rate@3 eval (A/B/C)
video (transcript+keyframes) ──reuses Strategy C + RRF──► answer_video() [timestamp citation]
Orientation, not answer: Section 1 of the notebook tells you the VLM does two different jobs — find the sentence that names them and notice which sections of the notebook correspond to each. The retrieval strategies (A/B/C) exist for a reason spelled out in Section 0 — that's Question #1's whole territory. The video extension in Section 9 isn't a fourth strategy; look closely at which earlier function it reuses before you assume it's new machinery.
uv sync # from 14_multimodal_rag, then select the uv kernelNeeds OPENAI_API_KEY (VLM parsing + generation + text embeddings). First CLIP call downloads
clip-ViT-B-32 (~600MB) via sentence-transformers — a one-time cost. VLM_MODEL / EMBED_MODEL are
the only two lines to change for a provider swap — with a caveat the "one-line" framing hides: the new
provider must be vision-capable and must accept the same v1 image content-block shape
({"type":"image","source_type":"base64",...}). init_chat_model unifies the call, not each
provider's multimodal support — a text-only or differently-shaped model will import fine and then fail at
the first image. Build the env on Python 3.13 (this session pins
>=3.13,<3.14 — the only one with an upper bound, to keep the CV/ML wheels resolvable).
Tooling you already have — and one telling absence. Everything here is a uv project (as since
day one), and Qdrant — your vector store since Session 1 — is back, just in :memory: mode (a real
Qdrant engine inside the notebook process, no server to stand up). Now look at what's not in
pyproject.toml: no rerank/ensemble library — no langchain-cohere, no rank-bm25, the two retrieval
libs you installed in Session 7. That's deliberate: the reciprocal_rank_fusion in Cell 5.4 is
hand-written in about eight lines of plain Python, so you can see exactly how the two result lists
combine before ever reaching for a library. (What is new: the CLIP + video stack —
sentence-transformers, opencv-python-headless, imageio, pillow.)
Versions this session expects (validated). Python 3.13 (requires-python = ">=3.13,<3.14") and
LangChain v1 — the notebook uses v1-only APIs (init_chat_model, v1 image content blocks,
langchain-qdrant), so an older LangChain 0.x will ImportError. A clean uv sync resolves to
langchain 1.3.14, langchain-openai 1.3.5, langchain-qdrant 1.1.0, qdrant-client 1.18.0,
sentence-transformers 5.6.0. (Provenance for those pins is in Version and maintenance notes at the
end of this sheet.)
| Role | Object | What it does |
|---|---|---|
| VLM (both jobs) | vlm (init_chat_model(VLM_MODEL)) |
used in two different cells for two different purposes — find both |
| Text embedder | text_embedder (OpenAIEmbeddings) |
embeds text; used by which store(s)? |
| CLIP embedder | clip (ClipEmbedder) |
has both an image side and a text side — check ClipEmbedder's methods |
| Vector store | Qdrant :memory: (multiple named collections) |
dies with the kernel; same API as a hosted cluster |
These are the things Questions #1–4 ask you to explain in your own words. Below is where to look and what to compare — not the conclusion. Write your own description before you read the notebook's own callouts (Cell 34 and the Section 8 "Gotchas" list say a lot out loud — better to form your own read first, then check it against theirs).
The three strategies side by side (the overview shows that they branch; this shows how they differ). For each, watch four things: (1) what turns an image into a vector, (2) which model embeds the query, (3) how many stores exist, (4) where fusion or filtering happens.
A — Caption first (Cell 5.1)
image ──VLM caption──► text embed ┐
text ────────────────► text embed ├──► ONE text store ──► similarity_search
image→vector: VLM writes a caption, text model embeds it query: OpenAI text
stores: 1 fuse/filter: none
B — Shared CLIP space (Cells 5.2–5.3)
image ──► CLIP image encoder ┐
text ───► CLIP text encoder ├──► ONE mixed store ──► search (optional modality filter)
image→vector: CLIP encodes the PIXELS query: CLIP text tower
stores: 1 (mixed) fuse/filter: metadata filter per modality
C — Separate and fuse (Cell 5.4)
text ───► OpenAI embed ──► text store ┐
image ──► CLIP embed ──► image store ├──► RRF fuse ──► top-k
image→vector: CLIP encodes the PIXELS query: OpenAI (text store) + CLIP (image store)
stores: 2 fuse/filter: RRF at query time
Read the panels together: A never embeds a pixel (it embeds words about the pixel); B and C both do, but B forces text and images into one space (hence the modality gap) while C keeps them apart and reconciles by rank. Every row in the tradeoffs table later is a consequence of these three shapes.
Reread the callout at the end of Cell 0.3. Then, for Strategies A (Cell 5.1), B (Cells 5.2–5.3), and C (Cell 5.4): find the exact moment each strategy would normally need to turn an image into a vector, and note what actually happens at that moment instead. Do all three route around the same wall the same way, or differently from each other? What does each one give up or add to make its choice work?
Run both lines in Cell 5.3 back to back: retrieve_B(q) and retrieve_B(q, modality="image"), same
q. Same query, two different top-3 lists — what changed between them? Write your own one-sentence
description of the pattern before you read the markdown cell immediately after (Cell 34) — it names
the phenomenon and offers two fixes. Then ask: does Strategy C's design (Cell 5.4) run into the same
problem? Why or why not?
The shape to predict before you run (schematic — your actual ids will vary, but the shape is the point):
Unified mixed search retrieve_B(q) |
Image-filtered retrieve_B(q, modality="image") |
|---|---|
| 1. a text document | 1. the matching chart |
| 2. a text document | 2. another chart |
| 3. a text document | 3. another chart |
That top-heavy text column — even though the query literally describes a chart — is the modality gap made visible. Does your run match this shape? Only after you've written your own one-sentence why should you open Cell 34 to check it.
Strategy B can find the right chart by matching pixels — you just proved that above. Now ask: if Strategy B's retriever were the whole pipeline (no further step), could it tell you the actual number on that chart? What kind of task is "read this exact figure off an image" versus "find the image whose content matches this description"? Are those the same skill?
Look at what's stored in a Chunk.content for an image (built in Cell 4.3) — it's text. Now look at
what build_answer_message (Cell 7.1) actually attaches to the model's context for that same image.
Compare the two. If the caption's data_points already list some numbers, why does the notebook still
reload the original file from source_path and send it as image_block(...)? Section 3 has a design
note about where the real numbers live in this dataset — reread it and connect it to what you just
compared.
In Cell 5.4, look at how store_C_text and store_C_img are each built — what embedding model
produces the vectors for each one? Are the two the same model? Now open reciprocal_rank_fusion in
that same cell and trace, line by line, what information it actually uses from each result — does the
function ever touch a similarity score, or only a result's position in its list? Given your answer
to the "are they the same model" question, what would go wrong if the merge instead just kept whichever
candidates had the highest raw score across both lists?
You've fused lists like this before — go compare. In Session 7 you hand-wrote the same
reciprocal_rank_fusion to merge a BM25 (keyword) list with a dense (embedding) list — two
scorers over the same text. Here the two lists come from different embedders over different
modalities (OpenAI text vs. CLIP images), and the formula (1/(k+rank), k=60) is identical. So ask
yourself: the reason the two lists are incomparable changed (different scorers → different
modalities), but the fix didn't — what is RRF actually protecting you from, and is it ever really
about "modality" at all, or something more general? One more distinction to carry forward: Session 7
also reranked the survivors with Cohere Rerank — a model that rescores each candidate by
relevance (a paid model call). RRF fuses by rank with no model call; a reranker rescores with
one. Which is which — and which of the two is Section 10's optional "cross-encoder reranker" reaching
back to?
Tradeoffs at a glance (from the notebook, Cell 37 — reread it after you've run Section 5, don't just skim it)
| A. Caption→text | B. Unified CLIP | C. Separate+fuse | |
|---|---|---|---|
| Cross-modal search | via captions only | native (pixels) | native (image store) |
| Ingestion cost | high (VLM call/image) | low | medium |
| Fine-grained numbers | as good as caption | weak | text strong, image weak |
| Query latency | low | low | higher (2 searches + fuse) |
| Main failure | ? | ? | ? |
The notebook fills in that last row (Cell 37) — go read it after you've run Section 5 and formed your own guess, not before. Before Activity #1, ask yourself: given this table, which strategy would you predict handles a query like "a pie chart of cloud spend" best, and which handles "what caused the March churn spike" best — before you run them? Then check your prediction against what actually comes back.
gold = {"some question": {"the_id_that_should_come_back"}, ...}
def recall_at_k(strategy, k=3): ... # fraction of gold questions where a correct id lands in top-kThis is the mechanism Activity #2 extends — go read Cell 54 to see the existing eight gold questions
before you write your own two. This gold-set + recall@k harness is not new to you: it's the same
shape you built in Session 7 (lib/retrieval_eval.py, which also reported MRR) and the
hand-rolled cousin of Session 6's RAGAS.
One precision point worth catching (the notebook doesn't flag it). Read Cell 54's function line by
line: it scores a question as a hit when len(want & got) > 0 — i.e. any one gold id in the top-k —
then divides by the number of questions. That is strictly hit-rate@k (a.k.a. success@k), not the
|found ∩ relevant| / |relevant| recall that Session 7's retrieval_eval.py computed. For the six
single-id questions the two are equal; but two gold entries here have two correct ids each ("When
did NPS dip" and "What caused the March churn spike") — and for those, retrieving just one of the
two still scores a full hit, where true recall would be 0.5. So ask: which of your two Activity-#2
questions should be single-id, and would picking a two-id one change what the printed number even means?
Then ask what MRR would tell you that neither hit-rate nor recall@3 can — and whether it would
change how you read Strategy A/B/C's scores.
Video = two aligned streams (a timestamped transcript + sampled keyframes). Before you read Section 9's
code closely, ask: given everything above about Strategy C, what would you expect the video retrieval
function to reuse? Check your guess against retrieve_video in Cell 9.5.
Once you've made that guess, here's the shape to check it against. The ./data clip is 45s of narrated
slides; each transcript segment is time-aligned with the keyframe(s) on screen in that window — a
time-windowed chunk. The two streams become two stores, fused by RRF — which is exactly
Strategy C, only the axis that aligns the modalities is now time:
time 0:00 0:06 0:16 0:26 0:37 0:45
│ intro │ revenue │ churn │ APAC lat. │ team │
transcript ──┴───────────┴───────────┴───────────┴────────┘ ─► TEXT store (OpenAI embed)
keyframes ──┬───────────┬───────────┬───────────┬────────┐ ─► IMAGE store (CLIP pixels)
│ kf │ kf │ kf │ kf │ kf │
▲
query: "when did the latency alert happen in the video?"
│
retrieve_video = RRF( TEXT store , IMAGE store ) ← identical to Strategy C
│
▼
answer_video() cites the winning window → [00:26–00:37]
So Section 9 is not a fourth strategy: it's Strategy C (store_C_text + store_C_img + the same
reciprocal_rank_fusion) pointed at a transcript and its keyframes, with t_start/t_end carried on
every chunk so the answer can cite [MM:SS–MM:SS]. Confirm each piece of that against Cells 9.4–9.6.
Section 4 was the investigation — where you ran cells, compared lists, and traced code. These four are the write-ups that investigation feeds. Each points back to the §4 subsection that set it up: the task here is to state your conclusion, not to re-run the mechanics. If a question feels unanswerable, go do the matching §4 subsection first, then come back and write.
What single property of LangChain's Embeddings interface (Cell 0.3), in tension with one property of
chat models, forces the whole three-strategy design? You already traced how A, B, and C each route
around that wall in §4's "The one fact everything traces back to" — so don't re-run them; settle the
payoff it left open: are the three genuinely different answers, or variations on one trick?
Name Strategy B's two failures — and the pipeline part that compensates for each. §4's "Two rankings, one query" and "What CLIP retrieval alone can and can't do" already had you see both. Write them up as two distinct failures at two different moments: the modality gap (before any answer exists) and CLIP-can't-read-values (after retrieval has already succeeded). Is the fix for each the same mechanism, or two different ones?
If the caption were always perfectly complete, would resending the original pixels still matter? You
compared Chunk.content (Cell 4.3) against what build_answer_message (Cell 7.1) sends in §4's "What
gets stored vs. what gets sent." Answer that counterfactual, and tie it to Section 3's note on where the
exact numbers live — and to the source-pixels-vs-generated-caption distinction from Section 0.
Since store_C_text (OpenAI) and store_C_img (CLIP) score on different scales, what would "keep the
higher raw score" actually be measuring — relevance, or scale? You already traced both stores and the
reciprocal_rank_fusion body in §4's "Comparing two lists that came from different places." State the
conclusion, and say why summing 1/(k + rank) sidesteps the scale problem entirely.
Deliverable: two of your own real questions about the ACME corpus (the data-dictionary printout in Section 3 is your menu) run through Strategies A, B, and C via the scaffold cell (Cell 41), plus a written observation on where the rankings differed and why, given the tradeoffs table. Method: try one visual phrasing (e.g., "a pie chart of…") and one more factual phrasing — the notebook itself nudges you this way. Run both through all three strategies and actually compare the printed id lists, not just skim them. For each place the rankings diverge, connect it back to a specific row of the tradeoffs table (ingestion cost? native pixel search? caption quality?) rather than just noting that they diverged.
Deliverable: two new entries added to gold — one whose answer lives in a text doc,
one whose answer lives in a chart image — plus a written observation on whether any strategy's
hit-rate@3 moved, and why that might be. (Two cells, don't confuse them: Cell 54 defines the original
eight-question gold; Cell 73 is the my_gold stub where you add yours and re-run — it does
gold.update(my_gold), so leave Cell 54 alone.)
Method: use the data dictionary (Section 3) and the doc bodies (Section 3) to pick two real
questions with unambiguous correct ids — double-check your mapping before running. Then look at the
actual before/after numbers per strategy, not just whether the overall average moved. If nothing
changed, that's a real, reportable result too — ask yourself why nothing changed given what you now
know about this corpus and these three strategies.
Provider swap, BM25+dense hybrid on text (Session 7's hybrid retriever, brought to this corpus), Markdown-table extraction in the parse prompt, a cross-encoder reranker (the Cohere Rerank move from Session 7 — rescore the fused survivors), scene-change keyframe sampling, or a "the data can't answer this" decline test — and measure whether it actually helped (that's what the hit-rate@3 harness is for).
Primary sources for the APIs and concepts this session leans on. Open them to verify what the notebook does and to check your own answers against the docs — not to shortcut the reasoning. (Links current as of 2026-07-16; APIs move fast, so confirm the version.)
- LangChain — Messages & multimodal content blocks (Python): the exact
{"type":"image","source_type":"base64","data":...,"mime_type":...}shape the notebook'simage_blockbuilds (Cell 4.1, 7.1). → https://docs.langchain.com/oss/python/langchain/messages - LangChain —
Embeddingsinterface (why it's text-only —embed_documents/embed_queryboth take strings): the wall Strategies A/B/C route around. → https://reference.langchain.com/python/langchain_core/embeddings/Embeddings
- LangChain — Providers & models (one API, any provider): grounds the "change
VLM_MODELand go" claim. → https://docs.langchain.com/oss/python/concepts/providers-and-models init_chat_modelAPI reference (theprovider:modelstring,temperature=0). → https://reference.langchain.com/python/langchain/chat_models/base/init_chat_model
- LangChain — Qdrant integration (
QdrantVectorStore, incl. the in-memory / locallocation=":memory:"mode the notebook uses, andsimilarity_searchwith metadata filters). → https://docs.langchain.com/oss/python/integrations/vectorstores/qdrant - Qdrant — local mode & quickstart (what "a real engine, no server" actually means). → https://qdrant.tech/documentation/quickstart/
- Sentence-Transformers — Image Search with CLIP (encode both images and text with one model into a
shared space — exactly
ClipEmbedderin Cell 5.2). → https://sbert.net/examples/sentence_transformer/applications/image-search/README.html clip-ViT-B-32model card (the ~600 MB weights the first CLIP call downloads). → https://huggingface.co/sentence-transformers/clip-ViT-B-32- The modality gap — the empirical result behind Cell 34's callout (why text queries land nearer any text than the best image): Liang et al., Mind the Gap (NeurIPS 2022). → https://arxiv.org/abs/2203.02053
- RRF, original paper — Cormack, Clarke & Büttcher (SIGIR 2009); the
1/(k+rank)formula withk=60is straight from here. → https://plg.uwaterloo.ca/~gvcormack/cormacksigir09-rrf.pdf - Qdrant — Hybrid queries (RRF as a first-class fusion primitive; the library version of the eight lines you read in Cell 5.4). → https://qdrant.tech/documentation/concepts/hybrid-queries/
- Session 1 — Qdrant first appears (
01_Cat_Health_Vector_RAG_LangChain_Qdrant.ipynb). - Session 6 — RAGAS, the eval framework
recall@kis the hand-rolled cousin of. - Session 7 — the same hand-written
reciprocal_rank_fusion(1/(60+rank)), the BM25+dense hybrid (rank-bm25), Cohere Rerank (langchain-cohere, a rescoring model call — contrast with RRF's rank-only fusion), andlib/retrieval_eval.py, which reports bothrecall@kand MRR. Section 10's optional cross-encoder reranker reaches back to this.
Provenance for the version pins in Section 3 — kept here so it doesn't interrupt the learning sequence.
- Resolved (clean
uv sync, validated 2026-07-16):langchain 1.3.14,langchain-openai 1.3.5,langchain-qdrant 1.1.0,qdrant-client 1.18.0,sentence-transformers 5.6.0; Python>=3.13,<3.14. - 2026-07-16 — two fixes landed, no behavior change: (1) the
pyproject.tomlLangChain/Qdrant floors were tightened from>=0.3to the cohort's v1 convention (langchain>=1.3.0,<2.0.0,langchain-openai>=1.2.0,<2.0.0,langchain-qdrant>=1.0.0,<2.0.0,qdrant-client>=1.15.0,<2.0.0) so a fresh install can't silently pull a pre-v1 LangChain; (2) the README's notebook filename was corrected tomultimodal_rag.ipynb(it previously readmultimodal_rag_vlm.ipynb, which doesn't exist).
The learning is in running the cells, comparing the actual printed rankings and recall numbers, and writing your own conclusions. The notebook's own callouts (Cell 34, Section 8's Gotchas, Section 10's recap) are there to check your reasoning against — read them after you've formed your own answer, not instead of forming one.