kits
Your first RAG app
A chatbot that answers questions from any PDF and cites the page, so every answer is one you can check. One dependency, free-tier APIs, under thirty minutes.
Answers questions from a PDF you supply and cites the page number for every claim, so you can check it rather than trust it.
Run it
- 1. You need Python 3.10 or newer. Check with: python3 --version. There is exactly one dependency and no database, no Docker and no SDK, because a first project should fail for reasons about your code rather than your setup.
- 2. Make a folder and install the one thing. mkdir first-rag && cd first-rag python3 -m venv .venv source .venv/bin/activate pip install pypdf On Windows PowerShell, activate with .venv\Scripts\Activate.ps1 instead of the source line.
- 3. Get a key on a free tier and point the kit at it. This talks plain OpenAI-compatible HTTP, so it works with a free tier, a paid key, or a model running on your own laptop through Ollama. Nothing here is tied to one company. export API_KEY=your-key-here The defaults target Google's OpenAI-compatible endpoint because it has a free tier that covers both embedding and answering. To use anything else, set BASE_URL, EMBED_MODEL and CHAT_MODEL as well. On Windows PowerShell: $env:API_KEY = "your-key-here"
- 4. Put a PDF in the folder. Use one you actually need answers from: a policy document, a manual, a paper you keep re-reading. A PDF you already know well is the only way you will spot a wrong answer, and spotting wrong answers is the point.
- 5. Save this as ask.py. import json, math, os, sys, urllib.request from pypdf import PdfReader # Any OpenAI-compatible endpoint. Set these three and the kit works anywhere: # a free tier, a paid key, or Ollama on your own laptop. BASE = os.environ.get("BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai") KEY = os.environ.get("API_KEY", "") EMBED_MODEL = os.environ.get("EMBED_MODEL", "gemini-embedding-001") CHAT_MODEL = os.environ.get("CHAT_MODEL", "gemini-2.5-flash") CHUNK, OVERLAP, TOP_K = 1200, 200, 5 CACHE = "index.json" def post(path, payload): req = urllib.request.Request( f"{BASE}{path}", method="POST", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", "Authorization": f"Bearer {KEY}"}) with urllib.request.urlopen(req) as r: return json.load(r) def embed(texts): return [d["embedding"] for d in post("/embeddings", {"model": EMBED_MODEL, "input": texts})["data"]] def cosine(a, b): na = math.sqrt(sum(x * x for x in a)) nb = math.sqrt(sum(y * y for y in b)) return sum(x * y for x, y in zip(a, b)) / (na * nb) if na and nb else 0.0 def chunks_of(text, page): """Carries the page number, so every answer can be checked against the source.""" step = CHUNK - OVERLAP out = [] for i in range(0, max(len(text), 1), step): piece = text[i:i + CHUNK].strip() if piece: out.append({"page": page, "text": piece}) return out def build(pdf_path): reader = PdfReader(pdf_path) chunks = [] for n, page in enumerate(reader.pages, start=1): chunks += chunks_of(page.extract_text() or "", n) if not chunks: sys.exit("No text found. This PDF is probably scanned images, which needs OCR first.") print(f"{len(reader.pages)} pages, {len(chunks)} chunks. Embedding once, then cached.") for i in range(0, len(chunks), 64): for c, v in zip(chunks[i:i + 64], embed([c["text"] for c in chunks[i:i + 64]])): c["vec"] = v json.dump(chunks, open(CACHE, "w")) return chunks def answer(chunks, question): qv = embed([question])[0] top = sorted(chunks, key=lambda c: -cosine(c["vec"], qv))[:TOP_K] context = "\n\n".join(f'[page {c["page"]}]\n{c["text"]}' for c in top) reply = post("/chat/completions", {"model": CHAT_MODEL, "messages": [ {"role": "system", "content": "Answer only from the excerpts. Cite the page number for every claim, like " "(page 4). If the excerpts do not contain the answer, say so plainly and stop. " "Never fill a gap with what you already know."}, {"role": "user", "content": f"Excerpts:\n{context}\n\nQuestion: {question}"}]}) return reply["choices"][0]["message"]["content"], [c["page"] for c in top] def main(): if len(sys.argv) < 2: sys.exit("usage: python ask.py yourfile.pdf") if not KEY: sys.exit("Set API_KEY first. See step 3.") chunks = (json.load(open(CACHE)) if os.path.exists(CACHE) else build(sys.argv[1])) print("Ask a question, or press Enter to quit.\n") while True: q = input("> ").strip() if not q: return text, pages = answer(chunks, q) print(f"\n{text}\n\nretrieved from pages: {sorted(set(pages))}\n") if __name__ == "__main__": main()
- 6. Run it. python ask.py yourfile.pdf The first run embeds the document once and writes index.json. Every run after that reads the cache, so you are not paying to re-index while you experiment.
- 7. Ask it five questions you already know the answers to. Check the cited pages. Then ask it something the PDF genuinely does not cover, and watch whether it says so or invents something. That second test tells you more than the first five.
- 8. Change one thing, CHUNK or TOP_K, delete index.json, and run again. The cache will not rebuild while it exists, which is the most common reason a change appears to do nothing.
What you get
Answers with page citations, and the retrieved page numbers printed underneath so you can see what it read before it spoke. When an answer is wrong, the pages tell you which half broke: wrong pages retrieved means the retrieval failed, right pages and a wrong answer means the model wandered. Those are different problems with different fixes, and most people never separate them.
Take it further
- Point it at a folder of PDFs instead of one, carrying the filename alongside the page so citations stay checkable.
- Lower TOP_K to 2 and see how often answers get worse. Most first pipelines retrieve far more than they need and pay for it on every question.
- Ask it something the document does not cover, ten times. Count how often it admits that. That number is your hallucination rate and it is the only quality figure that matters early.
- When one PDF becomes a thousand, move the vectors into pgvector and keep everything else. The loop does not change, only where the numbers live.