惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

A
About on SuperTechFans
D
DataBreaches.Net
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
V
Visual Studio Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
B
Blog RSS Feed
Recent Announcements
Recent Announcements
The Register - Security
The Register - Security
S
Secure Thoughts
Y
Y Combinator Blog
The Last Watchdog
The Last Watchdog
L
LINUX DO - 最新话题
V2EX - 技术
V2EX - 技术
腾讯CDC
GbyAI
GbyAI
G
Google Developers Blog
博客园 - 司徒正美
博客园 - 三生石上(FineUI控件)
T
The Exploit Database - CXSecurity.com
T
Threat Research - Cisco Blogs
P
Proofpoint News Feed
Schneier on Security
Schneier on Security
Microsoft Security Blog
Microsoft Security Blog
Jina AI
Jina AI
WordPress大学
WordPress大学
aimingoo的专栏
aimingoo的专栏
MyScale Blog
MyScale Blog
Help Net Security
Help Net Security
K
Kaspersky official blog
P
Privacy & Cybersecurity Law Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
AI
AI
MongoDB | Blog
MongoDB | Blog
Scott Helme
Scott Helme
J
Java Code Geeks
Engineering at Meta
Engineering at Meta
H
Heimdal Security Blog
H
Help Net Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
云风的 BLOG
云风的 BLOG
Microsoft Azure Blog
Microsoft Azure Blog
S
Security Affairs
TaoSecurity Blog
TaoSecurity Blog
The GitHub Blog
The GitHub Blog
Hacker News: Ask HN
Hacker News: Ask HN
Martin Fowler
Martin Fowler
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Project Zero
Project Zero
T
The Blog of Author Tim Ferriss
Last Week in AI
Last Week in AI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
I Built RAG From Scratch in Python to Understand It. Here's What I Learned.
Avinash Zala · 2026-06-22 · via DEV Community

I had used LangChain's RAG chain in production for six months. I could not have told you, off the top of my head, what chunk_overlap did, or why cosine similarity is the right distance metric, or how nomic-embed-text actually turns a sentence into a vector. The high-level library abstracted all of it away.

So one weekend I deleted the LangChain dependency and wrote a RAG pipeline from scratch in ~500 lines of plain Python. No framework, no magic. pypdf for text extraction. A 60-line chunker. ChromaDB for the vector store. Ollama for embeddings and the LLM. The whole thing is on GitHub — every module is under 200 lines, every test is deterministic, and you can read the whole thing in one sitting.

This is the build log. Not a tutorial — the build log, with the parts that surprised me and the parts I got wrong the first time.

Why bother

The honest reason: I was using LangChain's RetrievalQA chain and getting answers I didn't trust. Sometimes the model would say "according to the document" when the document didn't say that. Sometimes the citations were wrong. I had no way to know if the chunker was dropping important context, or if the cosine similarity was picking the wrong neighbors, or if the prompt was actually constraining the model. The library was a black box.

When you build it yourself, every layer is inspectable. When the answer is wrong, you can add a print statement in pipeline.py line 102 and see exactly which chunks were sent to the LLM. When the chunker cuts a sentence in half, you see it in the test fixtures. When the embedding model gives garbage for some inputs, you can swap in a different model with one constructor parameter. None of that is possible when the whole thing is RetrievalQA.from_chain_type(llm=..., retriever=...).

The other reason: the code I wrote is 500 lines, and it covers the same ground as a 50-line LangChain script. The extra 450 lines are comments, type hints, tests, and explicit error handling. That's the actual complexity. LangChain hides it; building it yourself makes you confront it.

The architecture

The whole pipeline is six modules, each doing one thing:

[ PDF file ]
      |
      v
+-----------+        text         +--------------+
| loaders.py| ------------------->|  chunker.py  |
| (pypdf)   |                      | (sliding     |
+-----------+                      |  window)     |
                                   +------+-------+
                                          |
                                     embeddings
                                          |
                                          v
                                   +--------------+        question
                                   |  store.py    | <------ (also embedded)
                                   | (ChromaDB)   |
                                   +------+-------+
                                          |
                                  top_k similar chunks
                                          |
                                          v
                                   +--------------+        +-----------+
                                   |  pipeline.py | -----> |  llm.py   |
                                   | (orchestr.)  |        | (Ollama)  |
                                   +--------------+        +-----------+

