Grounded in truth 🧭

A deep dive into retrieval-augmented generation with ragnar

RAG
ragnar
ellmer
embeddings
vector search
ollama
Build a real RAG pipeline end to end with ragnar: crawl a site, chunk it, embed it for free with a local model, store it in DuckDB, retrieve two different ways, and hand retrieval to the LLM as a tool.
Author

Nelson Amaya

Published

July 4, 2026

Modified

July 20, 2026

“It is a capital mistake to theorize before one has data.”
–Arthur Conan Doyle, A Scandal in Bohemia

PART I: Why retrieval, not memory

Talking to machines ended on an uncomfortable result: asked to judge song mood from titles alone, the model answered fluently and confidently – and its answers had zero correlation with the songs’ actual audio valence. The model wasn’t malfunctioning. It was doing exactly what you asked, articulately, from a question the input couldn’t actually answer.

Retrieval-augmented generation (RAG) is the fix for a related but distinct problem: instead of asking a model to answer from what it memorized during training (which might be outdated, wrong, or simply not about your content), you first retrieve the actual relevant text from a real source, then hand the model that text and ask it to answer from it. The model stops guessing and starts reading.

ragnar is a tidyverse-style toolkit that does the unglamorous half of RAG well: turning real documents into a searchable store. It has one function for each stage of the pipeline, and –true to form for this workshop– we’ll train it on R4DEV’s own published site, the same trick ragnar’s own homepage example plays on the R for Data Science book.

Level: Advanced  ·  Time: ~60 min  ·  Prerequisites: Talking to machines  ·  Tools: ragnar, ellmer, DuckDB

You will learn to

  • Explain why retrieval beats relying on a model’s memorised training data.
  • Turn a website into a searchable store: read, chunk, embed and store real content in DuckDB.
  • Retrieve relevant chunks two ways – keyword (BM25) and semantic (vector similarity).
  • Register a retrieval store as a tool an LLM can call on its own.
  • See the whole pipeline running live in the “Ask R4DEV” capstone at the end of this lesson.
TipThe whole pipeline in one sentence

Read a page into clean markdown → chunk it into retrievable pieces → embed each chunk as a vector → store everything in DuckDB → retrieve the pieces relevant to a question → hand them to an LLM. Every section below is one verb.

PART II: Read – turning a page into markdown

The web is HTML: navigation bars, footers, cookie banners, ads. None of that belongs in a knowledge base. read_as_markdown() fetches a URL and extracts just the article content (by default it keeps <main> and discards <nav>), returning clean markdown.

library(ragnar)

page <- "https://r4dev.netlify.app/sessions_workshop/02-plots/02-plots.html"
1md <- read_as_markdown(page)

2cat(substr(md, 1, 400))
1
One URL in, one markdown string out. Works on local files too, not just URLs.
2
The first 400 characters –notice the navbar and cookie banner are already gone.
# Everything in its right place 🎼 – R4DEV

# Everything in its right place 🎼

Learn how to retrieve information from APIs and create interactive visualisations

API

httr2

interactive

ggplot2

plotly

ggiraph

Learn how to get information from APIs with httr2, and create interactive visualisations of historical GDP, Kyoto weather and archived Spotify songs using ggplot2, plotly and ggiraph.

Aut

To index more than one page, ragnar_find_links() crawls a page for its own internal links –the same instinct as session 6’s scraping, aimed at an entire site instead of one page:

3links <- ragnar_find_links(page)

4length(links)
head(links, 6)
3
Starting from the plots session, find every link it points to.
4
Dozens of links –external package docs, other sessions, footnotes. We’ll filter this down before indexing anything.
[1] 45
[1] "http://davidgohel.github.io/ggiraph"                                        
[2] "http://rcharlie.net/sentify"                                                
[3] "https://cran.r-project.org/web/packages/esquisse/vignettes/get-started.html"
[4] "https://creativecommons.org/licenses/by/4.0"                                
[5] "https://docs.owid.io/projects/etl/api"                                      
[6] "https://ggplot2.tidyverse.org/reference/ggtheme.html"                       

PART III: Chunk – splitting for retrieval

An LLM’s context window is finite, and stuffing an entire session into one prompt wastes it on irrelevant paragraphs. markdown_chunk() splits a document into overlapping, retrievable pieces, trying to respect paragraph and heading boundaries rather than cutting mid-sentence.

5chunks <- markdown_chunk(md)

6class(chunks)
nrow(chunks)
7chunks[10, c("context", "text")]
5
target_size (default 1600 characters) and target_overlap (default 50%) control chunk granularity –smaller chunks retrieve more precisely but lose surrounding context.
6
A MarkdownDocumentChunks tibble –just a data frame with extra structure, so anything you know from dplyr still applies.
7
context carries the heading breadcrumb for that chunk (which section it came from); text is the chunk content itself. This is what eventually gets embedded and retrieved.
[1] "ragnar::MarkdownDocumentChunks" "tbl_df"                        
[3] "tbl"                            "data.frame"                    
[5] "S7_object"                     
[1] 54
# @document@origin:
#   https://r4dev.netlify.app/sessions_workshop/02-plots/02-plots.html
# A tibble:         1 × 2
  context                                                                  text 
  <chr>                                                                    <chr>
