%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '13px'}}}%%
graph LR
subgraph traditional ["Traditional LLM"]
Q1(["User<br/>Query"]) -->|relies on| T[("Training Data")]
T --> A(["LLM Response<br/><em>(hallucination<br>risk)</em>"])
A -->|answer| Q1
end
style traditional fill:#fff
style Q1 fill:#dfdfde
style T fill:#b3cde0
style A fill:#ffcccc
If you’ve published technical documentation with Quarto (like my Shiny App-Packages book) you’ve probably wished readers could ask it questions directly. A chatbot would be ideal; unfortunately, LLMs trained on public data might hallucinate when asked specific questions in reference to your documentation.1
Retrieval-Augmented Generation (RAG) solves this problem. Instead of relying on training data, RAG feeds your actual documentation into the LLM before generating a response. The result is a chatbot that answers accurately, cites the exact section it pulled from, and updates instantly as your book evolves.
In this post I’ll cover how to build a chatbot using {ragnar}, an R package that handles the entire RAG pipeline. I’ll show two deployment paths: one using a model API key (from Anthropic/Claude or OpenAI/GPT), and one using local models via Ollama for privacy and cost control. We’ll use the quartohelp project as our reference implementation.
What is RAG
RAG is a three-step process: 1) retrieve relevant excerpts from the documentation, 2) feed those excerpts to an LLM, and 3) let the LLM synthesize a response grounded in the actual content.
Why this matters
Without RAG, an LLM responds based on what it learned during training. For specialized topics like building Shiny apps as R packages2, this creates a reliability problem. The LLM might confidently describe module patterns from the book incorrectly because the training data was incomplete or conflicting.
With RAG, the LLM sees the actual text before responding. If we ask “where do I put external resources in my Shiny app/R package?”, the retrieval step finds the exact section of the book that covers this, passes this to the LLM, and the LLM synthesizes an answer directly from that content. If/when the documentation updates, the chatbot reflects those changes immediately without retraining.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '13px'}}}%%
graph LR
subgraph rag ["RAG Pipeline"]
Q2(["User<br/>Query"])
B[\"Your Documentation"\]
C(["LLM Response<br/><em>(grounded &<br>accurate)</em>"])
Q2 -->|"Retrieve relevant<br>excerpts"| B
B -->|feeds| C
C -->|answer| Q2
end
style rag fill:#fff
style Q2 fill:#dfdfde
style C fill:#ccffcc
The trade-off is complexity. RAG requires three additional components: a document chunking strategy, an embedding model to convert text to vectors, and a retrieval system to find relevant chunks. {ragnar} abstracts away most of this, but you’ll need to understand the basic concepts.
The {ragnar} workflow
{ragnar} implements RAG in seven sequential steps. Understanding this pipeline helped me configure it for my documentation.3
Here’s what each step does:
Document input:
{ragnar}accepts Quarto or Markdown files as input. For my Shiny App-Packages book, this means pointing{ragnar}at the.qmdfiles.Markdown conversion: Files are converted to a consistent Markdown format, preserving structure and headings.
Text chunking: Long documents are split into smaller chunks.
{ragnar}uses semantic chunking (breaking at logical section boundaries) rather than naive word-count splitting. This is important, because if the documentation has small subsections, those become natural chunk boundaries. Well-defined chunk boundaries are what makes the retrieval more precise.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '14px'}}}%%
graph TD
A(["<strong>Document Input</strong><br><em><code>.qmd</code> files</em>"]) --> B("<strong>Markdown Conversion</strong>")
B --> C("<strong>Text Chunking</strong><br><em>semantic boundaries</em>")
style A fill:#b2e7cd
style B fill:#fff
style C fill:#fbd6c2
Context augmentation: Each chunk is tagged with its heading hierarchy, which preserves context. For example, when the retrieval step finds a chunk about documenting Shiny app functions, it knows it came from the “Documenting app functions” section of Chapter 5.
Embedding: Chunks are converted to high-dimensional vector representations. This is where the embedding model (Claude, GPT, or local) enters the pipeline. Vectors capture meaning, and similar chunks have similar vectors, even if they use different words.
Storage: Vectors and chunks are stored in a
{duckdb}database. This is the retrieval index; it lives on disk and can be updated incrementally as the documentation evolves.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '14px'}}}%%
graph TD
C("<strong>Text Chunking</strong><br><em>semantic boundaries</em>")
D("<strong>Context Augmentation</strong><br><em>heading hierarchy</em>")
C --> D
D --> E["<strong>Embeddings Vectors</strong><br><em>Claude or Ollama</em>"]
E --> F[("<strong>DuckDB Storage</strong><br><em>local index</em>")]
style C fill:#b2e7cd
style D fill:#fff
style E fill:#fff
style F fill:#fbd6c2
- Retrieval and chat: At runtime, user queries are embedded using the same model, then compared to stored vectors using cosine similarity (vector math for “how similar are these concepts”).
{ragnar}also performs keyword matching as a fallback. Top results are passed to the LLM along with the user query.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '14px'}}}%%
graph TD
F[("<strong>DuckDB Storage<br></strong><em>local index</em>")]
F --> G("<strong>Retrieval<br></strong><em>semantic + keyword<br>cosine similarity</em>")
G --> H(["<strong>LLM Chat<br></strong><em>synthesis grounded<br>response</em>"])
style F fill:#b2e7cd
style G fill:#fff
style H fill:#fbd6c2
Notice that the embeddings and retrieval happen before generation. The LLM never touches the raw documentation; it only sees the relevant excerpts that the retriever found.
Setting up {ragnar} with API Keys
LLM Model providers (Claude from Anthropic or GPT from OpenAI) give the highest quality responses and the easiest setup. We just need an API key, but {ragnar} handles the rest.
Prerequisites
Install {ragnar} and required dependencies:
install.packages(c("ragnar", "duckdb"))library(ragnar)
library(duckdb)We’ll also need either an Anthropic API key (for Claude) or OpenAI key (for GPT). Set the API key as an environment variable before starting:
export ANTHROPIC_API_KEY="your-key-here"export OPENAI_API_KEY="your-key-here"Building a retrieval index
The first time we set up {ragnar}, we’ll ingest the book’s .qmd files and build a retrieval index. This is a one-time setup step. After this, we only need to rebuild if the book content changes.
The code below ingests the shiny-app-packages book and builds an embedded index.
# build embedded index -----
book_path <- "~/path/to/shiny-app-pkgs"
chunks <- markdown_chunk(
dir = book_path,
pattern = "\\.qmd$"
)
embedded <- embed_anthropic(
chunks,
model = "claude-3-5-sonnet-20241022"
)
store <- ragnar_store_create(
embedded,
path = "shiny-book-index.duckdb"
)- 1
-
Point to the book’s
.qmdfiles
- 2
-
Finds all
.qmdfiles in the book directory; converts them to chunks with heading context preserved.
- 3
-
Sends chunks to Anthropic API for embedding (this costs a few cents for a typical book), gets back vectors.
- 4
-
Creates a local
{duckdb}file that serves as the retrieval index.
The store is now ready. We can query it without additional API calls; only live chat with Claude requires the API.
Running the chatbot
Once the index is built, we can launch an interactive chat session:
# run the chatbot --------------
store <- ragnar_store_open("shiny-book-index.duckdb")
chat <- ragnar_chat_create(
store = store,
model = "claude-3-5-sonnet-20241022",
provider = "anthropic"
)
response <- ragnar_chat_message(
chat,
"How do I structure file names for R6 class modules in a Shiny app-package?"
)- 1
-
Load our stored index
- 2
-
Start a chat session (creates a chat session bound to our index and Claude model)
- 3
- Ask a question. The query is embedded and compared to stored vectors; top matches feed into Claude along with the query.
The contents of response include:
response$answer
response$sources
response$citations- 1
-
$answer: Claude’s response grounded in your book
- 2
-
$sources: List of chunks used to generate the response
- 3
-
$citations: Links back to the.qmdfiles
Claude generates a response grounded in our actual documentation, not training data.
Setting up {ragnar} locally
For privacy-conscious deployments or when API costs matter, we can run embeddings and chat locally using Ollama. This approach keeps the data on our machine and has no per-query costs.
Installing Ollama
Download and install Ollama from https://ollama.ai/. Then pull a model:
Pull the nomic-embed-text model (it’s a capable open model for embeddings):
ollama pull nomic-embed-text Pull a chat model (e.g., mistral or neural-chat)
ollama pull mistralStart the Ollama server:
ollama serveOllama listens on localhost:11434 by default.
Building the index locally
The setup is similar to the API key approach, but {ragnar} calls a local Ollama instance instead of Anthropic:
# Build the local index ----------------
chunks <- markdown_chunk(
dir = "~/path/to/shiny-app-pkgs",
pattern = "\\.qmd$"
)
embedded <- embed_ollama(
chunks,
base_url = "http://localhost:11434",
model = "nomic-embed-text"
)
store <- ragnar_store_create(
embedded,
path = "shiny-book-local.duckdb"
)- 1
-
Chunk the book
- 2
-
Embed locally via Ollama
- 3
-
Ollama server URL; ensure
ollama serveis running before this step
- 4
-
Store in
{duckdb}(same as before)
This step is slower than cloud embeddings (several minutes for a typical book) but runs entirely on the local machine.
Running local chat
Once the index is built, chat is just as simple:
# Run chat locally -----------
store <- ragnar_store_open("shiny-book-local.duckdb")
chat <- ragnar_chat_create(
store = store,
model = "mistral",
provider = "ollama",
base_url = "http://localhost:11434"
)
response <- ragnar_chat_message(
chat,
"Explain the difference between module-based and R6-based architecture in Shiny app-packages."
)- 1
-
Load locally stored index
- 2
-
Build
chatfrom localstoreand specifymodel
- 3
-
You can swap
mistralfor another Ollama model (e.g.,neural-chat,llama2)
- 4
-
Ask a question and store the
response
We can view the answer in the response:
response$answerThe workflow is identical; only the model and provider change. No API keys, no cloud calls, and the documentation content never leaves our machine.
API Keys vs. Local models
Here’s a practical comparison:
| Dimension | API Key (Claude/GPT) | Local (Ollama) |
|---|---|---|
| Setup | API key + one command | Download + server |
| Speed | ~1 sec per query | ~5-10 sec per query |
| Quality | Excellent | Good |
| Cost | ~$0.01-0.05 per 1K embeddings | Free after setup |
| Privacy | Data sent to provider | All local |
| Hallucination risk | Low | Moderate |
| Maintenance | None (provider manages) | Keep Ollama updated |
Use API model keys when
Response quality matters most; i.e., publishing the chatbot publicly
Speed is important; users expect sub-second responses
Cost is not a concern (typical book: $0.50 setup + $0.001 per chat turn)
Cloud API calls aren’t a concern
Use local models when
Privacy is mandatory; data cannot leave the network
Testing or building an internal tool
Cost is a concern (local models have zero per-query costs)
The available server can run Ollama (CPU is fine, GPU is better)
For the shiny-app-packages book, I started with Claude. The setup was simple, quality was excellent, and costs are negligible for a published resource. Later, if I want to offer an offline or private version, Ollama provides a clear fallback.
Deployment architectures
The two approaches differ in where computation happens and how data flows. Model keys send queries and receive responses from cloud APIs, but the index stays local.
%%{init: {'theme': 'neutral', 'themeVariables': {'fontSize': '13px'}}}%%
graph LR
subgraph cloud ["Model API (Claude/GPT)"]
direction TB
U1(["Your Machine"])
B1[("duckdb Index<br/>with embeddings<br/>stored locally")]
U1 -->|"i. query"| B1
B1 -->|"ii. retrieve chunks<br/>similarity search"| U1
U1 -->|"iii. query + chunks"| C1{{"Anthropic/OpenAI<br/>Servers"}}
C1 -->|"iv. response"| U1
end
style cloud fill:#fff
style C1 fill:#fff0e8
style B1 fill:#b3cde0
Local models keep everything on our machine; Ollama never touches the internet:
%%{init: {'theme': 'neutral', 'themeVariables': {'fontSize': '13px'}}}%%
graph LR
subgraph local ["Local Models (Ollama)"]
direction TB
U2(["Your Machine"])
B2[("duckdb Index<br/>with embeddings<br/>stored locally")]
U2 -->|"i. query"| B2
B2 -->|"ii. retrieve chunks<br/>similarity search"| U2
U2 -->|"iii. query + chunks"| O{{"Ollama Server<br/>localhost:11434"}}
O -->|"iv. response"| U2
end
style local fill:#fff
style O fill:#f0f8e8
style B2 fill:#b3cde0
Integrating with book site
The quartohelp project shows one complete working example: it builds the index during the site build, serves it through a Quarto extension, and embeds a chat widget directly in the rendered pages.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '13px'}}}%%
graph TD
author(["<strong>Author</strong><br/>Writes documentation"]) -->|"quarto render"| build("<strong>Site</strong><br>build process")
build -->|"Build index"| chunk("Chunk and embed<br/>during render")
chunk -->|"Create"| index[("<strong>Index</strong><br/>in memory")]
index -->|"Register"| ext("Quarto Extension")
ext -->|"Inject"| pages("Rendered HTML pages<br/>with chat widget")
pages -->|"Read + query"| reader(["Reader<br/>Uses chat widget<br/>on the page"])
style author fill:#dfdfde
style build fill:#fff
style chunk fill:#fff
style index fill:#b3cde0
style ext fill:#fbd6c2
style pages fill:#ccffcc
style reader fill:#dfdfde
This is an elegant approach, but it couples the chatbot to the site build, which means every render is also an embedding job and every change to the widget is a change to the book.
I reached for a simpler approach: Publish the index as a static file alongside the site, then let a separately deployed Shiny app read that file and do the querying. This way, the book stays a book, and the chatbot stays an app. Division of labor.
Why a decoupled index works
The {duckdb} index is a single file. Once the embeddings are computed, these files are immutable until the documentation changes, so nothing about it needs a server. GitHub Pages (or Netlify, or S3) already serves static files well, and publishing the index costs nothing beyond the storage.
Splitting the process into two pieces has three advantages:
Decoupling: Rendering the book never triggers an embedding run, so
quarto renderstays fast and stays free of API keys. Breaking the chat app never breaks the book.Simplicity: There’s no Quarto extension to maintain, no JavaScript bridge between the page and the index, and no per-reader retrieval running in the browser. The Shiny app is an ordinary Shiny app.
Maintenance: The index and the app version independently. We can redeploy the app with a new model or a nicer UI without rebuilding embeddings, and we can rebuild embeddings without redeploying the app.
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '13px'}}}%%
graph TD
author(["<strong>Author</strong><br/>Local Machine"]) -->|"i. Build Index"| chunk("Chunk documentation<br/>Embed via Claude/Ollama")
chunk -->|"ii. Create file"| duckdb[("<strong>shiny-book-index.duckdb</strong>")]
duckdb -->|"iii. Commit to repo"| repo("Git<br/>Repository")
repo -->|"iv. Site build"| build("Quarto/Hugo<br/>build process")
build -->|"v. Publish"| publish[("<strong>Published Index</strong><br/>https://site.com/assets/index.duckdb")]
style author fill:#dfdfde
style chunk fill:#fff
style duckdb fill:#b3cde0
style repo fill:#fff
style build fill:#fff
style publish fill:#fbd6c2
The Published Index is a hyperlink: https://site.com/assets/index.duckdb
The trade-off? Readers leave the book to use the chatbot, or interact with it through an embedded iframe rather than a native widget. For most documentation sites, that’s a fair price.
Publishing the index with the site
To build the index locally as mentioned above, we can write it into a directory that Quarto will publish. I put mine under assets/ at the book root.
shiny-app-pkgs/
├── _quarto.yml
├── index.qmd
├── document.qmd
├── assets/
│ ├── shiny-book-index.duckdb
│ └── index-version.txt
└── ...Quarto copies all the files it doesn’t render, but being explicit avoids surprises. So we add the directory to the resources key in _quarto.yml:
project:
type: book
resources:
- assets/shiny-book-index.duckdb
- assets/index-version.txtThe index-version.txt file holds a timestamp or commit SHA written at build time. The app uses it to decide whether its cached copy is stale.
# rebuild index ----
chunks <- markdown_chunk(dir = ".", pattern = "\\.qmd$")
embedded <- embed_anthropic(chunks, model = "claude-3-5-sonnet-20241022")
store <- ragnar_store_create(embedded, path = "assets/shiny-book-index.duckdb")
writeLines(format(Sys.time(), "%Y%m%d%H%M%S"), "assets/index-version.txt")Commit both files, push, and publish the site. The index is now a URL:
https://mjfrigaard.github.io/shiny-app-pkgs/assets/shiny-book-index.duckdbQuerying the index from Shiny
With the Shiny method, the app downloads the published file once at startup, opens it read only, and passes the store to a module. We can put the download in global.R (or app.R above ui), so it runs once per process rather than once per user session.
# global.R ----
library(shiny)
library(ragnar)
base_url <- "https://mjfrigaard.github.io/shiny-app-pkgs/assets"
index_path <- file.path(tempdir(), "shiny-book-index.duckdb")
remote_version <- readLines(file.path(base_url, "index-version.txt"))[1]
cached_version <- file.path(tempdir(), "index-version.txt")
stale <- !file.exists(index_path) ||
!identical(remote_version, readLines(cached_version)[1])
if (stale) {
curl::curl_download(file.path(base_url, "shiny-book-index.duckdb"), index_path)
writeLines(remote_version, cached_version)
}
store <- ragnar_store_open(index_path, read_only = TRUE)- 1
-
{duckdb}needs a file on disk, so the app writes to its own temp directory
- 2
-
One small HTTP request tells us whether the published index has moved
- 3
-
Download only when the cached copy is missing or out of date
- 4
- Opening read only means every user session can share this connection safely
The data flow is short because we’re building locally, committing, publishing the data with the site, downloading once at app startup, and querying in memory from then on. Readers never touch the book repo, and the app never writes back to the index.
The module handles the query itself. Each user gets their own chat session so conversation history doesn’t leak between readers, but they all read from the same store.
show/hide chat module
# R/mod_ragnar_chat.R ----
mod_ragnar_chat_ui <- function(id) {
ns <- NS(id)
tagList(
textAreaInput(ns("query"), "Ask the book a question", width = "100%", rows = 3),
actionButton(ns("ask"), "Ask", class = "btn-primary"),
uiOutput(ns("answer")),
uiOutput(ns("sources"))
)
}
mod_ragnar_chat_server <- function(id, store) {
moduleServer(id, function(input, output, session) {
chat <- ragnar_chat_create(
store = store,
model = "claude-3-5-sonnet-20241022",
provider = "anthropic"
)
response <- eventReactive(input$ask, {
req(nzchar(input$query))
ragnar_chat_message(chat, input$query)
})
output$answer <- renderUI({
markdown(response()$answer)
})
output$sources <- renderUI({
tags$ul(
lapply(response()$citations, \(cite) tags$li(tags$a(href = cite, cite)))
)
})
})
}- 1
-
Created inside
moduleServer(), so the session is per user
- 2
-
eventReactive()keeps the app from calling the API on every keystroke
- 3
- Citations are the payoff of RAG; always show the reader where the answer came from
We can wire it up in an unremarkable app interface:
ui <- fluidPage(
titlePanel("Ask Shiny App-Packages"),
mod_ragnar_chat_ui("chat")
)
server <- function(input, output, session) {
mod_ragnar_chat_server("chat", store = store)
}
shinyApp(ui, server)For deployment, use the {rsconnect} package to deploy to Posit Connect or Connect Cloud. Run the following:
rsconnect::deployApp()Before deploying, set an ANTHROPIC_API_KEY environment variable in the deployment platform’s secrets manager (not in the code). On Connect Cloud, this goes in the app’s Environment Variables settings on the dashboard. Link to the deployed app from the book’s navbar (or embed it in a page with an <iframe>).
%%{init: {'theme': 'neutral', 'themeVariables': { 'fontSize': '13px'}}}%%
graph TD
reader(["<strong>Reader</strong><br/>Opens Shiny App"]) -->|"i. Load index"| fetch("Shiny app fetches<br/>published <code>.duckdb</code> file")
fetch --> index[("<strong>Index in Memory</strong><br/>embeddings + chunks")]
index -->|"ii. User query"| embed("Embed query<br/>locally")
embed -->|"iii. Retrieve"| search("Cosine similarity search<br/>find top chunks")
search -->|"iv. Send"| llm{{"Claude/GPT API<br/>or Ollama"}}
llm -->|"v. Response"| response(["<strong>Chat Response</strong><br/>grounded in docs"])
style reader fill:#dfdfde
style fetch fill:#fff
style index fill:#b3cde0
style embed fill:#fff
style search fill:#fff
style llm fill:#fff0e8
style response fill:#ccffcc
When the documentation changes, rebuild the index, commit it, and republish the book. The app picks up the new file the next time it cold starts, or immediately if it’s restarted. That’s the whole update story (no coordinated release, no rebuilt site extension).
Recap
RAG grounds chatbots in actual documentation instead of training data, solving the hallucination problem:
{ragnar}handles the entire pipeline; chunking, embedding, retrieval, and chat are all one function each
- We can choose between API model keys (Claude, GPT) for quality or local Ollama for privacy and cost
- Building an index takes minutes; updating it as your documentation evolves can be done a single rebuild step
This approach transforms static documentation into an interactive resource without requiring us to retrain an LLM or manage complex infrastructure.
Footnotes
i.e., they might confidently describe a pattern from the documentation incorrectly, or even invent function arguments that don’t exist.↩︎
In truth, this topic isn’t so specialized that an LLM couldn’t give best practices. Mastering Shiny and R Packages, 2e have almost certainly been included in the training data. And if they haven’t, the Shiny source code and Writing R Extensions definitely have.↩︎
Read more in Why RAG? The Hallucination Problem.↩︎