File Processing¶
This document covers how files are handled in two contexts: chat file uploads, which belong to the person who made them, and RAG document ingestion, which belongs to a collection and is gated on who may reach it.
Chat File Uploads¶
When a user uploads a file in the chat interface, the following pipeline runs:
Flow¶
1. Upload POST /api/v1/files/upload
|
2. Validate Check MIME type against allowed list + enforce size limit
|
3. Classify Determine file_type: "image", "pdf", "docx", "text"
|
4. Parse Extract text content (images skip this step)
|
5. Store Save file to media/{user_id}/ via FileStorageService
|
6. Record Create ChatFile row in database
|
7. Link When message is sent, ChatFile is attached via message_id FK
|
8. Display Frontend shows images as thumbnails, documents as badges
Supported File Types¶
| Category | MIME Types | Extensions | Processing |
|---|---|---|---|
| Images | image/jpeg, image/png, image/webp, image/gif | .jpg, .png, .webp, .gif | Stored as-is. Sent to LLM as BinaryContent for vision analysis. |
| application/pdf | Text extracted via configured PDF parser. Appended to prompt as context. | ||
| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document | .docx | Paragraphs extracted via python-docx. Appended to prompt as context. |
| Text | text/plain, text/markdown | .txt, .md | UTF-8 decoded directly. Appended to prompt as context. |
Where an attachment goes depends on the agent¶
The "appended to prompt" column above is what happens to an agent with no workspace, and it is the whole file, on every turn. A two-hundred page report costs its full token weight when the user asks the first question and again when they ask "and what about March"; a fifty-megabyte CSV cannot be attached at all.
An agent with the sandbox capability
gets the file instead of the text:
| Attachment | No workspace | With a workspace |
|---|---|---|
| text, csv, md, json | parsed text pasted inline | written to /uploads/, message carries a reference and a 20-line head |
| pdf, docx | parsed text pasted inline | the original and a .txt beside it; reference |
| image | BinaryContent |
BinaryContent and written; reference names the path |
The reference is what the model actually reads:
Attached file: raport.csv (/uploads/3f2a1b9c-raport.csv, 2.4 MB, text)
First 20 lines:
month,total
jan,10
...
Enough to tell a sales export from a log and to see the column names — which is what the model needs in order to decide whether reading the rest is worth a tool call. The file has stopped being context and become data.
Four things about it are deliberate:
- Images go both ways. The model must still see the picture — that is what
a multimodal model is for, and a path string is not a substitute — and it must
also be able to resize or crop it, which needs bytes on a filesystem. Above
SANDBOX_INLINE_IMAGE_MAX_BYTESonly the file is kept, because past that point paying for the bytes twice stops being worth it. - A PDF gets both halves. The bytes are what a person asked to be given; the text this platform already extracted is the half a shell can actually read.
- The same file is written once. The path is derived from the
ChatFileid, so re-attaching it on turn five resolves to the path it already has — an upload costs one write, not one per turn for the rest of the conversation. - The filename is not trusted.
../../etc/passwdbecomesetc_passwd; two files calledreport.csvcannot overwrite each other.
A file that cannot be stored — a full workspace — falls back to the inline path rather than vanishing, and one the file store cannot load is skipped rather than failing the turn: the person asked a question, and answering without the attachment beats not answering.
Routing happens in app/services/attachments.py, called from the chat runner
rather than from each surface. It has to be there: where a file goes depends on
whether the agent has a workspace, and that is decided by prepare, which has
not run when a surface is assembling its prompt.
PDF Parsing (Chat)¶
Chat attachments are read with PyMuPDF, and that is not configurable. An attachment belongs to no collection, so there is no stored configuration to read a parser choice from.
The CHAT_PDF_PARSER variable that used to select between three parsers is
gone. Both alternatives were wrapped in except Exception: return
self._parse_pdf_pymupdf(data), so a deployment setting it to llamaparse or
liteparse had been silently using PyMuPDF anyway — and the LiteParse branch
could not have worked at all, calling a parse_async method the binding does
not define.
Size Limits¶
- Maximum file size:
MAX_UPLOAD_SIZE_MBenvironment variable (default: 50 MB) - The limit is enforced server-side after reading the file content.
Storage¶
Files are saved by FileStorageService to the media/ directory:
ChatFile Model¶
The ChatFile database model tracks uploaded files:
| Field | Type | Description |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID/FK | Owner (used for access control) |
filename |
String | Original filename |
mime_type |
String | MIME type (e.g. application/pdf) |
size |
Integer | File size in bytes |
storage_path |
String | Relative path in storage |
file_type |
String | Classified type: image, pdf, docx, text |
parsed_content |
Text | Extracted text content (NULL for images) |
message_id |
UUID/FK | Linked message (set when message is sent) |
created_at |
DateTime | Upload timestamp |
Ownership & Access¶
- Only the file owner can download their files (
GET /files/{id}). - The
FileUploadService.get_user_file()method compareschat_file.user_idagainst the requesting user's ID. ReturnsNotFoundErroron mismatch. - Ownership is the whole rule, and nothing widens it. No permission, no
organization role and no grant reaches another person's chat file through this
API — unlike a collection, which a grant can open up. The comparison is against
user_idand there is no second branch to hold a wider case.
RAG Document Ingestion¶
When documents are ingested into the RAG knowledge base (via CLI or API), a different pipeline handles parsing, chunking, and embedding.
Ingestion Flow¶
1. Input File path (CLI) or uploaded file (API)
|
2. Parse DocumentProcessor selects parser by file type
|
3. Chunk Text split into segments (configurable size/overlap/strategy)
|
4. Embed Chunks embedded via configured provider
|
5. Store Vectors written to vector database
|
6. Track RAGDocument record created in SQL (status tracking)
Over the API the order is the other way round: the RAGDocument row is written
first and steps 2–5 run in a background task against a session of their own,
which is why an upload answers {"status": "processing"} rather than waiting.
That task is started after the request's transaction commits — it is handed
over with spawn_after_commit, not spawn, and started by the session itself
once the row is durable. Dispatched any earlier it would look for the document
by id, find nothing, and stop, leaving the upload it had already acknowledged in
processing forever (#417).
The same applies to a sync: the SyncLog row exists before its flow does. See
Dispatching background work from a request.
Supported Formats¶
.txt, .md and .docx are read by the built-in Python parsers whatever the
collection's parser is. Beyond those, the set follows the parser:
| Parser | Also reads | Needs |
|---|---|---|
| PyMuPDF | .pdf |
nothing |
| LiteParse | .pdf; images (.png, .jpg, .tiff, .svg, …); office formats (.xlsx, .pptx, .odt, .csv, .rtf, …) |
LibreOffice for office formats only — images are converted natively |
| LlamaParse | .pdf, .pptx, .xlsx, .csv, .rtf, .epub, .html, images |
LLAMAPARSE_API_KEY |
The backend Dockerfile installs LibreOffice and Tesseract, so office formats and OCR work out of the box in a container. Running the backend outside Docker, an office upload to a LiteParse collection is refused with a message naming LibreOffice rather than failing during conversion.
GET /api/v1/rag/supported-formats?parser=liteparse answers for one parser.
These sets are what DocumentProcessor can actually route — pinned by
backend/tests/test_supported_formats.py, because they used to be aspirational:
a .xlsx was accepted, stored, given a document row and dispatched, and then
died in a worker as "Unsupported file type".
Parser Selection (RAG)¶
Per collection, on /rag, and overridable per upload — not an environment
variable. Stored on knowledge_bases.ingestion_config.
| Parser | Best For |
|---|---|
| PyMuPDF (default) | Fast local processing, text-heavy documents; the only one that extracts embedded images for description |
| LiteParse | Local, no key, layout-aware; reads office formats and images; markdown output |
| LlamaParse | Complex layouts and scanned PDFs; cloud, billed per page |
LiteParse options¶
| Setting | Default | Notes |
|---|---|---|
liteparse_output_format |
markdown |
Reconstructs headings, tables and lists — what the markdown chunking strategy splits on. text keeps the spatial grid. |
auto_ocr |
true |
Runs LiteParse's cheap text-layer check per document and OCRs only what needs it. OCR dominates the cost of a parse. |
ocr_language |
eng |
Tesseract codes — three letters, +-joined for several (eng+pol). A language with no pack installed reads nothing; add tesseract-ocr-<lang> to the Dockerfile. |
liteparse_dpi |
150 |
Higher reads faint scans, slower. |
max_pages |
1000 |
The setting that bounds the cost of one document; parse_timeout_seconds only bounds the wait. |
Chunking Configuration¶
Per collection, alongside the parser:
| Setting | Default | Description |
|---|---|---|
chunk_size |
512 |
Maximum characters per chunk |
chunk_overlap |
50 |
Characters of overlap; must be smaller than chunk_size |
chunking_strategy |
recursive |
Strategy: recursive, markdown, fixed |
Strategy comparison:
| Strategy | Best For |
|---|---|
recursive |
General text; splits by paragraph, then sentence, then word |
markdown |
Markdown/structured docs; splits at heading boundaries |
fixed |
Uniform chunk sizes; simplest but may split mid-sentence |
Embeddings — the model, and whose key pays¶
Embeddings go out through OpenRouter to an OpenAI embedding model. Both halves
of that call are decided per collection, not per deployment, by
app/services/embedding_resolution.py:
| Model and width | Recorded on the knowledge base at creation (embedding_model, embedding_dim) and never changed afterwards — PgVectorStore writes embedding vector(N) once, so a second model either cannot be written or is silently compared against vectors from another space. EMBEDDING_MODEL decides only what a new collection is built with. |
| Credential | The vault key chosen on the collection (embedding_secret_id), which is what the organization is billed for. A collection that chose none embeds on the deployment's OPENROUTER_API_KEY. |
The key is validated at creation — a key another organization holds, or one of the wrong purpose, is refused there, where the person choosing can fix it. At embed time nothing is refused: a chosen key that has since been deleted, cannot be unsealed, or does not hold an API key falls back to the deployment's, because whose key pays must never decide whether documents can be found.
That fallback is announced rather than assumed. The resolution carries which of the five sources it landed on, ingestion writes the degraded ones into the Prefect run's log, and a deployment with no key of its own fails with a message naming the collection and which key it tried — not with advice to set a variable about a collection that already had a key. Before #306 the ingestion worker was the one caller that never asked the resolver at all, so every uploaded document was embedded with the deployment's model and key whatever its collection had chosen.
Vector Storage¶
Vectors are stored in pgvector using the existing PostgreSQL database. No additional services needed.
One table per collection, created at runtime. The store issues CREATE TABLE IF
NOT EXISTS rag_<collection> the first time a collection is written to, so those
tables exist in the database and in nothing else — no model declares them and no
migration creates them, because a deployment holds as many as somebody has made
knowledge bases. Alembic does not own them, and alembic/env.py says so through
include_name: without that, make db-check read every one as a table the models
had dropped and failed on any database that had ever ingested a document. The
predicate lives in app/db/vector_tables.py, and it is narrower than the prefix on
purpose — rag_documents is a model table, and excluding it would have turned the
gate off for the one table ingestion writes through.
The store answers the same question with the same predicate: list_collections,
which is what rag-collections prints, reports a rag_ table only when no model
declares it. Matching the prefix alone had it reporting rag_documents as a
collection called documents — one nobody created, whose "vector count" was the
number of ingested documents, and which any caller could then ask to search.
What a collection may be called¶
A collection name is a string a caller chooses and the store builds identifiers
out of, so one function decides whether it is usable —
validate_collection_name in app/db/vector_tables.py. Four refusals, each a 400:
| Refused | Because |
|---|---|
Not a bare identifier — foo-bar, 2024_reports, anything with a space or a quote |
The store interpolates the name into DDL unquoted. A leading digit only looks safe: the rag_ prefix supplies the letter the name is missing. |
Any upper case — Handbook |
Postgres folds an unquoted identifier, so Handbook and handbook are one table. Nothing above the database can see it: names are compared as whole strings everywhere else, so the two are two rows the platform believes are two collections. Refused rather than lower-cased — storing a name the caller did not type is exactly the reinterpretation this rule exists to avoid. |
| Longer than 45 characters | Postgres keeps 63 bytes of an identifier and truncates the rest silently. rag_<name> fits at 59, but rag_<name>_embedding_idx does not, and the bound is the longest identifier — not the shortest. |
all |
Reserved. |
A table the models own — documents |
See below. |
Two of these are the same failure reached differently, and both are worth a
sentence. The length bound is the one that reads as pedantry and is not. Two collections
agreeing up to the truncation point are one object: one table if the name was
too long, so either organization's DROP destroys the other's vectors and every
search crosses between them; and one index if only the index name was, which is
quieter — CREATE INDEX IF NOT EXISTS finds the first collection's index already
there and builds nothing, leaving the second unindexed at whatever width the first
was built at. Nothing above the database can see either, because a collection name
is compared as a whole string everywhere else — which is also why case matters: a
spelling is a shorter road to the same shared table, and refusing upper case
closes a second one with it. _collection_exists compared rag_Handbook against
information_schema.tables, which stores the folded name, so it never matched and
search, get_documents and get_document_chunks answered empty for any
collection with a capital in it. That path is gone rather than fixed: such a name
is now refused where the table name is built, before anything can ask.
A collection may not be named after a table the models own, which is the
runtime-table predicate read a third way — asked of a name before its table exists.
Refused both at the API and in the store itself, because rag-drop <name> reaches
the store with no route in between. The name that made this necessary is
documents: prefixed, it is the tracking table, so dropping such a collection
aimed DROP TABLE IF EXISTS at every organization's ingestion history. The refusal
is derived rather than listed, so a rag_-prefixed model table added later is
covered, and a collection called documents_archive — which a literal exclusion
would have taken with it — is not affected.
And the name has to be free. The vector namespace is deployment-global: two
knowledge bases holding one collection name share one table, so a name already held
outside the caller's reach is refused with a 409 —
CollectionAccessService.claim, which POST /kb and POST /rag/collections/{name}
both call. Only one of them used to. POST /kb wrote whatever collection_name it
was sent, so a member with collections:edit could aim a knowledge base at another
organization's vector table and then read and write it through every gate
afterwards, because a collection resolves through whichever knowledge base the
caller can read — and now one of them is theirs. A name a caller does not supply
is derived from the display name plus six random hex characters, and is claimed on
the same path rather than trusted for being random.
documents was also the default collection, so the CLI quickstart used to aim
at the tracking table; the default is now default. A knowledge base created with
the old name before this landed still exists and is still deletable, but nothing
can be ingested into it — delete it and create one under another name. Nothing is
lost in doing so: an ingest into that collection has never succeeded, because
building the vector index on a table with no embedding column fails.
Who may reach a collection¶
Collections are not global, and nobody inside an organization is an "admin" for this purpose — there are no roles on a route here, only permissions (permissions).
A collection has two names. One is the vector table the chunks live in, which is a
string any caller can type into a URL; the other is the knowledge_bases row that
owns it, and only that row knows an organization. The row is the authority: every
/rag and /kb route resolves the name through it, in
app/services/collection_access.py, before touching a vector, a document or a sync
source. The listing and the per-resource routes read the rule from that one place,
because it was two copies of it — /rag/collections filtering by organization while
/rag/collections/{name}/info did not — that once let one tenant read another's.
Three scopes on the row, and no fourth:
| Scope | May read | May write |
|---|---|---|
personal |
its owner | its owner |
org |
collections:view reaching the row |
collections:edit reaching the row |
app |
anybody in the deployment | the deployment superadmin (is_app_admin) |
"Reaching the row" is resolve_access, the same decision every shareable resource
takes: the caller's scope for that permission, widened by any explicit grant on that
one collection. A grant widens what a role allows and never narrows it, so a Viewer
holding an explicit edit grant can manage that collection — the case a role gate
would refuse before ever looking. That is why the per-resource routes carry no
require(...) and hand the decision to the service instead.
What that buys, per operation:
Search — POST /rag/search, and the agent's retrieval tool |
collections:view. Every collection named is resolved before the first vector is read, and one the caller cannot reach refuses the whole search rather than being quietly dropped from it |
| Read — listing collections and documents, collection stats, a document's parsed text or original file, sync and ingestion logs | collections:view, and each answer holds only the collections that caller can reach |
| Write — creating and dropping a collection, uploading, ingesting, retrying, deleting a document, configuring or cancelling a sync source | collections:edit |
POST /rag/sync/local |
The one exception, and it keeps is_app_admin: its path names a directory on the server rather than anything a tenant owns, so opening it to collections:edit would hand every member a read of arbitrary server files, ingested into a collection they can then search |
A refusal is reported as "Collection not found", with the same message and details
an absent collection produces. Anything else turns the API into an oracle: these names
are derived from what people call their knowledge bases, so confirming that
acme_handbook_d1fac1 exists somewhere is already information.
Within a collection there is no per-document isolation. Access is decided at the collection, so reaching one reaches every document in it — which is the thing to weigh when deciding what to ingest where.
Document Tracking¶
Ingested documents are tracked in the SQL database via the RAGDocument model:
| Field | Description |
|---|---|
collection_name |
Target collection |
filename |
Original filename |
filesize |
File size in bytes |
filetype |
File extension (without dot) |
status |
processing, done, or error |
error_message |
What failed, if status is error — see below |
vector_document_id |
ID in the vector store |
chunk_count |
Number of chunks created |
storage_path |
Path to original file (for re-ingestion/download) |
created_at |
Ingestion start time |
completed_at |
Ingestion completion time |
Failed ingestions can be retried via POST /rag/documents/{id}/retry. It
re-reads storage_path — the copy the upload kept for exactly this — and
dispatches the parse again, replacing whatever the failed attempt indexed. A
document that did not fail, or that predates uploads keeping their file, is
refused with a 400 rather than moved to processing
(#441).
What a failed ingest says¶
error_message is a stored column, rendered on the documents page and in a
source's sync history to everyone who can see the collection. So it carries a
summary rather than whatever the client that failed happened to say:
The document could not be indexed (AuthenticationError) - check the
collection's embedding credential, then retry the upload. The worker log has
the full error.
Three parts, and each is there for a reason. The stage — parsing, indexing, recording the outcome, or a whole sync — is the one thing the reader cannot work out afterwards, and it separates a file this collection's parser does not read from a credential the provider refused. The exception's type is kept because a class name is a symbol: it says the credential was refused or the upstream timed out without naming the host that said so. The advice is what the reader can actually do.
One failure is reported by up to three handlers — the stage that raised, the
check that a returned failure is not done, and the flow's backstop — and the
first one to record it keeps the row, because it is the innermost and the
most specific. A retry clears the message, so the next attempt records its own.
A refusal this platform raised itself is passed through whole instead, because its message is written here and is the most useful thing to show: "No embedding credential is configured for this collection", "Organization monthly budget exhausted: $40.15 spent of $40.00 limit".
What is not stored is the failing client's own text. A provider SDK, httpx,
boto3 and the Google Drive client all put the request they were making into
their exception message, which routinely means an endpoint, an internal host, a
bucket, or a URL with a key in its query string — and unlike an HTTP error body,
a column is read again weeks later by anyone who opens the failed document. That
text is not lost: every one of these call sites logs it with logger.exception,
so the worker log has the message and the traceback, and a Prefect flow that
re-raises has both in its run. app/services/rag/failures.py is where the two
are separated.
The log is a smaller audience than the column, not a safe one — treat a worker log as something only operators read, and see #440 for why the redaction filter this deployment ships does not currently scrub it.
Sync Operations¶
Sync operations are tracked via the SyncLog model, recording source, mode,
total files, ingested/updated/skipped/failed counts, and timing. View sync
history via GET /rag/sync/logs.
One source's own history is GET /kb/{kb_id}/sync-sources/{source_id}/logs. The
source is resolved against that knowledge base first, so a source belonging to
another base answers 404 rather than an empty list — the two render the same
screen otherwise, and one of them is a request that should have failed. Its runs
are then read by source id, which is what keeps limit and total describing the
same set of rows: a source repointed at another base keeps its earlier runs under
the collection name it had then, and those used to be dropped from the page after
limit had already cut it.
What a sync source is not allowed to decide¶
A source's contents are not the deployment's to trust, and on a Drive folder
shared outside the organization they are not even the tenant's: sharing is what
folder sharing is for, so whoever can drop a file in one chooses the string
the next sync handles. Two of those strings used to be taken at face value, and
app/services/rag/remote_names.py is where both are now refused.
A file name is a label, not a path component. ../../../../home/app/.ssh/authorized_keys
is a legal Drive file name, and the connector wrote dest_dir / file.name
verbatim — outside the temporary directory the worker had made, wherever its uid
could write, and then ingested from there. The name is now reduced to its final
component and the result resolved and confirmed to be a child of the sync
directory, so .., its encodings, its lookalikes and a symlink already sitting
in the directory are one question rather than a list of spellings to keep up
with. A name that is no component at all — .., ., / — is refused; anything
else lands inside as one file. The destination is BaseSyncConnector's
answer, not a connector's: an implementation is handed a path and writes to it
(_fetch), which is what makes a connector added later inherit the refusal
rather than have to remember it.
A folder id reaches a query language. The Drive query wraps a parent id in
single quotes, so x' in parents or name contains 'salary is a well-formed,
wider query. A folder id is now checked against what Google can issue — letters,
digits, - and _ — where the query is built, which is the one funnel both the
configured folder and every sub-folder id pass through. validate_config
asks the same question, so a hostile value is answered by the route that
accepted it rather than by a sync log an hour later.
A Google Drive source runs on its own credential or not at all. The
connector used to fall back to GOOGLE_DRIVE_CREDENTIALS_FILE whenever
service_account_json was absent, which meant a tenant's folder id chose what
was listed under the operator's service account and whatever that account had
been shared. The fallback is gone; the setting now serves only the
rag-sync-gdrive CLI command, which an operator runs from their own shell.
Image Description¶
When processing documents that contain images, the system can optionally describe images using LLM vision capabilities. Image description is a per-collection setting: turn it on in the knowledge base's ingestion configuration and pick a vision-capable model profile there. The picker is the one the agent builder uses, so a provider, a model and its key can be defined without leaving the dialog — a deployment with no model profiles yet is not a dead end. What it does not offer is deleting a profile: that belongs where an organization's models are managed, because every agent pointed at one loses it. The generated descriptions are included in the document text for better semantic search.
From a channel¶
A file sent to a Slack, Telegram or Mattermost bot enters here, not beside here.
The adapter fetches it with the bot's own credential, it goes through the same
validation a browser upload does, and it becomes the same ChatFile row — so the
routing above applies unchanged and a channel cannot become the lenient path.
What differs is only what a refusal looks like: there is no form to show an error in, so a file that was too large or of an unsupported type is named in the bot's reply. See Channels.