1 "# Everything in its right place \U0001f3bc\n## PART I: The story of a … "The…

PART IV: Embed – turning text into vectors for free

An embedding turns text into a vector of numbers positioned so that similar meanings land near each other in space – “leaflet” and “interactive map” end up close together even though they don’t share a single letter. This is what makes semantic search possible, as opposed to matching exact keywords.

ragnar speaks to embedding providers the same way ellmer speaks to chat providers: embed_openai(), embed_google_gemini(), embed_azure_openai(), embed_bedrock()… one function per provider, same interface. We will use embed_ollama(), which runs a model entirely on your own machine via Ollama –no API key, no quota, no cost, ever. Install Ollama, pull an embedding model once (ollama pull embeddinggemma), and you have a private, free embedding service running on localhost.

8embed_fn <- ragnar::embed_ollama(model = "embeddinggemma")

9vec <- embed_fn("How do I make an interactive map?")
10dim(vec)
round(vec[1, 1:8], 3)
8
embed_ollama() talks to Ollama’s local API at http://localhost:11434 by default.
9
Any character vector goes in; embeddings often batch multiple texts in one call for speed.
10
One row per input text, one column per embedding dimension –768 here. The actual numbers are meaningless in isolation; what matters is distance between vectors, which is what retrieval computes next.
[1]   1 768
[1] -0.135 -0.008  0.020  0.023  0.018  0.045 -0.058 -0.013
ImportantThis workshop nearly didn’t have this section

Building the “Ask R4DEV” capstone (end of this session) first tried a paid embedding API and hit a wall: no Gemini key was ever provisioned, and the OpenAI key on file had insufficient_quota. Local embeddings solved it completely, for free. If you only remember one thing from this section, remember that ollama pull embeddinggemma is a legitimate, production-grade answer to “I don’t have an embedding API key” –not just a workaround for a workshop.

PART V: Store – a DuckDB-backed knowledge base

Reading, chunking and embedding one page by hand is instructive; indexing a whole site by hand is tedious. ragnar_store_create() opens a DuckDB file (or :memory:) configured to hold chunks and their vectors, and ragnar_store_ingest() does read + chunk + embed + insert + index in one call, for as many pages as you give it.

# Keep the site to its own session pages, skip everything external
11session_links <- links[grepl("sessions_(workshop|tools)/.*\\.html$", links)]

12store <- ragnar_store_create(
  "r4dev_ragnar.duckdb",
  embed = ragnar::embed_ollama(model = "embeddinggemma"),
  title = "R4DEV workshop content"
)

13ragnar_store_ingest(store, session_links, build_index = TRUE)
11
Filter the crawl down to R4DEV’s own session pages –no point indexing CRAN or GitHub in our knowledge base.
12
A named file instead of :memory: means the store survives after R exits –exactly what a deployed app needs.
13
One call: every page gets read, chunked, embedded, and inserted, and both search indexes (below) get built at the end. Ingestion runs in parallel workers automatically.
#> Launching 2 parallel workers.
#> ℹ ingested: https://r4dev.netlify.app/index.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/02-plots/02-plots.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/03-text/03-text.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/04-animate/04-animate.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/05-maps/05-maps.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/06-scrap/06-scrap.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/09-llm/09-llm.html
#> ℹ ingested: https://r4dev.netlify.app/sessions_workshop/10-llm-apps/10-llm-apps.html
#> Ingesting ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■  100% | ETA:  0s

PART VII: Give the model the tool

Talking to machines taught tool calling by hand: write a function, describe it, register it, let the model decide when to call it. ragnar_register_tool_retrieve() does exactly that for retrieval in one line –the model gets to decide when it needs to search, instead of your code always searching before every message.

library(ellmer)

16chat <- ellmer::chat_anthropic()
17ragnar_register_tool_retrieve(
  chat, store,
  store_description = "R4DEV workshop session content"
)

18chat$chat("What R package does this workshop use for structured data extraction from LLMs?")
16
Any ellmer chat works –Anthropic here, but chat_google_gemini() or chat_openai() are equally valid.
17
One function call wires the whole store up as a callable tool, with a description telling the model what it’s for.
18
Watch the ◯ [tool call] line in the output below: the model decided on its own that it needed to search before answering, called the tool, read the retrieved chunk from the Talking to machines session, and answered correctly –with details (chat_structured(), type_object(), parallel_chat_structured()) that only exist in the real retrieved text, not in the model’s general training.
#> ◯ [tool call] search_store_001(text = "R package structured data extraction LLMs")
#> ● #> [{"origin": "sessions_workshop/09-llm/09-llm.html", "doc_id": 2, "chunk_id": [31, 32], ...}]
#>
#> Based on the workshop content, the R package used for structured data
#> extraction from LLMs is **`ellmer`**.
#>
#> From the workshop materials, `ellmer` is described as the package from the
#> tidyverse team that enables you to:
#>
#> 1. **Extract structured data from text** -- Using the `$chat_structured()`
#> function, you define the shape of data you want (using `type_object()`,
#> like a contract), and `ellmer` forces the model's answer into exactly
#> that shape.
#> 2. **Work with multiple LLM providers** -- It speaks to every major
#> provider (Google Gemini, Anthropic, OpenAI, Mistral, and local models
#> via Ollama) with the same functions.
#> 3. **Scale extraction** -- The `parallel_chat_structured()` function
#> allows you to send multiple prompts concurrently and get back tidy
#> data frames.

