Writing · RAG
RAG quality starts at extraction, not retrieval
When a RAG system retrieves the wrong thing, the instinct is to reach for the retrieval layer. Try a different embedding model. Tune the chunk size. Add a reranker. Move to hybrid search.
Those are real levers. They are also downstream of the problem, which is frequently that the text going in was never clean. You cannot embed your way out of a chunk that is half navigation menu.
What contaminated chunks look like
Take a documentation site. Every page carries a sidebar with fifty links, a header, a footer, and a cookie notice. Extract it badly and the text of every page begins with the same several hundred words.
Two things follow, both bad.
Chunks stop being distinct. If the first chunk of every page is mostly sidebar, those chunks are near-identical to each other across the whole corpus. They will be similar to almost any query about the product, and they contain nothing.
Real content gets diluted. A 500-token chunk with 300 tokens of furniture carries 200 tokens of signal, and its embedding is a blend of both. It will lose to a cleaner chunk that is less relevant.
The symptom people report is “retrieval returns plausible but useless passages”. That is usually this.
Chunk on structure, not on character count
Splitting every 1,000 characters cuts sentences in half and severs paragraphs from the heading that gives them context. Splitting on headings uses a decision the author already made about where ideas begin and end.
Which means the extraction has to have kept the headings, and a plain-text extractor has not. Structure is the thing worth preserving, and this is where it pays.
Carrying the heading path into each chunk is cheap and helps a lot:
{
"text": "Ten requests per minute per client IP, and eight in flight …",
"heading_path": ["API reference", "Rate limits"],
"source_url": "https://example.com/docs/rate-limits",
"published_date": "2026-07-30"
}Metadata is a retrieval filter, not decoration
Extraction should give you the publication date, canonical URL, author and language as fields. Each does a job downstream:
- Date lets you prefer recent documents, or exclude anything written before a product changed. Retrieval that ignores recency confidently returns last year's pricing.
- Canonical URL is how you cite a source in an answer, and how you notice that three retrieved chunks are the same page.
- Language stops a multilingual corpus from returning a translated near-duplicate of the passage you already have.
Deduplicate before embedding
Crawls produce duplicates by nature: the same article under two URLs, a print view, a paginated page that repeats a summary, a versioned docs tree where most pages are unchanged between versions.
Embedding all of them costs money and hurts results, because retrieving five copies of one passage wastes the context you had for five different ones. Hashing extracted text and dropping repeats before embedding is a few lines and routinely removes a noticeable slice of a corpus.
Note that this works on extracted text. Raw HTML of the same article under two URLs differs in dozens of attributes, so hashing the HTML finds nothing.
Freshness is a pipeline property
A corpus scraped once is wrong within months and nothing about it announces that. Re-crawling on a schedule, comparing content hashes and only re-embedding what changed keeps it current for a fraction of the cost of rebuilding.
Diffing extracted markdown is also far more useful than diffing HTML, where a rotating build ID or a changed analytics attribute makes every page look modified.
The order that works
- Discover. Sitemaps and feeds, so you know what exists rather than guessing.
- Crawl or scrape. Fetch, render only when the page needs it, extract content and metadata together.
- Deduplicate. On extracted text, before anything expensive happens.
- Chunk. On headings, carrying the heading path and source metadata into each chunk.
- Embed and index. With metadata as filterable fields, not concatenated into the text.
The first four steps decide most of the quality. The fifth is the one that gets tuned.
snakereach turns any URL into the shape described here: markdown first, structure preserved, with per-stage timings. Try it on a page of your own, or read what comes back.