Tools

The RAG generator.

Point it at a folder, an upload or a Google Drive folder. Watch the engine compile it. Then search the result and export it. A local, single-user workbench that turns "we have a shared drive full of documents" into a retrievable corpus you can interrogate before you trust it.

On this page

What it is

Everything the generator does is available through the engine API: submit a batch to /v1/jobs, poll it, read /v1/jobs/{id}/documents/{doc}/chunks, query /v1/search. What the generator adds is the loop around them, so the whole thing is something you can watch and judge rather than a sequence of curl commands whose output you have to hold in your head.

It is deliberately small and deliberately local: one browser tab, one user, no accounts, no database of its own beyond a JSON file recording which documents came from which folder.

Figure: the workbench on the Search tab. Every hit carries the file, page and section it came from.

How the pieces fit

The generator is a client. It ships no parser, no models and no index; it calls an engine you already run. That means three things have to be alive, and the second one catches people out:

Diagram: the generator only ever talks to your engine, and the engine only ever talks to itself.

Run it with Compose

One file brings up the engine and the generator together. The generator image is private to your organization; the engine image is public, and the license is the gate.

# docker-compose.yml - engine + RAG generator, one command.
# Pinned versions, not :latest, so a redeploy cannot change what you tested.
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: nautris
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?see .env}
      POSTGRES_DB: nautris
    volumes: ["pgdata:/var/lib/postgresql/data"]
    # api and worker wait for this, so it must report readiness honestly.
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U nautris -d nautris"]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    image: registry.nautris.com/nautris:${ENGINE_TAG:-0.9.20}
    restart: unless-stopped
    ports: ["8000:8000"]          # /ui and the API. Drop to expose nothing.
    environment:
      NAUTRIS_LICENSE: ${NAUTRIS_LICENSE:?see .env}
      NAUTRIS_DATABASE_URL: postgresql://nautris:${POSTGRES_PASSWORD}@postgres:5432/nautris
      NAUTRIS_RAG_DB: /data/lancedb   # without this, search answers 409
      NAUTRIS_WORKERS: "0"            # the api enqueues...
      NAUTRIS_BACKGROUND: "1"         # ...the workers process
      NAUTRIS_API_KEYS: "rag=${ENGINE_KEY:?see .env}"
    volumes: ["engine-data:/data"]
    read_only: true                 # writes go to the volume and /tmp only
    tmpfs: [/tmp]
    depends_on:
      postgres: { condition: service_healthy }

  # Scale the parsing tier:  docker compose up -d --scale worker=4
  worker:
    image: registry.nautris.com/nautris:${ENGINE_TAG:-0.9.20}
    restart: unless-stopped
    command: ["python", "-m", "nautris.worker"]
    environment:
      NAUTRIS_LICENSE: ${NAUTRIS_LICENSE:?see .env}
      NAUTRIS_DATABASE_URL: postgresql://nautris:${POSTGRES_PASSWORD}@postgres:5432/nautris
      NAUTRIS_RAG_DB: /data/lancedb   # must match the api
    volumes: ["engine-data:/data"]
    # Workers parse untrusted bytes: no privilege escalation, hard caps. A
    # single heavy document then kills only its own worker (exit 137) and
    # dead-letters, instead of taking the host down.
    security_opt: ["no-new-privileges:true"]
    mem_limit: ${WORKER_MEM_LIMIT:-8g}
    pids_limit: 256
    depends_on:
      postgres: { condition: service_healthy }

  rag:
    image: registry.nautris.com/nautris-rag-generator:${RAG_TAG:-0.5.2}
    restart: unless-stopped
    ports: ["3100:3100"]
    environment:
      # Fetched by the generator's SERVER, not your browser, so it must resolve
      # INSIDE this network. localhost would be the container itself.
      NAUTRIS_URL: http://api:8000
      RAG_DATA_DIR: /data
    volumes:
      - rag-data:/data              # projects, settings, uploaded workspaces
      # The folder you want to compile. Read-only is enough: the generator
      # never writes to your documents. Scan it as /docs in the UI.
      - ${DOCS_DIR:?see .env}:/docs:ro
    depends_on:
      api: { condition: service_started }
      # Without a worker every job stays queued forever, so depend on it.
      worker: { condition: service_started }

volumes:
  pgdata:        # Postgres data. Losing it loses job history.
  engine-data:   # extracted trees, the vector index, cached models
  rag-data:      # the generator's own project store and settings
