🎯 A customizable, anti-detection cloud browser powered by self-developed Chromium designed for web crawlers and AI Agents.πŸ‘‰Try Now
Back to Blog

How to Build a Local RAG Pipeline With Scrapeless and Ollama

Ava Wilson
Ava Wilson

Expert in Web Scraping Technologies

30-Jul-2026

TL;DR:

  • The whole loop runs locally, with no embedding-provider or LLM-provider API key. Scrapeless is the only paid call in this pipeline β€” fetching the source pages. Chunking, embedding, vector storage, retrieval, and answer generation all run on this machine.
  • Retrieval is proven, not assumed. Two questions about two different scraped people each pull back the correct person's biography chunk out of 48 chunks spanning 5 authors β€” with the real similarity distances captured below, not asserted.
  • The generation step is a live local call, not a template answer. A 494M-parameter Ollama model reads only the retrieved chunk and answers correctly, grounded in text scraped minutes earlier.
  • Chunking and embedding stay small on purpose. Each biography becomes 4–14 overlapping 60-word chunks, turned into 384-dimensional vectors with sentence-transformers in one batched local call β€” no GPU required.
  • This is the downstream half of two other Scrapeless pipelines. If you already have clean chunks or an OpenAI-embedded index, jump to Stage 3 or Stage 5 below β€” the differences are called out inline.
  • Free to start. Create your Scrapeless API key on the free plan at app.scrapeless.com.

Pipeline at a Glance

Retrieval-augmented generation, as defined in the original RAG paper, pairs a retriever over an external corpus with a generator that reads what the retriever finds. Most walkthroughs of that idea stop at one half of it. This one builds both halves, end to end, against real scraped pages:

  1. Fetch β€” pull rendered HTML for a fixed set of author pages through the Scrapeless Universal Scraping API.
  2. Extract and chunk β€” parse each page into biography text, split into overlapping word windows with provenance.
  3. Embed β€” turn every chunk into a vector locally with sentence-transformers.
  4. Store β€” persist the vectors and their source metadata in a local Chroma collection.
  5. Retrieve β€” embed a question, ask Chroma for the nearest chunks, and check whether the right person came back.
  6. Generate β€” hand the retrieved chunk to a local Ollama model and get a grounded answer, with no cloud LLM call anywhere.

Two other Scrapeless posts already cover pieces of this. The web-text ingestion guide fetches, extracts, and chunks into corpus.jsonl, then stops on purpose: embeddings are "downstream, with whatever stack you already use." The LLM-text pipeline guide goes further: discover, extract, chunk, and embed with OpenAI into Chroma. But it stops at the embedded record β€” nothing queries the store or generates an answer β€” and it needs a paid OPENAI_API_KEY. This guide is the part neither of those covers: local embeddings with zero embedding-provider key, a live proof that retrieval finds the right source, and a local model that actually answers the question. (For the concept of RAG itself rather than a build, see What Is Retrieval-Augmented Generation.)

Prerequisites

  • Python 3.10 or later.
  • A Scrapeless API key, exported as SCRAPELESS_API_KEY β€” the fetch stage is the only one that needs it.
  • Ollama installed and running, with a small model pulled: ollama pull qwen2.5:0.5b.
  • pip install sentence-transformers chromadb beautifulsoup4 requests.

Install

sentence-transformers pulls in PyTorch, so the first install takes a few minutes and roughly a gigabyte of disk. Everything after that β€” model loading, embedding, and querying β€” runs offline once the model is cached.

bash Copy
pip install sentence-transformers chromadb beautifulsoup4 requests
ollama pull qwen2.5:0.5b
export SCRAPELESS_API_KEY="sk_your_scrapeless_key"

Stage 1 β€” Fetch the Source Pages

The corpus for this walkthrough is five author-biography pages on quotes.toscrape.com, a public site built for scraping practice: Albert Einstein, Jane Austen, Marilyn Monroe, J.K. Rowling, and Thomas A. Edison. Five different people with five genuinely different birthplaces, dates, and careers is what makes Stage 5's retrieval check meaningful β€” a question about one of them has one correct source, not five plausible ones.

One POST per page to the Universal Scraping API returns the rendered HTML in the response's data field:

python Copy
# fetch.py -- pull rendered author-bio pages through the Scrapeless Universal Scraping API
import os
import pathlib

import requests