Capstone: Ask R4DEV, rebuilt

This is the deployed, always-on version of everything above: a chatbot that answers questions about R4DEV using only real, retrieved excerpts from the 13 other sessions. It’s built with ragnar’s own ingestion pipeline (a real improvement over an earlier hand-rolled version of this app, which chunked by regex-splitting on markdown headings instead of markdown_chunk()’s semantic boundaries).

NoteWhy this runs on BM25, not VSS

The store behind this app was built locally with embed_ollama() –free, and genuinely used during development. But the deployed app runs on Posit Connect Cloud, which has no route back to a local Ollama instance, and this workshop has no cloud embedding provider with usable quota yet. So the live app retrieves with ragnar_retrieve_bm25() instead –a real production trade-off, not a shortcut. The moment a Gemini or OpenAI key with quota exists, swapping embed_ollama() for embed_google_gemini() in the ingestion script and redeploying is the entire fix.

🏗 Practice 3: Retrieve something real

Semantic search sounds like magic until you compute one by hand. The Basic tier does exactly that, in your browser, on a toy store of 10 chunks with 8-dimensional embeddings – the same arithmetic ragnar_retrieve_vss() runs over thousands of chunks with hundreds of dimensions.

Retrieve the best chunk for the question “how do I color regions of a map by a value?” – already embedded as a vector below. Two blanks: the dot product at the heart of cosine similarity, and the verb that ranks the results.

NoteHint

A dot product multiplies two vectors element-wise and then adds everything up – a * b is already done for you. Ranking descending is a dplyr verb you use every day.

TipSolution
library(dplyr)

chunks <- read.csv("toy_embeddings.csv")

question <- c(0.2, 0.9, 0, 0, 0, 0, 0.1, 0)

cosine <- function(a, b) sum(a * b) / (sqrt(sum(a^2)) * sqrt(sum(b^2)))

emb <- as.matrix(chunks[, -1])

chunks |>
  mutate(similarity = apply(emb, 1, \(v) cosine(v, question))) |>
  select(chunk, similarity) |>
  arrange(desc(similarity)) |>
  slice_head(n = 3)

The choropleth chunk wins – the question and the chunk share almost no words, but their vectors point the same way. That’s the whole trick behind semantic search. Try re-embedding the question for a different topic (e.g. put the 0.9 in the LLM dimension) and re-run.

Real stores, real embeddings – your own R session with ragnar and Ollama installed:

  • Crawl a small documentation site you use often (a package’s pkgdown site is a good target) and build a store from its first 5-10 pages.
  • Retrieve with ragnar_retrieve_bm25() for a question you already know the answer to. Does it find the right page?
  • Take three questions about R4DEV and compare ragnar_retrieve_vss() against ragnar_retrieve_bm25() for each. Which query type (conceptual vs. keyword-heavy) favors which method?
  • Change markdown_chunk()’s target_size to something much smaller (e.g. 400) and re-inspect the store. What happens to retrieval quality on a broad question versus a specific one?
  • Register two tools on one chat: the retrieval tool from this session and the weather_now() tool from Talking to machines. Ask a question that needs both, and watch the model choose which to call and when.

Key takeaways

  • Retrieval fixes a different problem than a bigger model: it gives the model real text to answer from instead of relying on what it memorised.
  • The RAG pipeline is one verb per stage – read, chunk, embed, store, retrieve – and ragnar has one function per verb.
  • Keyword search (BM25) and semantic search (VSS) find different things; a production app should know which one it needs.
  • A tool-calling model decides for itself when to search, without special-cased retrieval code in the prompt.
  • Local, free embeddings (embed_ollama()) are a legitimate production answer when no paid API key is available – not just a workshop workaround.
Back to top

Citation

BibTeX citation:
@online{amaya2026,
  author = {Amaya, Nelson},
  title = {Grounded in Truth 🧭},
  date = {2026-07-04},
  url = {https://r4dev.netlify.app/sessions_ai/03-ragnar/03-ragnar},
  langid = {en}
}
For attribution, please cite this work as:
Amaya, Nelson. 2026. “Grounded in Truth 🧭.” July 4. https://r4dev.netlify.app/sessions_ai/03-ragnar/03-ragnar.