# .env - sits next to docker-compose.yml. Compose reads it automatically,
# so it survives a new terminal and a reboot.

# ---- required: nothing starts without these --------------------------------

# Your signed license token. Free and instant at https://get.nautris.com
# The engine refuses to start without one.
NAUTRIS_LICENSE=nl1....

# The folder to compile, as an ABSOLUTE path on the host. Mounted read-only at
# /docs, so you scan "/docs" in the generator, never the host path.
DOCS_DIR=/srv/documents

# Postgres password. You invent it. Used twice: it sets the database password
# AND is interpolated into the engine's connection string, so one value keeps
# all three services consistent.
# Applied only when the pgdata volume is FIRST created; changing it later needs
# `docker compose down -v` or the api can no longer authenticate.
# Avoid @ : / # or generate it: openssl rand -hex 24
POSTGRES_PASSWORD=change-me

# The engine API key. You invent it. Registered as NAUTRIS_API_KEYS="rag=<this>"
# and pasted into the generator's Settings page.
ENGINE_KEY=change-me

# ---- optional: sensible defaults, override only if you mean it -------------

# Image versions. Pinned rather than :latest so a redeploy cannot change what
# you tested. These are the current releases; the minimum that works is engine
# 0.9.13 (the first with DELETE /v1/documents/{id}, which the generator needs to
# retire superseded documents on a re-scan) and generator 0.4.0.
ENGINE_TAG=0.9.20
RAG_TAG=0.5.2

# Per-worker memory ceiling. The full pipeline peaks past 4 GB on large or
# OCR-heavy PDFs. Must be <= the memory available to Docker.
WORKER_MEM_LIMIT=8g
In the app, use engine URL http://api:8000 and the key you set in ENGINE_KEY. The URL is fetched by the generator's server, so it must resolve inside the compose network, not in your browser.

Every variable, explained

VariableRequiredDefaultWhat it does
NAUTRIS_LICENSEYesnoneSigned license token. The only value you cannot invent: get one free at get.nautris.com. The engine fails to start without it.
DOCS_DIRYesnoneAbsolute host path to the folder you want to compile. Mounted read-only at /docs; scan /docs, not the host path.
POSTGRES_PASSWORDYesnoneYou invent it. Sets the database password and is interpolated into the engine's connection string. Applied only on first creation of the pgdata volume.
ENGINE_KEYYesnoneYou invent it. Becomes NAUTRIS_API_KEYS="rag=<key>" and is what you paste into the generator's Settings page.
ENGINE_TAGNo0.9.20Engine image version, currently 0.9.20. The minimum is 0.9.13, which added the single-document deletion that incremental re-scan needs.
RAG_TAGNo0.5.2Generator image version, currently 0.5.2. The minimum is 0.4.0, which introduced re-scan; 0.5.0 added the export bundle and Integrate tab.
WORKER_MEM_LIMITNo8gPer-worker ceiling. Extraction peaks past 4 GB on large or OCR-heavy PDFs; a worker that exceeds this is killed (exit 137) and its document dead-letters.
NAUTRIS_URLNohttp://localhost:8000Set in the compose to http://api:8000. Pre-fills the engine URL; whatever is saved in Settings wins.
RAG_DATA_DIRNo./dataWhere the generator keeps projects, settings and uploaded workspaces. Set to /data in the compose and backed by a volume.
NAUTRIS_RAG_DBNounsetEngine-side vector index path. Without it search answers 409 and the workbench is useless, which is why the compose always sets it.
NAUTRIS_MAX_BATCH_FILESNo200Client-side mirror of the engine's batch limit. Change only if you changed it on the engine.
NAUTRIS_MAX_UPLOAD_MBNo50Client-side mirror of the engine's per-file cap. If these drift, the app either rejects files the engine would take or sends files it will refuse.
The engine URL is resolved by the generator's server, not your browser. Inside Compose that means a service name (http://api:8000). localhost would refer to the generator's own container, and a browser-only hostname such as api.nautris.localhost will not resolve there either.

Which folders it can see

This is the single most common source of confusion, and it has a one-sentence rule: the generator scans its own filesystem. It does not upload from your browser and it cannot reach your engine's disk.

So in a container it sees exactly what you mount. Mount read-only; it never writes to your documents:

    volumes:
      - /srv/documents:/docs:ro     # then scan  /docs
      - /home/you/reports:/reports:ro  # then scan  /reports

