---
title: "Building a RAG pipeline"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Building a RAG pipeline}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE)
```

This vignette builds a small retrieval-augmented generation (RAG) corpus end to
end: convert documents, chunk them with awareness of your embedding model's
tokenizer, attach embeddings, and run a similarity search.

## 1. Convert and chunk

Chunk size should respect the context window of the embedding model you will
use. `docling_chunk()` is token-aware: point it at the *same* tokenizer as your
embedder so the `max_tokens` budget is measured in the right units.

```{r}
library(doclingr)

doc <- docling_convert("paper.pdf")

chunks <- docling_chunk(
  doc,
  tokenizer  = "BAAI/bge-small-en-v1.5",
  max_tokens = 512
)
chunks
```

Each chunk's `text` is *contextualized* -- prefixed with its heading path and
table context -- which is the form you should embed. The unmodified passage is
kept in `raw_text`, and `headings`/`pages` let you cite sources later.

## 2. Attach embeddings

doclingr does not lock you into an embedding provider. You supply a function that
maps a character vector to vectors; `docling_embed()` handles batching and tidy
assembly. Here is a sketch against an HTTP embeddings API:

```{r}
embed_api <- function(texts) {
  # POST `texts` to your endpoint and return a matrix: one row per text.
  # e.g. with httr2:
  resp <- httr2::request("https://api.example.com/v1/embeddings") |>
    httr2::req_headers(Authorization = paste("Bearer", Sys.getenv("EMBED_KEY"))) |>
    httr2::req_body_json(list(model = "bge-small", input = texts)) |>
    httr2::req_perform()
  do.call(rbind, lapply(httr2::resp_body_json(resp)$data, \(d) unlist(d$embedding)))
}

corpus <- docling_embed(chunks, embed_api, batch_size = 64)
corpus
```

A local model works just as well -- anything that returns a matrix or a list of
numeric vectors:

```{r}
# Using a sentence-transformers model through reticulate
st <- reticulate::import("sentence_transformers")
model <- st$SentenceTransformer("BAAI/bge-small-en-v1.5")
embed_local <- function(texts) model$encode(texts)

corpus <- docling_embed(chunks, embed_local, batch_size = 64)
```

## 3. Retrieve

With embeddings in a matrix, retrieval is plain R. Embed the query the same way,
then rank chunks by cosine similarity:

```{r}
emb <- do.call(rbind, corpus$embedding)

cosine_top <- function(query, k = 5) {
  q <- as.numeric(embed_api(query))
  sims <- as.numeric(emb %*% q) /
    (sqrt(rowSums(emb^2)) * sqrt(sum(q^2)))
  corpus[order(sims, decreasing = TRUE)[seq_len(k)], c("text", "headings", "pages")]
}

cosine_top("What datasets were used for evaluation?")
```

For larger corpora, push the embeddings into a dedicated vector store instead of
an in-memory matrix.

## 4. Persist

Converting and embedding are the expensive steps. Save the corpus so you only
pay once:

```{r}
arrow::write_parquet(corpus, "corpus.parquet")
# later:
corpus <- arrow::read_parquet("corpus.parquet")
```

## Scaling to many documents

`docling_convert()` accepts a vector of sources and converts them in one batch.
Combine that with the chunk/embed steps to build a corpus over a folder:

```{r}
files <- list.files("docs", pattern = "[.](pdf|docx|html)$", full.names = TRUE)
docs  <- docling_convert(files)

corpus <- purrr::imap(docs, \(d, src) {
  docling_chunk(d, tokenizer = "BAAI/bge-small-en-v1.5", max_tokens = 512) |>
    docling_embed(embed_api, batch_size = 64) |>
    dplyr::mutate(source = src)
}) |>
  purrr::list_rbind()
```

That `corpus` -- chunk text, headings, pages, source and embeddings in one tidy
table -- is everything you need to power retrieval for an LLM.
