"Which invoices were for caffeine?" No invoice contains the word. With fastembed vectors in LanceDB the search finds INV-2043 (0.75) and INV-2049 (0.73), the coffee orders, and a RAG prompt answers with citations.
python3 --version.pip install "pandas>=2.2"pip install "pyarrow>=15"pip install "duckdb>=1.0"pip install "pytesseract>=0.3.10"pip install "pillow>=10"pip install "fastembed>=0.4"pip install "lancedb>=0.10"brew install tesseractgit clone https://github.com/DayanEbrar0X/data-anatomy.ai.git cd data-anatomy.ai
python3 -m venv .venv source .venv/bin/activate
pip install -r requirements.txt # or just this lesson: pip install "pandas>=2.2" "pyarrow>=15" "duckdb>=1.0" "pytesseract>=0.3.10" "pillow>=10" "fastembed>=0.4" "lancedb>=0.10"
brew install tesseract # macOS sudo apt install tesseract-ocr # Debian / Ubuntu tesseract --version
cd data-engineering/build-lab-02-documents-to-data cd docs_pipeline python3 main.py # Part 1 python3 search.py # Part 2
from embed import embed
from store import build_index
table = build_index()
print(len(table), "invoices embedded, 384 dims")
q = "Which invoices were for caffeine?"
print("Q:", q)
hits = (table.search(embed([q])[0])
.distance_type("cosine").limit(2).to_list())
for h in hits:
score = round(1 - h["_distance"], 2)
print(h["invoice_no"], h["vendor"], score)
ctx = "\n".join(f"[{h['invoice_no']}] {h['text']}"
for h in hits)
prompt = ("Answer from these invoices only. "
"Cite invoice numbers.\n\n"
f"{ctx}\n\nQ: {q}")
# in production, this prompt goes to the LLM
ids = " ".join(f"[{h['invoice_no']}]" for h in hits)
print(f"prompt: {len(prompt)} chars, sources {ids}")Ask your invoices a question in plain English. Get the right ones back, with citations. It's part two of a project you can build over a weekend. Part two.
Last time, ten scanned invoices became one Parquet table. Today, that same table feeds a second consumer: AI retrieval. Keyword search has a limit. Ask which invoices were for caffeine, and no invoice contains that word.
File one: embed.py. An embedding turns text into a list of numbers that captures its meaning. We load a small open model, BGE small.
It runs on your CPU, with no API key. Every text becomes 384 numbers. Texts with similar meaning get similar numbers. File two: store.
py. We read the Parquet table from part one, and embed each invoice's text into a new vector column. LanceDB saves the vectors and their metadata side by side, in a local folder. No server needed.
That's a vector database: ask for the nearest neighbors, and it finds the closest meaning. File three: search.py. Here's our question: which invoices were for caffeine?
We embed it with the same model, so it lands in the same space. Then LanceDB returns the two nearest invoices, by cosine distance. One minus the distance is the similarity. Closer to one means closer in meaning.
Their text goes into a prompt, each tagged with its invoice number, so the answer can cite it. We don't call a model here. In production, this prompt goes to the LLM. And we print the sources.
Let's run it. Ten invoices embedded, 384 numbers each. Top two: both Harbor Coffee invoices. Espresso beans, and cold brew.
Scores of 0.75 and 0.73, and the word caffeine appears in neither. The prompt carries both, with sources INV-2043 and INV-2049.
So one Parquet table now serves two consumers: SQL analytics with DuckDB, and AI retrieval with LanceDB. Swap the invoices for contracts, tickets, or manuals, and the pipeline stays the same. Pixels to text. Text to vectors.
Vectors to answers, with sources.
Read the lesson on GitHub →