ENDPOINT = "https://api.scrapeless.com/api/v2/unlocker/request"
HEADERS = {
    "Content-Type": "application/json",
    "x-api-token": os.environ["SCRAPELESS_API_KEY"],
}
SOURCE_HOST = "https://quotes.toscrape.com"

AUTHORS = ["Albert-Einstein", "Jane-Austen", "Marilyn-Monroe", "J-K-Rowling", "Thomas-A-Edison"]

pathlib.Path("pages").mkdir(exist_ok=True)
for slug in AUTHORS:
    url = f"{SOURCE_HOST}/author/{slug}"
    resp = requests.post(
        ENDPOINT,
        headers=HEADERS,
        json={"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "redirect": True}},
        timeout=120,
    )
    resp.raise_for_status()
    html = resp.json()["data"]
    pathlib.Path("pages", f"{slug}.html").write_text(html, encoding="utf-8")
    print(f"{slug}: {len(html):,} bytes")

These pages render server-side with no client-side JavaScript building the biography, so js_render stays False β€” the unlocker still handles the request and returns the page either way, but there is no rendering cost to pay for content that is already in the initial HTML. A live run returns five distinct byte counts, one per author:

text Copy
Albert-Einstein: 5,329 bytes
Jane-Austen: 3,413 bytes
Marilyn-Monroe: 3,663 bytes
J-K-Rowling: 5,347 bytes
Thomas-A-Edison: 2,648 bytes

Get your API key on the free plan: app.scrapeless.com

Stage 2 β€” Extract and Chunk

Each page carries the same three fields in fixed classes: author-title, author-born-date, author-born-location, and author-description. Pull those with BeautifulSoup, lead the biography with an explicit born-sentence so the fact anchors a chunk on its own, then split into overlapping 60-word windows with a 15-word carryover:

python Copy
# chunk_corpus.py -- pages/*.html -> corpus.jsonl (overlapping word-window chunks with provenance)
import json
import pathlib
import re

from bs4 import BeautifulSoup

CHUNK_WORDS = 60
OVERLAP_WORDS = 15


def extract_author(html: str) -> dict:
    soup = BeautifulSoup(html, "html.parser")
    name = soup.find("h3", class_="author-title").get_text(strip=True)
    born_date = soup.find("span", class_="author-born-date").get_text(strip=True)
    born_loc = soup.find("span", class_="author-born-location").get_text(strip=True)
    desc = soup.find("div", class_="author-description").get_text(" ", strip=True)
    return {"name": name, "born_date": born_date, "born_location": born_loc,
            "description": re.sub(r"\s+", " ", desc)}


def chunk(words: list[str]):
    step = CHUNK_WORDS - OVERLAP_WORDS
    for start in range(0, max(len(words) - OVERLAP_WORDS, 1), step):
        yield start, " ".join(words[start:start + CHUNK_WORDS])


total = 0
with open("corpus.jsonl", "w", encoding="utf-8") as out:
    for page in sorted(pathlib.Path("pages").glob("*.html")):
        author = extract_author(page.read_text(encoding="utf-8"))
        lead = f"{author['name']} was born {author['born_date']} {author['born_location']}."
        words = f"{lead} {author['description']}".split()
        for start, body in chunk(words):
            out.write(json.dumps({"id": f"{page.stem}-{start}", "source": page.stem,
                                   "author": author["name"], "word_offset": start,
                                   "text": body}) + "\n")
            total += 1
print(f"{total} chunks -> corpus.jsonl")

A live run against the five fetched pages produces 48 chunks, unevenly split by how much each biography actually says:

text Copy
Albert Einstein: 615 words -> 14 chunks
J.K. Rowling: 644 words -> 14 chunks
Jane Austen: 327 words -> 7 chunks
Marilyn Monroe: 376 words -> 9 chunks
Thomas A. Edison: 195 words -> 4 chunks
48 chunks -> corpus.jsonl

Sixty words with a 25% overlap is a small-corpus setting, deliberately tighter than the 220/40-word window in the ingestion guide linked above β€” these biographies run a few hundred words each, not the multi-thousand-word articles that guide chunks. Tune the window to your source length, not to a fixed default.

Stage 3 β€” Embed Locally

Every chunk becomes a 384-dimensional vector with all-MiniLM-L6-v2, a compact sentence-embedding model small enough to run on CPU and benchmarked alongside larger models on the MTEB benchmark suite. The model downloads once (roughly 90 MB) and every encode call after that runs offline:

python Copy
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
vectors = model.encode(texts, show_progress_bar=False).tolist()

Stage 4 β€” Store in a Local Vector Database

Chroma's PersistentClient writes the collection to disk, so the index survives between runs without a server to manage. Each vector keeps its author and source page as metadata, which is what makes a retrieved chunk traceable back to a specific page rather than an anonymous string of text:

python Copy
import chromadb

client = chromadb.PersistentClient(path=".chroma")
collection = client.create_collection("author_bios")
collection.add(
    ids=[c["id"] for c in chunks],
    documents=[c["text"] for c in chunks],
    embeddings=vectors,
    metadatas=[{"author": c["author"], "source": c["source"], "word_offset": c["word_offset"]}
               for c in chunks],
)

A live run against all 48 chunks confirms the collection holds everything that was embedded:

text Copy
embedded 48 chunks, dim 384, collection count 48

Stage 5 β€” Retrieve the Right Chunk

This is the step most local-RAG walkthroughs skip: proving that retrieval actually returns the correct source instead of just returning something. Embed a question the same way the chunks were embedded, then ask Chroma for the nearest neighbors by its default L2 distance, where a smaller number means a closer match:

python Copy
query_vector = model.encode([question]).tolist()
result = collection.query(query_embeddings=query_vector, n_results=2)
top_author = result["metadatas"][0][0]["author"]
top_distance = result["distances"][0][0]

Two questions about two different people, run against the same 48-chunk index, each pull back the correct person and nobody else's biography:

text Copy
Q: Where was Albert Einstein born?
  top match author: Albert Einstein | distance: 0.6465
  chunk: Albert Einstein was born March 14, 1879 in Ulm, Germany. In 1879, Albert Einstein was born in Ulm, Germany. He completed his Ph.D. at the University of Zurich b...
Q: Where was J.K. Rowling born?
  top match author: J.K. Rowling | distance: 0.4566
  chunk: J.K. Rowling was born July 31, 1965 in Yate, South Gloucestershire, England, The United Kingdom. See also: Robert Galbraith Although she writes under the pen nam...

Neither question retrieves a chunk from Jane Austen, Marilyn Monroe, or Thomas Edison β€” the embedding space separates five unrelated biographies well enough that a factual question about one person does not accidentally surface another's.

Stage 6 β€” Generate a Grounded Answer

The retrieved chunk becomes the only context a local Ollama model gets. The prompt asks for a single JSON field β€” just the answer β€” because provenance already lives in the retrieval metadata from Stage 5; asking a small model to also restate a citation field is asking it to do a job the vector store already did for free, and it is not a reliable extra field to ask a 494M-parameter model for:

python Copy
import json
import requests

prompt = (
    'Answer the question in one short sentence using ONLY the context below. '
    'Reply with ONLY JSON {"answer": "..."}.\n\n'
    f"Context:\n{context}\n\nQuestion: {question}"
)
resp = requests.post(
    "http://localhost:11434/api/generate",
    json={"model": "qwen2.5:0.5b", "prompt": prompt, "stream": False,
          "format": "json", "options": {"temperature": 0, "num_predict": 40}},
    timeout=600,
)
answer = json.loads(resp.json()["response"])

A live call, fed only the retrieved Einstein chunk, answers correctly without ever seeing the other four biographies:

text Copy
retrieved chunk author (from vector store metadata): Albert Einstein
question: Where was Albert Einstein born, and in what year?
generated answer: Albert Einstein was born March 14, 1879 in Ulm, Germany.

That is the complete loop: a question goes in, the vector store narrows 48 candidates down to one relevant chunk, and a model running on localhost turns that chunk into a sentence β€” no OpenAI key, no Scraping Browser, nothing leaving the machine after the initial fetch.

The Complete Pipeline

Every stage above chains into one script: fetch five pages, chunk them, embed and store the result, run both retrieval checks, then generate the final answer. Nothing here is faked or assembled from separate sessions β€” it is the same six stages, executed once, top to bottom:

python Copy
# full_pipeline.py -- fetch -> chunk -> embed -> store -> retrieve -> generate, end to end
import json
import logging
import os
import pathlib
import re

os.environ.setdefault("HF_HOME", "/usr/local/share/hf-cache")
os.environ.setdefault("HF_HUB_OFFLINE", "1")

import chromadb
import requests
from bs4 import BeautifulSoup
from sentence_transformers import SentenceTransformer

logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)