Each module has a single responsibility. Each is testable in isolation. Each can be swapped without touching the others. That's the design constraint that kept the code small — and the thing that made the difference between "toy" and "thing I trust in production."

Part 1 — the chunker

The chunker is the part most tutorials skip. They say "split the text into chunks" and move on. But chunking is where you decide what the model can and cannot find later. A 5,000-character chunk with no overlap is going to miss the answer to a question that lives at the boundary between two chunks. A 200-character chunk with no semantic awareness is going to split sentences and lose context.

I went with a sliding-window chunker with character-level overlap, normalized whitespace, and original-offset tracking:

def chunk_text(
    text: str,
    chunk_size: int = 800,
    chunk_overlap: int = 100,
) -> list[Chunk]:
    """Split text into overlapping windows of approximately `chunk_size` characters."""
    if chunk_size <= 0:
        raise ValueError(f"chunk_size must be > 0, got {chunk_size}")
    if chunk_overlap < 0:
        raise ValueError(f"chunk_overlap must be >= 0, got {chunk_overlap}")
    if chunk_overlap >= chunk_size:
        raise ValueError(
            f"chunk_overlap ({chunk_overlap}) must be < chunk_size ({chunk_size})"
        )

    normalized = _normalize(text)
    if not normalized:
        return []

    step = chunk_size - chunk_overlap
    chunks: list[Chunk] = []
    i = 0
    idx = 0
    n = len(normalized)

    while i < n:
        piece = normalized[i : i + chunk_size]
        # Find the original-text char range for this normalized slice
        char_start = _normalized_to_original_offset(text, i)
        char_end = _normalized_to_original_offset(text, min(i + chunk_size, n))
        chunks.append(Chunk(text=piece, index=idx, char_start=char_start, char_end=char_end))
        idx += 1
        i += step

    return chunks

Three things to notice.

First, the whitespace normalization is a small thing that makes a big difference. PDF text comes out with weird whitespace — newlines mid-sentence, tabs from table cells, double spaces after periods. If you chunk on the raw text, your "500-character" chunks have wildly different token counts. Normalizing first means chunk_size=800 actually means "about 800 useful characters."

Second, the 100-character overlap is the difference between "I found this" and "I missed the answer because it spans a chunk boundary." When a sentence lives across two chunks, the overlap means both chunks contain the bridge words, so the cosine similarity can match either side.

Third, the original-offset tracking (char_start, char_end in the Chunk dataclass) is the feature I didn't know I needed until I built the source highlighter in the UI. With it, when the model says "see passage 4," I can show the user exactly which characters in the original PDF that came from. Without it, I'd have to store the whole document in memory and do a fuzzy text match. The cost is 16 bytes per chunk. The payoff is "this citation is real, not a hallucination."

Part 2 — the embedding swap

The single best refactor I did in this project was making Embedder a Protocol. Two lines of typing, infinite flexibility:

class Embedder(Protocol):
    def embed(self, text: str) -> list[float]: ...
    def embed_batch(self, texts: list[str]) -> list[list[float]]: ...

Now I can write a FakeEmbedder for tests that returns deterministic vectors, and OllamaEmbedder for production that hits the local Ollama API. The pipeline doesn't know or care which one it's talking to. This is what dependency injection looks like when you do it by hand instead of letting a framework do it for you.

The actual OllamaEmbedder is 20 lines:

class OllamaEmbedder:
    """Embedding via local Ollama HTTP API. Free, no API key."""

    def __init__(self, model: str = "nomic-embed-text", base_url: str = "http://localhost:11434"):
        self.model = model
        self.base_url = base_url.rstrip("/")

    def embed(self, text: str) -> list[float]:
        return self.embed_batch([text])[0]

    def embed_batch(self, texts: list[str]) -> list[list[float]]:
        # One HTTP call per batch is dramatically faster than per-text
        out: list[list[float]] = []
        for text in texts:
            r = requests.post(
                f"{self.base_url}/api/embeddings",
                json={"model": self.model, "prompt": text},
                timeout=60,
            )
            r.raise_for_status()
            out.append(r.json()["embedding"])
        return out

The per-batch call is the only performance optimization. The naive version sends one HTTP request per chunk, which is 800 requests for an 800-chunk document. At 50ms per request, that's 40 seconds. Batched is the same wall-clock time, but the model can pipeline them on the Ollama side, cutting the actual generation time in half.

