# --- Colab setup (skipped when running locally) ---
import os, sys
if 'google.colab' in sys.modules and not os.path.exists('data/sample_corpus.json'):
!git clone -q https://github.com/mdhabibi/llm-search-handbook.git
%cd llm-search-handbook
!pip -q install -r requirements.txt
Chapter 13 — Capstone: the whole course in one system¶
We assemble ingest → hybrid retrieve → re-rank → grounded RAG → evaluate using the SearchStack reference class in src/search_stack.py. Then you swap in your own corpus.
Setup¶
pip install sentence-transformers transformers rank-bm25
# Bootstrap: locate repo root and import shared helpers
import sys, os, json
d = os.getcwd()
while d != os.path.dirname(d) and not os.path.exists(os.path.join(d, 'data', 'sample_corpus.json')):
d = os.path.dirname(d)
ROOT = d; sys.path.insert(0, os.path.join(ROOT, 'src'))
import numpy as np
from corpus import load_corpus
1. Build the stack¶
SearchStack takes an encoder, an optional cross-encoder re-ranker, and an optional LLM generator. Turn features on one at a time and measure.
from search_stack import SearchStack
from sentence_transformers import SentenceTransformer, CrossEncoder
embedder = SentenceTransformer('all-MiniLM-L6-v2') # Ch4
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2') # Ch7
# Optional LLM for RAG answers (comment out for search-only)
from transformers import pipeline
_llm = pipeline('text2text-generation', model='google/flan-t5-base') # Ch10
def generate(prompt): return _llm(prompt, max_new_tokens=128)[0]['generated_text']
stack = SearchStack(embedder.encode, reranker=reranker, generator=generate,
use_hybrid=True).ingest(load_corpus())
print('Stack built. Indexed', len(stack.chunks), 'chunks.')
2. Search (hybrid + re-rank)¶
for hit in stack.search('a large animal that lives in the sea', k=3):
print(f" [{hit['title']}] {hit['chunk'][:70]}...")
3. Answer (grounded RAG with citations)¶
out = stack.answer('what is the largest animal that has ever lived?')
print('ANSWER :', out['answer'])
print('CITATIONS:', out['citations'])
4. Evaluate configurations¶
Let numbers pick the winner. We compare dense-only, hybrid, and hybrid+rerank on the labeled set.
eval_set = json.load(open(os.path.join(ROOT,'data','eval_queries.json')))['queries']
configs = {
'dense only' : SearchStack(embedder.encode, use_hybrid=False),
'hybrid' : SearchStack(embedder.encode, use_hybrid=True),
'hybrid + rerank' : SearchStack(embedder.encode, reranker=reranker, use_hybrid=True),
}
print(f"{'config':18}{'nDCG@10':>9}{'MRR':>7}{'MAP':>7}{'recall@10':>11}")
for name, st in configs.items():
st.ingest(load_corpus())
m = st.evaluate(eval_set)
print(f"{name:18}{m['nDCG@10']:>9.3f}{m['MRR']:>7.3f}{m['MAP']:>7.3f}{m['recall@10']:>11.3f}")
5. Bring your own corpus¶
Replace the course corpus with your documents — a list of {'id', 'title', 'text'} dicts — and rerun. That's the whole point: your data, the full stack.
# my_docs = [
# {'id': 0, 'title': 'My note 1', 'text': open('notes/1.txt').read()},
# {'id': 1, 'title': 'My note 2', 'text': open('notes/2.txt').read()},
# # ... load your PDFs / articles / wiki here
# ]
# mystack = SearchStack(embedder.encode, reranker=reranker, generator=generate).ingest(my_docs)
# print(mystack.answer('your question here')['answer'])
print('Swap in your documents above, then search/answer/evaluate exactly as we did.')
You've finished 🎉¶
You built a modern search system from keyword matching to grounded RAG, measured it, and can justify each choice. See the chapter README for the self-assessment rubric and extension ideas (UI, deployment, CLIP, fine-tuning).
Exercises
- Report which config won on your data and why.
- Add a Streamlit UI with a search box and answer panel.
- Persist the index (Chroma on disk) so restarts don't re-embed.
- Add an advanced retrieval technique from Chapter 12 and measure the lift.