# Stage 1: Fetch
ENDPOINT = "https://api.scrapeless.com/api/v2/unlocker/request"
HEADERS = {"Content-Type": "application/json", "x-api-token": os.environ["SCRAPELESS_API_KEY"]}
SOURCE_HOST = "https://quotes.toscrape.com"
AUTHORS = ["Albert-Einstein", "Jane-Austen", "Marilyn-Monroe", "J-K-Rowling", "Thomas-A-Edison"]

pathlib.Path("pages").mkdir(exist_ok=True)
for slug in AUTHORS:
    url = f"{SOURCE_HOST}/author/{slug}"
    resp = requests.post(
        ENDPOINT, headers=HEADERS,
        json={"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "redirect": True}},
        timeout=120,
    )
    resp.raise_for_status()
    html = resp.json()["data"]
    pathlib.Path("pages", f"{slug}.html").write_text(html, encoding="utf-8")
    print(f"{slug}: {len(html):,} bytes")

# Stage 2: Extract and chunk
CHUNK_WORDS, OVERLAP_WORDS = 60, 15


def extract_author(html: str) -> dict:
    soup = BeautifulSoup(html, "html.parser")
    name = soup.find("h3", class_="author-title").get_text(strip=True)
    born_date = soup.find("span", class_="author-born-date").get_text(strip=True)
    born_loc = soup.find("span", class_="author-born-location").get_text(strip=True)
    desc = soup.find("div", class_="author-description").get_text(" ", strip=True)
    return {"name": name, "born_date": born_date, "born_location": born_loc,
            "description": re.sub(r"\s+", " ", desc)}


def chunk_words(words: list[str]):
    step = CHUNK_WORDS - OVERLAP_WORDS
    for start in range(0, max(len(words) - OVERLAP_WORDS, 1), step):
        yield start, " ".join(words[start:start + CHUNK_WORDS])


chunks = []
with open("corpus.jsonl", "w", encoding="utf-8") as out:
    for page in sorted(pathlib.Path("pages").glob("*.html")):
        author = extract_author(page.read_text(encoding="utf-8"))
        lead = f"{author['name']} was born {author['born_date']} {author['born_location']}."
        words = f"{lead} {author['description']}".split()
        n_chunks = 0
        for start, body in chunk_words(words):
            rec = {"id": f"{page.stem}-{start}", "source": page.stem, "author": author["name"],
                   "word_offset": start, "text": body}
            out.write(json.dumps(rec) + "\n")
            chunks.append(rec)
            n_chunks += 1
        print(f"{author['name']}: {len(words)} words -> {n_chunks} chunks")
print(f"{len(chunks)} chunks -> corpus.jsonl")

# Stage 3 & 4: Embed and store
model = SentenceTransformer("all-MiniLM-L6-v2")
client = chromadb.PersistentClient(path=".chroma", settings=chromadb.Settings(anonymized_telemetry=False))
if "author_bios" in [c.name for c in client.list_collections()]:
    client.delete_collection("author_bios")
collection = client.create_collection("author_bios")

texts = [c["text"] for c in chunks]
vectors = model.encode(texts, show_progress_bar=False).tolist()
collection.add(
    ids=[c["id"] for c in chunks],
    documents=texts,
    embeddings=vectors,
    metadatas=[{"author": c["author"], "source": c["source"], "word_offset": c["word_offset"]} for c in chunks],
)
print(f"embedded {len(chunks)} chunks, dim {len(vectors[0])}, collection count {collection.count()}")

# Stage 5: Retrieve
for question in ["Where was Albert Einstein born?", "Where was J.K. Rowling born?"]:
    query_vector = model.encode([question]).tolist()
    result = collection.query(query_embeddings=query_vector, n_results=2)
    top_author = result["metadatas"][0][0]["author"]
    top_distance = result["distances"][0][0]
    top_text = result["documents"][0][0]
    print(f"Q: {question}")
    print(f"  top match author: {top_author} | distance: {top_distance:.4f}")
    print(f"  chunk: {top_text[:160]}...")

# Stage 6: Generate
question = "Where was Albert Einstein born, and in what year?"
query_vector = model.encode([question]).tolist()
result = collection.query(query_embeddings=query_vector, n_results=1)
context = result["documents"][0][0]
retrieved_author = result["metadatas"][0][0]["author"]

prompt = (
    "Answer the question in one short sentence using ONLY the context below. "
    'Reply with ONLY JSON {"answer": "..."}.\n\n'
    f"Context:\n{context}\n\nQuestion: {question}"
)
resp = requests.post(
    "http://localhost:11434/api/generate",
    json={"model": "qwen2.5:0.5b", "prompt": prompt, "stream": False,
          "format": "json", "options": {"temperature": 0, "num_predict": 40}},
    timeout=600,
)
resp.raise_for_status()
answer = json.loads(resp.json()["response"])
print("retrieved chunk author (from vector store metadata):", retrieved_author)
print("question:", question)
print("generated answer:", answer["answer"])

A single top-to-bottom run reproduces every number shown stage by stage above: five byte counts, 48 chunks, a 384-dimensional collection of 48 vectors, two correct retrievals, and one grounded answer.

Sourcing Responsibly

Biography pages describe real people, even on a practice site built for scraping. Three practices keep a retrieval corpus defensible when you point this pattern at production sources: collect public pages only and check each site's terms of service and robots directives before adding a domain to the fetch list; keep the source URL and author name attached to every chunk, the way this pipeline's metadata does, so a generated answer can be traced back to where it came from; and treat request volume the way this walkthrough does β€” five pages, once, not a standing crawl against a site that has not agreed to one.

Conclusion

Five pages in, one grounded sentence out, and every stage after the initial fetch runs on this machine. The retrieval half is the part worth trusting before the generation half: 48 chunks, two questions, two correct authors, with the distances to prove it β€” and only then does the local model get to answer. Swap the source pages, the embedding model, or the Ollama model, and the shape holds; the pipeline does not change when the content does.

Ready to Build Your RAG Pipeline?

Join the community building data pipelines on Scrapeless: Discord Β· Telegram.

Sign up at app.scrapeless.com for free trial credits, and point Stage 1 at the pages your own retrieval corpus needs. The developer docs cover the unlocker.webunlocker request shape, and current plans are on the pricing page.

FAQ

Q: Does any part of this pipeline need a cloud API key besides Scrapeless?

No. Scrapeless is the only paid call, and it is only needed for Stage 1's fetch. Embedding runs locally through sentence-transformers, the vector store is a Chroma file on disk, and generation runs through a local Ollama model β€” nothing else requires a provider key.

Q: How is this different from Scrapeless's other RAG-related posts?

Coverage, not overlap. The ingestion guide fetches, extracts, and chunks, then deliberately stops before embeddings. The LLM-text pipeline guide embeds with OpenAI and stores in Chroma, then stops before querying or generating. This guide is the local, zero-embedding-key half that finishes the loop: retrieve, then generate.

Q: Do I need a GPU?

No. all-MiniLM-L6-v2 and qwen2.5:0.5b both ran on CPU for this walkthrough. A GPU speeds up a larger local model, but neither the embedding step nor a small generation model requires one to build and test the pipeline.

Q: How do I pick a chunk size?

Match it to how much a single source actually says. This walkthrough's biographies run a few hundred words each, so 60 words with a 15-word overlap keeps a chunk to roughly one idea. A multi-thousand-word article calls for a wider window β€” the ingestion guide above uses 220 words with 40 of overlap for exactly that reason.

Q: What if retrieval returns the wrong chunk?

It usually means the corpus has near-duplicate content the embedding model cannot separate, or the question is phrased far from how the source text is worded. Widening chunk overlap, embedding with a larger model, or adding a second reranking pass over the top candidates all help; this walkthrough's five distinct biographies are deliberately easy to tell apart, which is what makes a clean retrieval result possible to show rather than just claim.

Q: Can I swap in a bigger local model for generation?

Yes β€” the request shape does not change, only the model string does. qwen2.5:0.5b is fast enough to demonstrate the loop on CPU; a 3B or larger Ollama model answers more reliably on messier context if the hardware has the memory to spare.

Q: Is scraping biography pages and generating answers from them legal?

Scraping publicly accessible pages is broadly permitted, but site terms and jurisdiction both matter. Collect only public pages, check the target site's terms of service and robots directives first, keep volume bounded, and handle any personal data in the corpus under the privacy laws that apply to you.

At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.

Most Popular Articles

Catalogue