13 KiB
Ingestion pipeline
From "a user pastes a note" to "the note has evidence-backed relationships".
The governing rule: the original is preserved. Whatever a user supplies is stored verbatim before any parsing is attempted. If parsing later fails, the failure is recorded as a warning on the item and the original remains retrievable and reprocessable.
Overview
HTTP ──▶ ingest.Service.Ingest ──▶ items collection + parse_item job
│
┌──────────────────────────────────────────────────┘
▼
parse_item ─▶ chunk_item ─▶ generate_embeddings ─▶ extract_entities
│
discover_relationships ◀── extract_claims
Each stage is a separate job type, so progress is visible on the queue page and a failure is attributable to a specific stage. Stages hand off by enqueuing the next.
Stage 0 — Acceptance
internal/ingest/service.go
Before anything is written:
- Admission control. A semaphore bounds concurrent ingestion, because each in-flight request holds its content in memory.
- Size check against
RELATA_MAX_UPLOAD_BYTES. - Content-based type detection.
parsing.Sniffdecides from magic bytes. The browser'sContent-Typeand the filename are hints, never authorities.- PDF:
%PDF- - DOCX: a zip containing
word/document.xml— checked by reading the central directory - Archives (gzip, bzip2, xz, rar, 7z, plain zip): rejected
- Executables (ELF, PE, Mach-O,
#!): rejected - Text: must be valid UTF-8, no NUL bytes, under 2% control characters
- JSON: parses as a single well-formed value
- Markdown vs plain text: decided by filename or declared type, then by content signals
- PDF:
- Validation of title length, metadata keys and values, and the user-asserted creation date (a date far in the future is refused, since it would distort every temporal signal).
- Exact-duplicate detection. SHA-256 over the supplied bytes, looked up against
(workspace, content_hash). An identical item returns409withexisting_item_idunlessallow_duplicateis set, in which caseduplicate_ofrecords the link. - Original storage. Binary formats are written to
{upload_dir}/{workspace_id}/{item_id}.{ext}with mode0600. The filename is the item's own UUID and the extension comes from a fixed allow-list — the user's filename never influences the path. Text formats are kept inline on the item. - Item created, then
parse_itemenqueued.
If the declared type and the detected type disagree, a mime_mismatch warning is stored. It
may be innocent, or it may be an attempt to smuggle one format past a check for another; either
way it is visible.
Stage 1 — Parse
internal/parsing, handler in pipeline.go
Opens a processing run, then extracts text from the preserved original.
| Format | Approach | Retains |
|---|---|---|
| Plain text | Paragraph splitting on blank lines; RFC-822 headers recognised as a block | Paragraph offsets |
| Markdown | Line-based structural parse: headings, lists, fences, quotes, tables | Heading hierarchy as a section path |
| JSON | Depth-bounded streaming scan, then flattening to path: value lines, keys sorted |
Object boundaries as sections |
| Page by page; a page that fails is skipped with a warning | Page numbers | |
| DOCX | Streaming XML over word/document.xml; Word heading styles → structural headings |
Heading hierarchy |
Every block records byte offsets into the extracted text, and the invariant
block.Text == doc.Text[block.Start:block.End] holds. Every derived record's source
reference depends on that identity; it is asserted in unit tests and fuzz-tested.
Failure behaviour. A parse failure is not a job failure. The warning is recorded, the
item is marked failed, the original is untouched, and the job returns successfully — the job
did its work correctly, and retrying would only repeat the same failure. The user sees the
warning on the item and can reprocess after fixing the cause.
Quality warnings raised here:
low_extractable_text— a large file yielding almost no letters, usually a scan. Relata does not OCR, and says so rather than silently indexing nothing.truncated_text— output exceeded the configured limit; blocks past the cut are dropped and the straddling one clamped, so offsets stay valid.json_depth_exceeded— nesting or width beyond the limit.parse_failed— recorded per page for PDFs, or for the whole document.
Stage 2 — Chunk
internal/chunking
Structure first, character counts only as a ceiling.
Rules, in order:
- A document below
RELATA_CHUNK_MIN_SPLIT_CHARSbecomes one chunk. Splitting a two-sentence note produces fragments that embed worse than the whole. - A heading starts a new chunk, once the current one is large enough to stand alone.
- A page change (PDF) is a real boundary.
- A JSON object boundary is respected once the chunk has reached its target size.
- Adding a block that would exceed
RELATA_CHUNK_MAX_CHARSstarts a new chunk. - A single block larger than the ceiling is split internally, preferring blank lines, then sentence terminators, then line breaks, then word breaks.
- Chunks below
RELATA_CHUNK_MIN_CHARSare merged into a neighbour.
Overlap. Each chunk after the first extends backwards by up to
RELATA_CHUNK_OVERLAP_CHARS, aligned to a word boundary, so a sentence spanning a boundary is
retrievable from both sides. The chunk's Start moves back with it, so
chunk.Text == text[Start:End] still holds; OverlapChars records how much was borrowed.
Each chunk stores: parent item, sequence, source offsets, page, section, heading, text, content hash and (after embedding) the embedding model.
Chunk IDs are deterministic — derived from (workspace, item, sequence, content_hash) — so
re-chunking unchanged content reuses the same Qdrant point IDs and overwrites vectors rather
than orphaning them. Before writing new chunks, the item's existing vectors are deleted.
Stage 3 — Embed
internal/embeddings
- Chunks are embedded in batches of
RELATA_EMBED_BATCH_SIZE. - An AI semaphore is held for the duration of the provider call, so concurrent model usage
stays within
RELATA_AI_CONCURRENCYregardless of worker count. - Dimension validation. The first response fixes the width; a later mismatch is a permanent error, because writing differently-sized vectors into one collection would corrupt it. The error names the fix.
- Bounded retry. Only transport failures and explicitly transient HTTP statuses retry, with
exponential backoff and full jitter, within
RELATA_AI_MAX_RETRIES. A 400 or an unknown model never retries. Jitter matters because the worker pool dispatches in parallel: without it, a provider restart would be met by every worker retrying in lockstep. - Vectors go to Qdrant with the minimal payload; chunks are marked embedded with the model name.
- Document text is never logged.
Stage 4 — Entities
internal/entities
Two stages, separately labelled so a user can tell a matched pattern from a model's guess.
Deterministic (method = deterministic) — narrow by design, because a pattern that fires
on ordinary prose would flood the index and make shared-entity evidence meaningless:
- organisations ending in a legal suffix (
Ltd,PLC,GmbH,Inc, …) - people preceded by an honorific (
Dr,Prof,Ms, …) - email addresses
- dates in ISO,
14 March 2025,March 14, 2025and14/03/2025forms, normalised to ISO-8601 (day/month/year for the ambiguous numeric form, matching the application's British English conventions)
Model-assisted (method = model) — broader concepts, over a bounded sample of chunks.
Output is enum-validated per row; one bad row is skipped rather than invalidating the response.
A claimed evidence quotation that does not appear in the source is dropped, because a
fabricated quotation would break the promise that source references point at real text.
Resolution. Entities are keyed on (workspace, type, normalised_name) and merged only on
an exact key match. Normalisation is shallow — case folding, whitespace, punctuation,
apostrophes, diacritics — and deliberately does not strip legal suffixes: "Example Ltd" and
"Example Holdings Ltd" are different companies far more often than they are typos.
Names that look alike produce a candidate alias for a human to confirm, using a looser key plus token overlap, edit distance and acronym detection. Nothing is merged automatically.
Every mention carries item-relative byte offsets and the verbatim quote.
Stage 5 — Claims
A claim records that a source said something. It is not a fact, and it never loses its link to the original text.
Deterministic — sentences carrying a figure or a date, because those are the statements that can actually agree or conflict with another source. Sentence splitting knows common abbreviations, so "Ltd. reported" stays one sentence.
For each, Relata derives comparison keys:
- subject — the content word governing the figure ("cost" in "the total project cost is £42,000")
- numbers — currency- and separator-aware
- dates — normalised to ISO-8601
- polarity — +1 affirmative, −1 negated
Model-assisted — broader claims, with attribution and a quoted/paraphrased/inferred
nature. A claim whose quotation cannot be located in the source is discarded; whitespace
reflow is tolerated, because that locates the same span rather than inventing one.
Stage 6 — Discover relationships
internal/relationships/discover.go
Candidates never come from an all-pairs comparison. Three bounded, indexed sources:
- Vector neighbours — a sample of the item's chunks is embedded and searched, filtered to the workspace and excluding the item itself.
- Shared entities — an indexed aggregation over
entities.item_ids, ordered by how many entities are shared. Concepts are excluded as a source (too broad) while still contributing a signal. - Shared claim subjects — an indexed lookup on
claims.subject.
The union is capped at RELATA_REL_MAX_CANDIDATES before any scoring or model call.
Evidence gathering per candidate: shared entities (discounted by corpus frequency), concept overlap, temporal proximity, shared metadata, claim comparison and content-hash match.
Claim comparison is what makes "which sources disagree about the cost?" answerable without a language model: two claims about the same subject with materially different figures (beyond a 0.1% tolerance) or different dates are a contradiction, recorded with both quotations attached. Matching figures are support.
Scoring, then a bounded number of model classification calls — only for candidates that already have real evidence, so model time is not spent on noise. The model contributes one signal; a maximally confident opinion cannot on its own reach the storage threshold.
Storage. Candidates below RELATA_REL_STORE_SCORE are discarded. The rest are stored as
pending review with full evidence. Non-directional types are stored in a canonical endpoint
order so AB and BA are one relationship. A near-duplicate (similarity above
RELATA_NEAR_DUPLICATE_SIMILARITY) is flagged with an explicit note — never merged.
Failure and recovery
| Failure | Behaviour |
|---|---|
| Parse fails | Warning recorded, item marked failed, original preserved, job succeeds |
| Embedding provider down | Transient — retried with backoff, then the job fails and can be retried from the queue page |
| Model returns invalid JSON | Recorded with a reason, not retried; deterministic results are kept |
| Model unavailable | Entity and claim extraction degrade to deterministic only; the pipeline continues |
| Worker crashes | The lease expires and the job returns to the queue |
| Job exceeds its attempts | Marked failed; retryable manually |
| Item deleted mid-pipeline | Permanent failure — the stage stops rather than resurrecting derived data |
Reprocessing
RequestReprocess opens a new processing run and restarts the chain at parsing, with the
run ID in the job's idempotency key so it is never suppressed as a duplicate of the original
ingest.
Nothing multiplies:
| Records | Why not |
|---|---|
| Chunks | Replaced wholesale |
| Vectors | Deterministic IDs; the item's vectors are cleared before re-chunking |
| Claims | Replaced wholesale; unique dedupe key |
| Entities | Upserted on the normalised key; observations accumulate |
| Relationships | Upserted on the dedupe key — and a human review decision is never overwritten |
Previous processing runs are retained, recording the parser, embedding model, analysis model and scoring version that produced each generation. That is the audit trail for "what did the old parser make of this?"
Reprocess when a parser changes, the embedding model changes, the analysis model changes, or the relationship scoring algorithm changes. For an embedding-model change across the whole corpus, use Settings → Re-embed workspace, which processes in bounded batches and re-enqueues itself rather than holding one lease for hours.