Document Loaders
Get documents from files, web pages and S3 into a RAG pipeline, and keep the index in step with them.
Table of contents
- Overview
- The DocumentLoader contract
- Loaders
- Composing loaders
- Feeding a loader to RAG
- Safety
- See also
Overview
llm4s-rag reads documents through one trait, DocumentLoader, in org.llm4s.rag.loader. A
loader says where documents come from; RAG decides what to do with them: chunk, embed and store
them (ingest), update only what changed (sync), or rebuild the index (refresh).
| Loader | Reads | Package |
|---|---|---|
TextLoader |
Strings or Documents you already hold |
org.llm4s.rag.loader |
FileLoader |
One file, extracted with Tika (text, Markdown, HTML, PDF, DOCX, …) | org.llm4s.rag.loader |
DirectoryLoader |
Every matching file under a directory | org.llm4s.rag.loader |
UrlLoader |
A list of HTTP(S) URLs | org.llm4s.rag.loader |
WebCrawlerLoader |
Pages reached by following links from seed URLs | org.llm4s.rag.loader |
S3Loader |
Objects in an S3 bucket (or LocalStack, MinIO) | org.llm4s.rag.loader.s3 |
The samples in
modules/samples/src/main/scala/org/llm4s/samples/rag/
show each of them running: DocumentLoaderExample, WebCrawlerExample and S3LoaderExample.
The DocumentLoader contract
1
2
3
4
5
6
7
8
import org.llm4s.rag.loader.LoadResult
trait DocumentLoader {
def load(): Iterator[LoadResult]
def estimatedCount: Option[Int] = None
def description: String
def ++(other: DocumentLoader): DocumentLoader
}
load() returns an iterator, so a large source is streamed one document at a time, and it yields
one LoadResult per document rather than failing as a whole, so one unreadable file does not stop
the rest from loading.
Document
A loaded document is a Document:
| Field | Meaning |
|---|---|
id |
Stable identifier. sync matches documents across runs by it: FileLoader uses the absolute path, UrlLoader the URL. |
content |
The extracted text |
metadata |
Map[String, String]: source, path, url, content_type, … depending on the loader, plus your own |
hints |
Optional DocumentHints: a suggested chunking strategy and config, batch size, priority, a skip reason |
version |
Optional DocumentVersion: SHA-256 contentHash, plus a timestamp and an HTTP etag where the source has them |
DocumentHints has presets for common content: DocumentHints.markdown, DocumentHints.prose,
DocumentHints.code, DocumentHints.rateLimited(batchSize) and DocumentHints.skip(reason).
FileLoader picks one from the file extension (Markdown, code, otherwise prose).
LoadResult
| Result | Meaning |
|---|---|
LoadResult.Success(document) |
The document was loaded. |
LoadResult.Failure(source, error, recoverable, documentId) |
One document could not be read: a missing file, a failed extraction, an HTTP error. |
LoadResult.Skipped(source, reason) |
The loader chose not to load it: blocked by robots.txt, a content type the crawler does not accept, a page with no text. |
LoadResult.ListingFailure(source, error) |
The loader could not enumerate its documents at all: a directory that does not exist, an S3 listing with no credentials or a missing bucket. |
Why a failure names its document. source is a path or a URL, not necessarily the id the
document is indexed under, so a Failure can also carry documentId. sync uses it to tell
“this document is still there but could not be read this time” from “this document is gone”:
- A
Failurewith adocumentIdkeeps that document’s indexed version - it is not deleted and not counted as changed. The next sync that reads it picks up any change.FileLoadersets it when a file exists but cannot be extracted;UrlLoadersets it for every failed URL (the URL is the id); the S3 loader sets it when an object’s read or extraction fails. - A
Failurewithout adocumentIdcould be about any document, sosynccannot tell which unlisted documents are really gone: it skips its deletion pass for that run and logs a warning. Adds and updates still apply. - A
ListingFailureis not one failed document among successes: the set of documents is unknown.ingest,syncandrefreshreturn its error as aLeft,syncdeletes nothing, andrefreshleaves the index as it was.
Loaders
TextLoader
For content you already have in memory: database rows, API responses, test fixtures.
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.rag.loader.*
val fromPairs = TextLoader.fromPairs(
"doc-1" -> "Scala is a programming language that combines OOP and FP.",
"doc-2" -> "LLM4S is a framework for building LLM-powered applications in Scala."
)
val built = TextLoader
.builder()
.add("faq-1", "How do I install it? Add the sbt dependency.", Map("section" -> "faq"))
.add(Document("faq-2", "Which JDK? 21."))
.build()
TextLoader("content", "id"), TextLoader.fromMap(...) and TextLoader.fromContents(...) (random
ids) are also available. Every document gets a content-hash version, so sync works with it too.
Use stable ids if you intend to sync: a random id makes every run look like new documents.
FileLoader
One file, extracted with Apache Tika, so it reads plain text, Markdown, HTML, JSON, XML, PDF and Word documents.
1
2
3
import org.llm4s.rag.loader.FileLoader
val readme = FileLoader("./README.md", Map("project" -> "llm4s"))
The document’s id is the file’s absolute path, its version is the content hash plus the file’s
last-modified time, and its metadata holds source (the file name), path, extension,
lastModified and size, merged over yours. A missing path or a directory gives a Failure with
no documentId; a file that exists but cannot be extracted gives a Failure naming it.
DirectoryLoader
Every file under a directory whose name ends in one of extensions, each loaded with FileLoader.
1
2
3
4
5
6
7
8
9
import org.llm4s.rag.loader.DirectoryLoader
val docs = DirectoryLoader("./docs")
.withExtensions(Set("md", "pdf"))
.withRecursive(true)
.withMaxDepth(3)
.withMetadata(Map("collection" -> "handbook"))
val markdownOnly = DirectoryLoader.markdown("./docs")
| Option | Default |
|---|---|
extensions |
DirectoryLoader.defaultExtensions: txt, md, markdown, pdf, docx, json, xml, html, htm, rst |
recursive |
true |
maxDepth |
10 (0 is the directory itself only) |
metadata |
empty; each document also gets directory (the directory’s name) |
DirectoryLoader.markdown, .code, .text, .pdf and .docx preset the extensions. A
directory that does not exist, or a path that is not a directory, is a ListingFailure.
UrlLoader
Fetches each URL once, with HttpURLConnection.
1
2
3
4
5
6
7
8
9
10
11
import org.llm4s.rag.loader.UrlLoader
import org.llm4s.security.Secret
import scala.concurrent.duration.*
val pages = UrlLoader(
"https://example.com/guide.md",
"https://example.com/faq.html"
).withTimeout(10.seconds)
.withHeaders(Map("Accept-Language" -> "en"))
val privateDocs = UrlLoader.withAuth(Seq("https://docs.example.com/internal.md"), Secret("token"))
| Option | Default |
|---|---|
headers |
none; User-Agent is LLM4S-RAG/1.0 unless you set it (UrlLoader.withUserAgent) |
timeout |
30 seconds, for connecting and for reading |
metadata |
empty; each document also gets source, url, content_type, and etag / lastModified when the server sends them |
retryCount |
2; stored on the loader (withRetries), but load() does not currently retry - a failed URL is a Failure for that run |
The document’s id is the URL. Its version is the content hash with the response’s ETag and
Last-Modified, when present. Up to 5 redirects are followed, each checked as described in
SSRF protection; any status other than 200 after redirects is a Failure.
toString masks credential-named headers such as Authorization, so a loader can be logged.
WebCrawlerLoader
Starts from seed URLs and follows links breadth-first, converting each HTML page to text.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.llm4s.rag.loader.{ CrawlerConfig, WebCrawlerLoader }
import scala.concurrent.duration.*
val crawler = WebCrawlerLoader(
Seq("https://docs.example.com/"),
CrawlerConfig()
.withMaxDepth(2)
.withMaxPages(200)
.withFollowPatterns("docs.example.com/*")
.withExcludePatterns("*/archive/*")
.withDelay(1.second)
)
val onePage = WebCrawlerLoader.singlePage("https://example.com/about")
CrawlerConfig options:
| Option | Default | Meaning |
|---|---|---|
maxDepth |
3 |
Link depth from the seeds; 0 fetches only the seeds |
maxPages |
1000 |
Pages to fetch in total |
followPatterns / excludePatterns |
* / none |
Glob patterns (* wildcards) a URL must match / must not match |
respectRobotsTxt |
true |
See robots.txt |
delay |
500.millis |
Pause between requests; a longer robots.txt Crawl-delay wins |
timeout |
30.seconds |
Connect and read timeout |
userAgent |
LLM4S-Crawler/1.0 |
Sent with every request, and used to pick robots.txt rules |
maxQueueSize |
10000 |
Cap on queued URLs |
includeQueryParams |
false |
Whether URLs that differ only in their query string are different pages |
sameDomainOnly |
true |
Only follow links to the seeds’ domains (and their subdomains) |
acceptContentTypes |
text/html, application/xhtml+xml |
Other content types are Skipped |
Presets: CrawlerConfig.default, CrawlerConfig.polite (depth 2, 100 pages, 1 second delay),
CrawlerConfig.fast (depth 5, 5000 pages, 100 ms delay) and CrawlerConfig.singlePage.
WebCrawlerLoader.forDocs(url) uses polite. estimatedCount is None: the size of a crawl is
unknown until it ends. A crawled page that fails is a Failure without a documentId, because the
pages it links to were never seen, so a sync with a crawler skips its deletion pass for that run.
S3Loader
Objects in a bucket, extracted like files. It needs the AWS SDK, which llm4s-rag already depends
on.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.llm4s.rag.loader.s3.S3Loader
import org.llm4s.security.Secret
// The AWS default credential chain
val bucketDocs = S3Loader("my-bucket", prefix = "docs/", region = "eu-west-1")
// Explicit keys
val withKeys = S3Loader.withCredentials(
bucket = "my-bucket",
accessKeyId = Secret("AKIA..."),
secretAccessKey = Secret("..."),
region = "eu-west-1",
prefix = "docs/"
)
// LocalStack on localhost:4566, credentials test/test
val local = S3Loader.forLocalStack("test-bucket")
credentials is an Option[AwsCredentialsProvider]. When it is not set, the client uses the AWS
SDK’s DefaultCredentialsProvider: environment variables (AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY), system properties, the ~/.aws/credentials file, then an instance or
container role. If none of them yields credentials, or the bucket does not exist, listing fails and
the loader reports a ListingFailure, so sync deletes nothing.
region defaults to us-east-1. extensions defaults to S3DocumentSource.defaultExtensions
(txt, md, markdown, pdf, docx, doc, json, xml, html, htm, csv, rst). For
another endpoint, such as MinIO, configure the source and wrap it:
1
2
3
4
import org.llm4s.rag.loader.SourceBackedLoader
import org.llm4s.rag.loader.s3.S3Loader
val minio = SourceBackedLoader(S3Loader.source("my-bucket", "docs/").withEndpoint("http://minio:9000"))
Each document’s version carries the object’s ETag alongside its content hash.
Composing loaders
++ concatenates two loaders; DocumentLoaders has the rest.
1
2
3
4
5
6
7
8
9
10
11
import org.llm4s.rag.loader.*
val handbook = DirectoryLoader.markdown("./handbook")
val faq = TextLoader.fromPairs("faq-1" -> "Ask in #help.")
val all = handbook ++ faq ++ UrlLoader("https://example.com/changelog.md")
val tagged = DocumentLoaders.withMetadata(all, Map("tenant" -> "acme"))
val prose = DocumentLoaders.withHints(tagged, DocumentHints.prose)
val small = DocumentLoaders.filter(prose)(_.length < 100000)
val first = DocumentLoaders.take(small, 50)
val trimmed = DocumentLoaders.map(first)(doc => doc.copy(content = doc.content.trim))
| Combinator | Does |
|---|---|
combine(loaders) |
Loads from each loader in turn (what ++ uses) |
map(loader)(f) / filter(loader)(p) |
Transforms / keeps successfully loaded documents; failures pass through |
withMetadata(loader, map) / withHints(loader, hints) |
Adds metadata / merges hints into every document |
take(loader, n) / drop(loader, n) |
Limits / skips the first n results |
successesOnly(loader) |
Drops Failures and Skippeds; keeps a ListingFailure |
fromDocuments(docs) / fromIterator(iter) / defer(loader) / empty |
Builds a loader from documents, a lazy iterator, or a by-name loader |
successesOnly also drops what sync needs to keep an unreadable document’s indexed version, so
sync then deletes such a document as if it were gone from the source. Sync the unwrapped loader
to keep it.
Feeding a loader to RAG
A loader can be passed at build time, with RAG.builder().withDocuments(loader), or to a built
RAG:
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.rag.RAG
import org.llm4s.rag.loader.*
import org.llm4s.types.Result
def index(rag: RAG): Result[Unit] =
for {
loaded <- rag.ingest(DirectoryLoader("./docs"))
_ = println(s"${loaded.successful}/${loaded.totalAttempted} loaded, ${loaded.failed} failed")
_ = if (loaded.hasErrors) println(loaded.formattedErrors)
synced <- rag.sync(DirectoryLoader("./docs"))
_ = println(s"+${synced.added} ~${synced.updated} -${synced.deleted} =${synced.unchanged}")
} yield ()
| Method | Returns | Does |
|---|---|---|
ingest(loader) |
Result[LoadStats] |
Chunks, embeds and stores every loaded document |
sync(loader) |
Result[SyncStats] |
Adds new documents, re-ingests changed ones, removes ones the source no longer lists, leaves the rest |
refresh(loader) |
Result[SyncStats] |
Clears the index and re-ingests everything (all counted as added) |
Each has an ...Async form (ingestAsync, syncAsync, refreshAsync) returning a
Future[Result[...]].
LoadStats reports totalAttempted, successful, failed, skipped and errors (source and
error for each failure), with successRate, hasErrors and formattedErrors. A failed document is
counted, not returned as a Left, unless the RAG was configured with failOnLoadError(true).
SyncStats reports added, updated, deleted and unchanged, with total, changed and
hasChanges.
How sync skips unchanged documents. sync keeps a registry of the version each document id
was indexed at. For each loaded document it takes the loader’s version (or a content hash, if the
loader set none) and compares its contentHash with the registry’s: an unknown id is added, a
different hash is re-ingested (its chunks replaced), the same hash is left alone. A timestamp or
ETag change with the same content does not count as a change. After the loader is exhausted, ids
in the registry that the loader did not produce are deleted - unless the run had a Failure
without a documentId (deletion is skipped) or a ListingFailure (sync stops and returns the
error). sync streams the loader, holding one document at a time; refresh holds every loaded
document in memory before clearing the index, so that a listing failure leaves the index untouched.
For a very large source prefer sync.
Safety
SSRF protection
UrlLoader and WebCrawlerLoader check every URL with NetworkSecurity.validateUrl (in
org.llm4s.core.safety) before connecting, and again for every redirect target. Only http and
https are allowed, and a host that resolves to a non-public address is refused: loopback
(localhost, 127.0.0.0/8, ::1), private networks (10/8, 172.16/12, 192.168/16,
fc00::/7), link-local addresses including the cloud metadata endpoint 169.254.169.254, and the
other special-purpose ranges. Redirects are followed by the loader itself, not by
HttpURLConnection, so a public URL cannot redirect into the internal network: such a redirect
fails with “Redirect blocked by SSRF protection”.
This means neither loader can read from localhost or a private network. For internal documents,
read them with FileLoader/DirectoryLoader or S3Loader, or fetch them yourself and pass them
in with TextLoader.
robots.txt
With respectRobotsTxt = true (the default), WebCrawlerLoader fetches /robots.txt once per
scheme, host and port, caches the parsed rules for an hour, and uses the group for its userAgent
(the longest matching User-agent, else *). A disallowed page is Skipped with
“Blocked by robots.txt” and its links are not followed. The longest matching Allow/Disallow
rule wins, and Allow wins a tie; * and $ patterns are supported. A robots.txt that cannot be
fetched, or answers anything other than 200, allows everything. A Crawl-delay longer than
CrawlerConfig.delay replaces it.
UrlLoader does not read robots.txt: it fetches exactly the URLs you give it.
See also
- Vector Store - the stores and search behind
RAG - Embeddings - choosing the embedding provider
RAGuses - Permission-Based RAG - ingesting with access control
- Web crawler design note - how the crawler was designed