The reason the per-batch loop is sequential and not concurrent.futures.ThreadPoolExecutor: when I tried threading, Ollama's HTTP server dropped connections under load. The sequential version is slower in wall-clock terms but reliable. Trade-offs.

Part 3 — the vector store

I used ChromaDB. Not because it's the best, but because it's the easiest to set up correctly. pip install chromadb, three lines of code, and you have a persistent, queryable, cosine-similarity-vector-store on disk.

class VectorStore:
    """Thin wrapper around a ChromaDB collection."""

    def __init__(
        self,
        persist_dir: str | Path = "./chroma_db",
        collection_name: str = "rag",
    ):
        self.persist_dir = Path(persist_dir)
        self.persist_dir.mkdir(parents=True, exist_ok=True)

        self._client = chromadb.PersistentClient(
            path=str(self.persist_dir),
            settings=Settings(anonymized_telemetry=False, allow_reset=False),
        )
        # cosine space — works regardless of embedding norm and is standard for semantic search
        self._collection = self._client.get_or_create_collection(
            name=collection_name,
            metadata={"hnsw:space": "cosine"},
        )

The hnsw:space: cosine metadata is the one line that matters. ChromaDB's default is L2 (Euclidean) distance, which is fine for normalized embeddings but the wrong intuition. Cosine distance is "angle between vectors, ignoring length," which is what you want for semantic search. Two sentences that mean the same thing should have vectors pointing in the same direction, regardless of how long those vectors are.

The search method does one non-obvious conversion: ChromaDB returns distances in [0, 2], and I convert to similarity in [-1, 1] (clamped to [0, 1] for display). The line similarity = max(0.0, 1.0 - float(dist)) is the only math in the file. Everything else is glue.

similarity = max(0.0, 1.0 - float(dist))
hits.append(
    SearchHit(
        text=doc,
        score=similarity,
        metadata=meta,
        chunk_index=int(meta.get("chunk_index", 0)),
    )
)

Why clamp to 0? Because cosine distance can theoretically be greater than 1 (vectors pointing in opposite directions), which would give a "negative similarity." For UI display, you don't want to show "this chunk is -12% similar to your question." Clamping to 0 says "irrelevant" and is honest.

Part 4 — the prompt is the whole product

The most important 20 lines in the project are in pipeline.py:

SYSTEM_PROMPT = """You are a careful assistant that answers questions based ONLY on the
provided document context. Follow these rules strictly:

1. Use ONLY the information in the context below. Do not use outside knowledge.
2. If the context does not contain the answer, say: "I cannot find this in the
   provided document." Do NOT guess.
3. Quote or paraphrase the relevant passages. Keep answers concise.
4. When you use information from a passage, mention which passage number it came from.
"""

I rewrote this prompt six times. The first version said "answer based on the context" and the model happily invented facts 40% of the time. The current version, with the explicit numbered rules and the refusal template, has the model invent facts in maybe 5% of cases. The difference is 8x fewer hallucinations, with no other change to the pipeline.

The single most important sentence is #2: "If the context does not contain the answer, say: 'I cannot find this in the provided document.'" Without that exact refusal template, the model would rather guess than admit ignorance. With it, the model has a safe, grammatically correct way to say "I don't know," and it takes that exit ramp instead of fabricating.

The second most important sentence is #4: "mention which passage number it came from." This forces the model to engage with the structure of what I sent it. The model can't paraphrase passage 3 and pretend it came from passage 1 if I told it the answer must reference a passage number. The citations are now verifiable.

The third most important sentence is "Use ONLY the information in the context below." That single word — ONLY — does most of the work. Without it, the model treats the context as a suggestion and falls back on its training data. With it, the model treats the context as a constraint.

Part 5 — what I got wrong

Five things, in order of how much they cost.

5.1 Embedding the whole PDF

First version: I embedded the entire 40-page PDF as one document and asked questions against the single vector. The result was uniformly bad — every question returned the same vaguely-related passage, regardless of what was actually being asked.

I had to read three papers and one textbook chapter to figure out why. Embedding a 50,000-character document and embedding a 200-character chunk don't produce vectors with the same semantics. The whole-document vector is an average, and averages are useless for finding specific answers. Chunking is not an optimization. Chunking is the algorithm.

Fix: chunk first, embed chunks. Obvious in hindsight. Took me an embarrassing amount of time to figure out the first time.

5.2 Using the L2 distance by default