If you would rather type any path on your machine with no mounting at all, run the generator directly instead of in a container, and point it at an engine that publishes a port:

npm install && npm run start     # http://localhost:3000
# engine URL: http://localhost:8000  (add  ports: ["8000:8000"]  to the api service)

The four steps

1. Connect

Enter the engine URL and an API key. The generator reports back the plan, days remaining, page quota position, queue depth, whether a vector index exists, and whether folder linking is included in your license. Everything that changes what you can do next is stated here rather than discovered as a failure three steps later.

The key is used server-side and kept in session storage only, so it disappears when you close the tab.

2. Documents

Pick a source, then read the report. Whichever source you use, the scan types every file by content, using the same magic-byte routing the engine applies at intake. A PDF named .txt is reported as a PDF, because that is what the engine will make of it.

Spreadsheets and CSV become tables, not prose. .xlsx and, from engine 0.9.14, .csv are compiled into table blocks, so the row and column relationship survives into the chunks. That matters more than it sounds: a price list flattened into a wall of numbers retrieves perfectly well and answers wrongly, because the figure ends up next to the wrong line item. CSV detection is deliberately conservative, since the format has no signature: prose that happens to contain regular commas is left as text rather than risking a mangled table.

Nothing reaches the engine yet, so this is the cheap moment to discover that half the selection is archives. Files are set aside with a reason:

Skipped asWhy
no readerThe engine has no reader for that content, so it would dead-letter.
too largeOver the per-file cap, NAUTRIS_MAX_UPLOAD_MB (50 MB by default).
duplicateByte-identical to another file in the folder. Documents are keyed by content, so both copies would collapse into one anyway.
emptyZero bytes.

Hidden folders, caches and dependency directories are skipped, and symlinks are never followed.

3. Compile

Files go up in batches of 200, the engine's own limit, and each document is reported as it settles: pages, blocks, chunks, how long it took, any flags raised for review, and the engine's exact reason for a failure. Stopping mid-run keeps everything already compiled, and re-running is safe because documents are keyed by content rather than by filename.

4. The RAG

Four tabs. Search opens first, because "is this any good?" is the question you actually arrive with. Chunks lists every indexed chunk with its provenance and a redaction toggle. Overview shows the shape of the corpus, review flags, and the documents that failed. Export takes it elsewhere.

Where documents come from

Three sources, one contract: each produces the same scan, so everything after it (compile, search, export) is identical. A source is a way of getting files onto the machine, never a separate flow.

SourceUse it whenWhere the files live
Server path The documents are already on the machine running the generator (or mounted into it). Untouched, in place. Nothing is copied.
Upload The documents are on your own computer, or arrive as a zip. Copied into a managed workspace under the generator's data directory.
Google Drive The documents live in Drive. Imported into a managed workspace, same as an upload.

Uploading folders and archives

Drag a folder and its subfolders come with it, structure preserved. You can also use the folder picker, drop one or more .zip archives, or hand over loose files. Uploads are sent in bounded batches with a progress bar, so a large folder is predictable rather than one enormous request.

Archives are extracted on the server, and treated as hostile input, because an archive is the classic way to attack an extractor:

The extraction report appears above the scan results, so the numbers before and after always reconcile.

Uploaded and imported files persist in the generator's data volume, which is what keeps "Compile again", re-scanning and reload-restore working for them. Manage or remove them in Settings > Data.

Settings

The gear icon in the header, or /settings. Stored server-side in RAG_DATA_DIR/settings.json, so choices hold across browsers and restarts rather than living in one browser's storage.

SectionWhat it controls
AppearanceDefault theme: System, Dark or Light. The header toggle still overrides per tab.
DefaultsThe engine URL the connect step pre-fills, and the retrieval mode and result limit the search tab opens with.
ScanningWhether to include hidden files and folders. Dependency and cache directories are always skipped and symlinks never followed, by design.
IntegrationsGoogle Drive.
DataEvery project with its source, counts, and the disk its uploads occupy.

Data is worth knowing precisely, because deletion means different things per source. Deleting an uploaded or Drive project removes its stored files and frees the disk. Deleting a server-path project removes only the generator's record: your folder is never touched. Workspaces orphaned by a cancelled upload are listed separately and can be swept, so disk cannot leak invisibly.

