---
title: "Phase 1: Document Ingestion"
format:
html:
toc: true
toc-depth: 2
---
This page demonstrates the ingestion pipeline built in Phase 1 using the same
synthetic data as the unit tests in `tests/test_ingest.py`. Every output here
is produced by the live `rag.ingest` module — not hardcoded.
Related issues: [#1 Load CSV](https://github.com/almac09/private-rag-assistant/issues/1)
(closed), [#2 Chunk documents](https://github.com/almac09/private-rag-assistant/issues/2)
(closed), [#3 Embed and store in ChromaDB](https://github.com/almac09/private-rag-assistant/issues/3)
(closed), [#4 Spot-check chunk quality](https://github.com/almac09/private-rag-assistant/issues/4)
(closed).
---
## Step 1 — Load speeches from CSV
`load_speeches_csv()` reads each row of the speeches CSV and converts it to a
LangChain `Document` with the speech text as `page_content` and all columns
(date, year, speakers, title) attached as `metadata`.
```{python}
import sys
sys.path.insert(0, "..")
import pandas as pd
from io import StringIO
from rag.ingest import load_speeches_csv, chunk_documents
# Same synthetic data used in test_load_speeches_csv and test_chunk_documents
SAMPLE = pd.DataFrame({
"date": ["2023-01-15", "2023-02-20"],
"year": [2023, 2023],
"speakers": ["Christine Lagarde", "Philip Lane"],
"title": ["Monetary Policy Speech", "Economic Outlook"],
"subtitle": ["January Meeting", "February Update"],
"contents": [
"The ECB reaffirms its commitment to price stability. " * 20,
"Economic conditions remain challenging. " * 20,
],
})
import tempfile, os
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="") as f:
SAMPLE.to_csv(f, index=False)
tmp_path = f.name
documents = load_speeches_csv(tmp_path)
os.unlink(tmp_path)
print(f"Speeches loaded: {len(documents)}")
```
### Loaded documents
```{python}
from IPython.display import HTML, display
rows = "".join(
f"<tr><td>{i}</td><td>{d.metadata['speakers']}</td><td>{d.metadata['date']}</td>"
f"<td>{d.metadata['title']}</td><td>{len(d.page_content)} chars</td></tr>"
for i, d in enumerate(documents)
)
display(HTML(
"<table style='width:100%;border-collapse:collapse'>"
"<thead><tr style='border-bottom:2px solid #dee2e6'>"
"<th>#</th><th>Speaker</th><th>Date</th><th>Title</th><th>Content length</th>"
"</tr></thead><tbody>" + rows + "</tbody></table>"
))
```
### Sample metadata
```{python}
import json
print(json.dumps(documents[0].metadata, indent=2))
```
---
## Step 2 — Chunk documents
`chunk_documents()` splits each `Document` into smaller overlapping chunks using
`RecursiveCharacterTextSplitter`. Metadata is preserved on every chunk so
retrieved chunks can always be traced back to their source speech.
```{python}
chunks = chunk_documents(documents, chunk_size=100, chunk_overlap=10)
print(f"Source documents : {len(documents)}")
print(f"Chunks produced : {len(chunks)}")
print(f"Avg chunk length : {sum(len(c.page_content) for c in chunks) / len(chunks):.0f} chars")
```
### Chunk lengths
```{python}
lengths = [len(c.page_content) for c in chunks]
bars = ""
max_len = max(lengths)
for i, l in enumerate(lengths):
width = int((l / max_len) * 300)
bars += (
f"<tr><td style='padding:2px 8px'>chunk {i}</td>"
f"<td><div style='background:#4c8bf5;height:16px;width:{width}px;border-radius:2px'></div></td>"
f"<td style='padding:2px 8px'>{l} chars</td></tr>"
)
display(HTML(
"<table style='border-collapse:collapse'>" + bars + "</table>"
))
```
### Sample chunk with metadata
```{python}
c = chunks[0]
print(f"Content : {c.page_content[:120]}...")
print()
print(f"Metadata: {json.dumps(c.metadata, indent=2)}")
```
---
## Step 3 — Embed chunks and store in ChromaDB
`ingest_to_chromadb()` is the end-to-end function: it calls Steps 1 and 2
internally, then embeds every chunk using Ollama (`nomic-embed-text`) and
writes them to a local ChromaDB collection.
Because Ollama is not available at render time, the cell below uses the same
mock strategy as `tests/test_ingest.py` — replacing `OllamaEmbeddings` and
`Chroma` with lightweight fakes so the pipeline logic can be demonstrated
without a running model.
```{python}
from unittest.mock import MagicMock, patch
# Fake embedding: returns a 4-dim vector for any text (real model uses 768-dim)
class FakeEmbeddings:
def embed_documents(self, texts):
return [[0.1, 0.2, 0.3, 0.4]] * len(texts)
def embed_query(self, text):
return [0.1, 0.2, 0.3, 0.4]
mock_vectorstore = MagicMock()
mock_vectorstore.__len__ = lambda self: len(chunks)
with patch("rag.ingest.OllamaEmbeddings", return_value=FakeEmbeddings()), \
patch("rag.ingest.Chroma.from_documents", return_value=mock_vectorstore), \
patch("os.makedirs"):
from rag.ingest import ingest_to_chromadb
import tempfile, os
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="") as f:
SAMPLE.to_csv(f, index=False)
tmp_path = f.name
vs = ingest_to_chromadb(tmp_path, chroma_dir="/tmp/demo_chroma")
os.unlink(tmp_path)
```
### What gets stored
```{python}
print(f"Chunks embedded and stored : {len(chunks)}")
print(f"ChromaDB collection : ecb_speeches")
print(f"Embedding model : nomic-embed-text (768-dim, via Ollama)")
print(f"Embedding dims (mock) : 4 (real model: 768)")
print()
print("Each stored document contains:")
print(" • page_content — the chunk text (≤1000 chars by default)")
print(" • metadata — speaker, date, title, source file, row index")
```
### Pipeline summary
```{python}
display(HTML(
"<table style='width:100%;border-collapse:collapse'>"
"<thead><tr style='border-bottom:2px solid #dee2e6'>"
"<th style='text-align:left;padding:6px'>Step</th>"
"<th style='text-align:left;padding:6px'>Function</th>"
"<th style='text-align:left;padding:6px'>Input → Output</th>"
"</tr></thead>"
"<tbody>"
"<tr><td style='padding:6px'>1</td><td><code>load_speeches_csv()</code></td>"
"<td>CSV rows → LangChain Documents</td></tr>"
"<tr><td style='padding:6px'>2</td><td><code>chunk_documents()</code></td>"
"<td>Documents → overlapping chunks (metadata preserved)</td></tr>"
"<tr><td style='padding:6px'>3</td><td><code>ingest_to_chromadb()</code></td>"
"<td>Chunks → embeddings → ChromaDB collection</td></tr>"
"<tr><td style='padding:6px'>—</td><td><code>load_chromadb()</code></td>"
"<td>Disk → Chroma instance (for query phase)</td></tr>"
"</tbody></table>"
))
```
---
## Step 4 — Spot-check chunk quality
Before embedding, it is worth verifying that the chunks produced by Step 2
are sensible: no empty splits, no degenerate micro-chunks, metadata intact,
and lengths within the requested bounds. These checks are codified in
`tests/test_ingest.py` and run on every push via CI.
```{python}
quality_checks = [
("No empty chunks", all(c.page_content.strip() for c in chunks)),
("No chunks shorter than 10 chars", all(len(c.page_content.strip()) >= 10 for c in chunks)),
("All required metadata keys present",
all({"date", "year", "speakers", "title", "source"} <= set(c.metadata) for c in chunks)),
("No chunk exceeds chunk_size by >10%",
all(len(c.page_content) <= 100 * 1.1 for c in chunks)),
]
rows = "".join(
f"<tr><td>{'✅' if ok else '❌'}</td><td>{label}</td></tr>"
for label, ok in quality_checks
)
display(HTML(
"<table style='border-collapse:collapse'>"
"<thead><tr style='border-bottom:2px solid #dee2e6'>"
"<th style='padding:6px'>Result</th><th style='padding:6px;text-align:left'>Check</th>"
"</tr></thead><tbody>" + rows + "</tbody></table>"
))
```
### Chunk length distribution
```{python}
lengths = [len(c.page_content) for c in chunks]
bins = [0, 25, 50, 75, 100, 125]
labels_bin = ["0–25", "26–50", "51–75", "76–100", "101+"]
counts = [
sum(1 for l in lengths if bins[i] <= l < bins[i+1])
for i in range(len(bins)-1)
]
counts.append(sum(1 for l in lengths if l >= 125))
max_count = max(counts) if counts else 1
bars = "".join(
f"<tr><td style='padding:2px 8px'>{labels_bin[i]}</td>"
f"<td><div style='background:#4c8bf5;height:16px;width:{int(counts[i]/max_count*200)}px;"
f"border-radius:2px;min-width:2px'></div></td>"
f"<td style='padding:2px 8px'>{counts[i]} chunks</td></tr>"
for i in range(len(labels_bin))
)
display(HTML("<table style='border-collapse:collapse'>" + bars + "</table>"))
print(f"Min: {min(lengths)} chars | Max: {max(lengths)} chars | Avg: {sum(lengths)/len(lengths):.0f} chars")
```
---
## Phase 1 complete
All three ingestion steps are implemented, tested, and demonstrated above.
The ChromaDB collection (`ecb_speeches`) is ready for the Phase 2 query
pipeline — retrieval + generation.
See [Phase 2 issues #5–8](https://github.com/almac09/private-rag-assistant/issues/5).