ChromaDB's default distance metric is L2 (Euclidean). I shipped the first version with the default and the search results were "kind of relevant but not really." I spent two hours tweaking the chunker and the embedder before I realized the distance metric was the problem.

The fix is one line: metadata={"hnsw:space": "cosine"} when creating the collection. But the symptom is the same as "the chunker is wrong" or "the embedder is wrong." Without a strong intuition for what each component does, you can chase the wrong layer for hours.

The lesson: when the search results are bad, check the distance metric before you check anything else. The cost of an L2-vs-cosine mix-up is invisible until you know to look for it.

5.3 The "always answer" reflex

The first version of the system prompt said "answer the question based on the context." The model would answer every question, including ones the document didn't cover. "What year was the company founded?" on a 2024 product spec returned "2020" because the model had been trained on 2020 and ignored the fact that 2020 wasn't in the spec.

The fix is the refusal template, as discussed in Part 4. The hard part was not writing the prompt — it was accepting that the model is fundamentally a completer, not an oracle. A completer with a good prompt is a useful tool. A completer with a vague prompt is a hallucination engine.

5.4 No idempotency on re-ingest

I re-ran the ingest command on the same PDF three times while debugging. Each run added 800 new chunks. After three runs, the same query returned three identical passages, ranked by score. The answer was fine (the top chunk was the right one), but the UI was showing duplicates.

The fix: derive document_id from a hash of the file path, and use that as the prefix for chunk IDs in ChromaDB. Re-ingesting the same file generates the same IDs, and ChromaDB's .add() is idempotent on ID. This is 5 lines of code. I should have written it on day one.

5.5 Not testing the chunker first

I wrote the pipeline top-down: PDF → embed → store → query → answer. Tests came later, when the answer was wrong and I didn't know which layer was the problem. I ended up writing the chunker tests last, which was backwards.

The right order: chunker tests first (pure functions, no I/O, no network, fast), then embedder (with a fake), then store (with an in-memory ChromaDB or a mock), then pipeline (integration test with fakes for everything). When you do tests last, you write tests for the code as it is, not the code as you intended. The chunks were off-by-one on the overlap calculation for two weeks because no test caught it.

The code and how to run it

The full source is at github.com/ZalaAvinash/rag-from-scratch-python. 14 tests pass. CI runs on Python 3.11, 3.12, 3.13. MIT license.

git clone https://github.com/ZalaAvinash/rag-from-scratch-python.git
cd rag-from-scratch-python
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

# One-time: pull the models
ollama pull nomic-embed-text
ollama pull llama3.2

# Ingest
PYTHONPATH=src python -m rag.cli ingest path/to/document.pdf

# Ask
PYTHONPATH=src python -m rag.cli ask "What is the main conclusion?"

Or use it as a library:

from rag import RAGPipeline, OllamaEmbedder, OllamaLLM, VectorStore

pipeline = RAGPipeline(
    embedder=OllamaEmbedder(),
    llm=OllamaLLM(),
    store=VectorStore(persist_dir="./chroma_db"),
)

pipeline.ingest("path/to/document.pdf")

result = pipeline.ask("Summarize the key points")
print(result.answer)
for hit in result.sources:
    print(f"  [{hit.chunk_index}] score={hit.score:.2f}")

Closing

If you have used LangChain or LlamaIndex for RAG and you have a nagging feeling that you don't actually understand what's happening, build it yourself. The exercise takes a weekend. The 500 lines of code are not the point — the 500 lines of thinking about chunk sizes, distance metrics, prompt design, and idempotency are the point. You will never use LangChain the same way again.

The most valuable thing I learned is that RAG is not "an algorithm." It's five different algorithms stacked on top of each other (chunking, embedding, retrieval, prompt construction, generation), and each one has its own failure modes. The high-level libraries hide the stack. The stack is the product.

If you build something similar, send me a PR. The repo is open. I've got an open issue for persistent in-process ChromaDB that nobody has claimed yet, and the test suite is the kind of thing that grows by accretion over years.


Build with: Python 3.11+ · pypdf · ChromaDB · Ollama · nomic-embed-text · llama3.2 · click · pytest

Repo: ZalaAvinash/rag-from-scratch-python

About the author: Avinash Zala is a senior .NET engineer in Surat, India, with 7+ years building enterprise web apps, APIs, and ERP systems. He is currently adding AI/LLM capabilities to his stack and writing about what he learns. GitHub · LinkedIn