The API key is deliberately not a setting. It stays in the browser tab's session storage and disappears when the tab closes, rather than resting on disk.

Google Drive

Read-only import of a Drive folder tree. Google does not permit shipping shared OAuth credentials inside self-hosted software, so the connection uses your own OAuth client. It is a one-time setup of about five minutes:

  1. In Google Cloud Console, create or pick a project.
  2. APIs & Services > Library: enable the Google Drive API.
  3. OAuth consent screen: External, and add yourself as a test user.
  4. Credentials > Create credentials > OAuth client ID, type Web application. Register the redirect URI shown in Settings > Integrations.
  5. Paste the client id and secret into Settings, save, then Connect Google Drive.
The redirect URI depends on the address you open the generator with, so copy the one displayed in the browser you actually use. A mismatch here is the single most common setup failure, and Google reports it as redirect_uri_mismatch.

Once connected, the Drive source browses My Drive with a breadcrumb trail and previews what an import will do with each file before you start it. Importing takes the whole tree, subfolders included.

Google-native documents cannot be downloaded, only exported, so they are exported to the formats the engine reads natively:

Drive typeImported as
Google Docs.docx
Google Sheets.xlsx
Google Slides.pptx
Google Drawings.png

Types with no useful export (Forms, Sites) are skipped with a reason, and Google caps exports at roughly 10 MB, which is reported as a per-file skip rather than a failed import. Everything else downloads as-is.

The client secret and the OAuth tokens are write-only through the generator's API: they live in its data directory and are never sent to the browser. Disconnecting revokes the grant at Google and clears them locally.

Validating retrieval

The point of the Search tab is to let you disbelieve the corpus productively. Ask something you already know the answer to. If the right passage comes back with the right page, the corpus is sound. If it does not, two things usually explain why.

The mode matters. Semantic search finds meaning and tolerates different wording; keyword (BM25) finds exact terms, names, codes and identifiers that an embedding will happily miss; hybrid fuses both and is the best default. A term that returns nothing under one mode is often the top hit under another.

Scores are only comparable within one mode. Semantic is a cosine similarity from 0 to 1, keyword is BM25 and unbounded, hybrid is reciprocal-rank fusion and lands around 0 to 0.033. A hybrid top hit scoring 0.033 is the best match in the corpus, not a bad one, so the interface labels which scale is in play and never mixes modes in one list.

Provenance tells you where a hit came from. Every result carries its source file, page, section breadcrumb, the block ids it was built from and, for formats that have geometry, the bounding boxes. When retrieval surfaces something odd, that trail usually shows whether the problem is the query, the chunking, or the document itself.

The first search after the engine starts takes roughly fifteen seconds while the embedding model loads, then settles to a few hundred milliseconds. That pause is the model, not your corpus.

Re-scanning a folder

Scan a folder you have already compiled and the generator diffs it instead of rebuilding the RAG. Only new and edited files are sent, and the documents they replace are removed from the index, so the RAG matches the folder as it is now:

Re-sync: 2 new, 3 changed, 412 unchanged (skipped), 1 removed
Retired 4 superseded document(s).

The second half of that is the part worth understanding. The engine keys documents by content, so an edited file becomes a different document rather than a new version of the old one. Compiling it again indexes the new text but leaves the previous version searchable, and queries return both. The generator therefore un-indexes the superseded document as well, and it does so only after the replacement is compiled: if a run is interrupted, having both versions is recoverable by re-syncing, whereas deleting first and then failing to compile would lose the content outright.

Change detection is deliberately cheap. A file whose size and modification time both match what was compiled is never re-read. Anything else is hashed, and the bytes decide, so a file that was merely touched (a git checkout, an rsync -a) is correctly recognised as unchanged instead of being recompiled.

Linked folders

By default the generator uploads, and a re-scan is already incremental (see re-scanning a folder). If your license includes the sources feature, it can instead register the folder with the engine and let the engine pull it, so the engine itself tracks what changed:

new: 1   changed: 1   removed: 1   unchanged: 11

Two constraints, both surfaced in the interface rather than left to fail:

Exporting the RAG

A RAG you cannot move is a RAG you do not own. Everything is regenerated from the engine's stored trees at the moment you ask, using the same deterministic chunker that filled the index, so the same project always exports the same bytes.

FormatWhat it is for
chunks.jsonlOne chunk per line with full provenance. The payload: what you embed into your own vector store, and the provenance is what lets you cite a page later.
trees.jsonlThe complete CDOM per document, in case you want to chunk differently.
finetune.jsonlThe engine's own training-format export. Behaviour, not retrieval.
rag-bundle.zipAll three plus manifest.json and a generated README.md. The format to hand to a colleague, and the one described below.

The bundle is self-describing

From generator 0.5.0 the zip carries two files whose only job is to make the other three usable by someone who has never seen nautris. A bare chunks.jsonl forces the recipient to reverse-engineer the payload before they can act on it, which is how good corpora end up unused.

manifest.json declares a spec version, so a consumer can refuse a bundle it does not understand rather than guess:

{
  "bundle": "nautris-rag-bundle/v1",
  "kind": "rag-ready-corpus",
  "files": { "chunks.jsonl": "One chunk per line. The retrievable unit...", ... },
  "chunk_schema": {
    "provenance.section_path": "Heading trail, e.g. ['Contrat','5. Maintenance']...",
    "provenance.pages": "Source page numbers this chunk covers. Cite these.",
    ...
  },
  "embeddings": { "included": false, "reason": "Chunks ship as text so you choose your own model..." }
}

README.md is generated per project and opens with the distinction that matters: this is a RAG-ready corpus, not a running RAG. Your documents are parsed, structured, chunked and carry the provenance needed to cite them, which is the expensive half. You supply the embedding model, the store and the retrieval, and nothing in the bundle needs nautris at runtime.

Why no vectors. The chunks ship as text on purpose. Embedding is the one decision teams have real opinions about (language mix, cost, an existing store), and shipping vectors would pin every consumer to the engine's default model. Embedding a corpus is one script; being locked to the wrong model is not.

Using the corpus in your application

The Integrate tab answers the question the Export tab leaves open. It gives complete, copy-ready code for four destinations, pre-filled with that project's own document and chunk counts, because a generic example gets read while one carrying your numbers gets pasted and run.

DestinationWhat you get
pgvectorDDL with an HNSW cosine index, plus a GIN index so keyword-only users need no embeddings at all. Loader and query included.
PythonNo database: load the corpus in memory and search it. The fastest way to prove a corpus is good before committing to a store.
TypeScriptStreams the JSONL, so a large corpus never lands in memory at once. Includes a citation helper.
LangChainMaps each chunk to a Document, so any LangChain retriever, chain or LangGraph agent consumes it unchanged.

Every snippet carries source_file, pages and section_path through to the destination. Those fields cost nothing to store and cannot be rebuilt afterwards, and they are the difference between an answer that cites "contrat.pdf, section 5.2, page 3" and one that says "according to my documents".

One rule for your own pipeline. Whatever embedding model you choose, embed your queries the same way you embedded the passages: same model, same normalization, same prefix convention. A mismatch raises no error, it just returns worse results. The engine's own search is not exposed to this, since it embeds both sides with one model and refuses to mix embedding spaces; it becomes your responsibility only once the corpus leaves.

Troubleshooting

SymptomCause and fix
Compile sits at queued and never moves No worker is running. The api enqueues but does not parse. Start the worker service.
Search fails with 409 The engine has no vector index. Set NAUTRIS_RAG_DB on both api and worker, restart, and re-compile.
Cannot reach the engine for a URL that works in your browser The URL is fetched by the generator's server. Hostnames ending in .localhost resolve in browsers but not in Node. Use a published port or the in-network service name.
The folder scan reports "No such folder" The generator can only see its own filesystem. In a container, mount the folder and scan the mounted path.
Folder linking returns 402 The plan does not include sources. Upload instead, which works on every plan.
The first search takes about fifteen seconds Expected: the embedding model loads on first use, then stays warm.
Google returns redirect_uri_mismatch The URI registered in Google Cloud differs from the one the generator sends. Copy it from Settings > Integrations in the browser you actually use, character for character.
Drive connects, then later says access was revoked The refresh token was withdrawn (at myaccount.google.com/permissions, or by changing the OAuth client). Reconnect in Settings.
A Google Doc was skipped as too large Google caps exports at roughly 10 MB. Split the document, or download and upload it in a binary format.
An uploaded zip stayed in the file list instead of extracting It was nested deeper than one archive inside another, was encrypted, or exceeded the extraction budget. The scan reports which, and the archive is left intact.
Next: browse the whole engine surface in the API explorer, or read how the pipeline works in the overview.