24 KiB
Relata
A self-organising semantic document database.
Relata accepts unstructured and semi-structured information, analyses it, discovers evidence-backed relationships between stored items, and lets you search associatively — returning an explanation of why each result came back.
The central idea: store arbitrary information, discover evidence-backed relationships between items, and retrieve related knowledge with an explanation of why each result was returned.
What Relata does
- Ingests plain text, Markdown, JSON, PDF and DOCX — pasted, uploaded, or posted to a JSON API. The original is always preserved verbatim.
- Extracts text while keeping headings, paragraphs, page numbers and document sections.
- Chunks documents along structural boundaries rather than at arbitrary character counts.
- Embeds chunks with a local model and stores the vectors in Qdrant.
- Extracts entities and claims in two stages: deterministic rules first, then model-assisted extraction for broader concepts. Every record says which produced it.
- Discovers candidate relationships between items from a bounded candidate set, and scores them from named, inspectable signals.
- Searches with hybrid retrieval — exact matching, MongoDB text search, vector similarity, entity matching and relationship expansion — and explains every result.
- Answers questions from stored material only, with citations, an explicit "Sources used" section, and an "Uncertainty and conflicts" section when sources disagree.
- Keeps humans in charge: every inferred relationship is a proposal until someone confirms it, and rejecting one never deletes the evidence behind it.
What Relata does not do
These are deliberate limits, not oversights.
- It does not decide what is true. A claim records that a source said something. When two sources disagree, Relata shows both and says they conflict; it never picks a winner.
- It does not treat similarity as a relationship. Two documents can be near-identical in wording and describe entirely different events. Semantic similarity alone tops out well below the threshold at which a link looks well-evidenced.
- It does not produce calibrated probabilities. A relationship's strength is a weighted sum of explicit signals under a versioned scoring model. It is a heuristic strength, and the interface says so wherever it appears.
- It does not merge entities on name similarity. "Example Ltd" and "Example Holdings Ltd" stay separate. Similar names become candidate aliases for a person to confirm — and even a confirmed alias does not merge the records; it becomes one weighted signal among many.
- It does not ask a language model for a score. A model may classify a relationship type or describe a connection; that contributes exactly one signal among many.
- It does not OCR. A scanned PDF with no text layer is stored and preserved, and Relata tells you its contents are not searchable.
- It does not accept archives (zip, tar, gz, rar, 7z) or executables.
- It does not fetch URLs found inside documents. Nothing in a stored document can cause Relata to make an outbound request.
- It is not multi-tenant yet. Every record carries a workspace ID and every query filters on it, so the boundary is enforced from day one — but there is one workspace in practice.
Architecture overview
A modular monolith with in-process background workers. One binary, three data stores.
┌──────────────────────────────────────────┐
browser ────────▶│ HTTP: server-rendered HTML + /api/v1 │
API client ─────▶│ auth · CSRF · CSP · rate limits │
└───────────────┬──────────────────────────┘
│
┌───────────────▼──────────────────────────┐
│ Services: ingest · search · relationships│
│ entities · analysis │
└───────────────┬──────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ MongoDB │ │ Qdrant │ │ Ollama │
│ authoritative: │ │ vectors + minimal│ │ embeddings + │
│ items, chunks, │ │ retrieval payload│ │ analysis │
│ entities, claims,│ │ │ │ (replaceable) │
│ relationships, │ │ │ │ │
│ jobs, audit │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
▲
│
┌─────────┴──────────────────────────────────────────────────┐
│ Background workers: leased job queue in MongoDB │
│ parse → chunk → embed → entities → claims → relationships │
└────────────────────────────────────────────────────────────┘
internal/domain holds the types and port interfaces and imports only the standard library.
Storage and AI adapters implement those ports; the service layer never sees a MongoDB, Qdrant
or HTTP type. See docs/ARCHITECTURE.md.
Prerequisites
- Go 1.26.5 or newer — earlier 1.26 patches carry an
html/templateXSS fix Relata needs - Docker and the Compose plugin, for MongoDB, Qdrant and Ollama
- ~8 GB free disk for the Ollama models
- Linux or macOS. Windows via WSL2.
Ollama can run either in Compose or natively on the host — Relata only needs an HTTP endpoint.
Exact local startup
# 1. Fetch dependencies and create .env from the example
make setup
# 2. Start MongoDB, Qdrant and Ollama
make compose-up
# 3. Pull the models (a few GB, once)
make models
# 4. Create the administrator account (prompts for a password, no echo)
make createadmin
# 5. Run Relata
make dev
Then open http://127.0.0.1:8080 and sign in.
To confirm everything is healthy before signing in:
curl -s http://127.0.0.1:8080/health/ready | jq
mongodb and qdrant must report ok. The AI providers are reported but not required —
ingestion, keyword search and browsing all work without them.
Initial administrator creation
relata createadmin --username admin
You are prompted for a password twice, without echo. The minimum length is 12 characters; length is what matters, so a passphrase is fine. The password is hashed with Argon2id (64 MiB, 3 passes) and the plain text is never stored, logged or echoed.
For unattended setup, set RELATA_ADMIN_PASSWORD in the environment instead. The password
is deliberately not accepted as a command-line flag, because arguments are visible in
the process list and shell history.
Signing in through an identity provider
Relata can sit behind OAuth 2.0 / OpenID Connect. The redirect URI to register is derived from
RELATA_BASE_URL and a fixed path:
<RELATA_BASE_URL>/auth/oauth/callback
The path is not configurable, so what you register and what Relata serves cannot drift apart.
RELATA_OAUTH_ENABLED=true
RELATA_OAUTH_PROVIDER_NAME=Blackwall
RELATA_OAUTH_ISSUER=https://www.blackwall.cx # or the three endpoints directly
RELATA_OAUTH_CLIENT_ID=...
RELATA_OAUTH_CLIENT_SECRET=...
Each person who signs in gets a private workspace of their own and is admin of it. Nobody sees anyone else's documents — which also means relationship discovery never spans users. That trade-off is the point of ADR 8.
Local password login switches itself off once OAuth is configured, and back on if it is not.
Set RELATA_LOCAL_LOGIN_ENABLED=true to keep it as a way back in when the provider is broken;
relata createadmin works regardless. A federated account has no password and can never sign
in locally.
Startup refuses a configuration with no way in at all, and refuses non-HTTPS provider endpoints outside development.
Full detail: docs/AUTHENTICATION.md.
API tokens
Programs authenticate with bearer tokens created in Settings → API tokens, carrying any of
ingest, query and review:
curl -H "Authorization: Bearer $RELATA_TOKEN" \
'http://127.0.0.1:8080/api/v1/search?q=delivery+schedule'
A token is shown once — only its hash is stored — never carries more than its owner's current role, cannot manage tokens, and cannot reach the web interface.
Ollama model setup
Relata uses two models, both replaceable:
| Purpose | Default | Why |
|---|---|---|
| Embeddings | nomic-embed-text (768 dims) |
Small, fast, good retrieval quality |
| Analysis | llama3.1:8b |
Entity/claim extraction, relationship classification, answers |
ollama pull nomic-embed-text
ollama pull llama3.1:8b
On a CPU-only machine, a smaller analysis model is much more comfortable:
ollama pull llama3.2:3b
# then in .env:
RELATA_ANALYSIS_MODEL=llama3.2:3b
To run without a language model entirely, set RELATA_ANALYSIS_ENABLED=false. Ingestion,
chunking, embedding, deterministic entity and claim extraction, relationship scoring and
search all continue to work; model-assisted extraction and generated answers are disabled.
Changing the embedding model changes the vector geometry. Relata detects a dimension mismatch
and refuses to write mixed-width vectors. After such a change, set
RELATA_EMBEDDING_DIMENSIONS to match and re-embed the workspace from Settings →
Re-embed workspace.
Demonstration dataset
make seed # load and return
make seed-wait # load and wait for processing to finish
Eleven documents chosen so that the difference between similarity and evidence is visible:
| Document | What it demonstrates |
|---|---|
| Atlas delivery schedule (original) | Related to, and superseded by, the revision |
| Atlas revised delivery schedule | Updates the original; shares entities and subject |
| Atlas invoice reconciliation | Contradicts the original about the project cost |
| Returns policy v3 / v2 | An update that supersedes older information |
| Northfield fire drill / safety inspection | Superficially similar, different events |
| Example Ltd sponsorship announcement | Shares an entity but not a subject |
| Atlas revised delivery — forwarded copy | A likely duplicate, flagged not merged |
| Office plant care rota | Unrelated to everything |
| Atlas shipment record (JSON) | Structured data sharing entities and figures |
Demonstration searches:
- What information relates to the revised delivery date?
- Which sources disagree about the project cost?
- Show information associated with Example Ltd.
- What replaced the original policy?
- Find items similar in meaning but not actually about the same event.
Example API requests
The API uses the same session cookie as the browser, and enforces CSRF on state-changing requests. Sign in first and keep the cookie jar.
BASE=http://127.0.0.1:8080
# Sign in (the login form's own CSRF token comes from the page)
TOKEN=$(curl -s -c cj.txt $BASE/login \
| grep -o 'name="csrf_token" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"//')
curl -s -b cj.txt -c cj.txt -X POST $BASE/login \
--data-urlencode "username=admin" \
--data-urlencode "password=YOUR-PASSWORD" \
--data-urlencode "csrf_token=$TOKEN"
# The session's CSRF token, for subsequent writes
CSRF=$(curl -s -b cj.txt $BASE/ \
| grep -o 'name="csrf_token" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"//')
Store a note
curl -s -b cj.txt -X POST $BASE/api/v1/items \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
-d '{
"title": "Delivery update",
"content": "The revised delivery date is 14 March 2025. Example Ltd will ship 240 units.",
"source_type": "note",
"metadata": {"project": "atlas"},
"created_at": "2025-02-28T09:00:00Z"
}'
Returns 202 Accepted with the item and the ID of the job that will process it.
Identical content returns 409 with existing_item_id; add "allow_duplicate": true to
store it anyway.
Upload a document
curl -s -b cj.txt -X POST $BASE/api/v1/items/upload \
-H "X-CSRF-Token: $CSRF" \
-F 'file=@contract.pdf' \
-F 'title=Supply contract' \
-F 'metadata[project]=atlas'
Search
curl -s -b cj.txt \
"$BASE/api/v1/search?q=revised+delivery+date&limit=5" | jq '.search.results[] | {
title: .item.title,
query_relevance,
semantic_similarity,
why: .why_matched
}'
Ask a question, answered only from stored material
curl -s -b cj.txt -X POST $BASE/api/v1/search/answer \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
-d '{"question": "Which sources disagree about the project cost?"}' | jq
Confirm that two names refer to the same thing
curl -s -b cj.txt -X POST "$BASE/api/v1/aliases/$ALIAS_ID/confirm" -H "X-CSRF-Token: $CSRF"
Candidate aliases appear on an entity's page. Confirming one records a human judgement that becomes evidence during relationship discovery; the two entities remain separate records.
Inspect and review a relationship
curl -s -b cj.txt "$BASE/api/v1/items/$ITEM_ID/relationships" | jq '.outgoing[0].explanation'
curl -s -b cj.txt -X POST "$BASE/api/v1/relationships/$REL_ID/confirm" \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
-d '{"note": "Checked against the signed contract."}'
Preview a deletion before committing
curl -s -b cj.txt -X DELETE -H "X-CSRF-Token: $CSRF" \
"$BASE/api/v1/items/$ITEM_ID?dry_run=true" | jq .plan
The full specification is in docs/openapi.yaml.
Running the tests
make test # unit tests with the race detector
make test-integration # against disposable MongoDB and Qdrant containers
make test-security # security regression tests
make fuzz # short run over all 15 fuzz targets: parsers, model output,
# API decoding, filenames, entity and claim extraction, scoring
make lint # gofmt check, go vet, staticcheck
make security # govulncheck + security tests + secret check
make check # everything CI runs
Some of those tests exercise concurrency rather than behaviour, because two of the defects
found in this codebase were only reachable under it: duplicate detection is a read followed by
a write, and asserting a relationship used to page through a node's links. Both are now
enforced by unique indexes and covered by tests/race_test.go.
Integration tests need Docker. They start their own containers, use a database named after the run, and clean up afterwards. To point them at instances you already have:
RELATA_TEST_MONGO_URI=mongodb://127.0.0.1:27017 \
RELATA_TEST_QDRANT_URL=http://127.0.0.1:6333 \
go test -tags=integration ./tests/...
Resetting the local environment
make backup # capture MongoDB and the uploaded originals first
make reset # asks for confirmation, then removes all volumes and uploads
Or piecemeal:
docker compose down -v # drop MongoDB and Qdrant data
rm -rf data/uploads/* # drop stored original files
make createadmin # create an account again
To clear only the vectors and rebuild them, use Settings → Re-embed workspace.
Configuration
Every setting is an environment variable, documented in .env.example, which
make setup copies to .env. Real environment variables take precedence over the file.
The settings you are most likely to change:
| Variable | Default | Notes |
|---|---|---|
RELATA_HTTP_ADDR |
127.0.0.1:8080 |
Non-loopback requires RELATA_ALLOW_EXTERNAL_BIND=true |
RELATA_EMBEDDING_MODEL |
nomic-embed-text |
Changing it requires re-embedding |
RELATA_ANALYSIS_MODEL |
llama3.1:8b |
Use llama3.2:3b on CPU-only machines |
RELATA_ANALYSIS_ENABLED |
true |
false runs the deterministic pipeline only |
RELATA_WORKER_COUNT |
3 |
Background workers |
RELATA_AI_CONCURRENCY |
2 |
Concurrent model calls, independent of workers |
RELATA_REL_STORE_SCORE |
0.25 |
Strength below which a candidate is discarded |
RELATA_MAX_UPLOAD_BYTES |
26214400 |
25 MB |
Configuration is validated at startup: Relata refuses to start with a clear list of every problem rather than failing later inside a request.
.env must never be committed. It is in .gitignore, and make security fails if it
is tracked.
Known limitations
Stated plainly, because a system that discovers relationships should be honest about its own.
Relationship scoring
- Scores are heuristic strengths, not calibrated probabilities. The weights in
relationship-score-v1were chosen by judgement, not fitted to labelled data. A 0.7 does not mean "70% likely to be a real relationship". - Changing weights changes every future score. Existing relationships keep the version that produced them; they are not recomputed automatically.
- Human confirmations and rejections influence ranking and expansion. They do not retrain any model, and Relata never sends your decisions anywhere.
Extraction quality
- Deterministic entity extraction is intentionally narrow: organisations with a legal suffix, people with an honorific, email addresses and dates. It will miss a person named without a title. Broader coverage comes from the model stage and is labelled as such.
- Deterministic claim extraction only picks up sentences carrying a figure or a date, because those are the statements that can actually agree or conflict. Prose claims need the model.
- Contradiction detection compares figures and dates about the same coarse subject. It will not catch a contradiction expressed entirely in prose.
- Model output quality varies considerably with model size. A 3B model produces noticeably
weaker entity and relationship classification than an 8B one, and its generated answers are
often unusable — though the safeguards still hold: an uncited answer is downgraded to
"not sufficient", and recorded contradictions are surfaced regardless of what the model says.
A model that answers with the schema itself (
llama3.2:3breturned{"answer": "sufficient"}) is rejected asdegenerate_contentrather than displayed; the request still returns the retrieved sources and any recorded contradictions, since neither depends on the model. That check catches obvious emptiness only — it cannot distinguish a weak answer from a good one, so a small model still produces answers not worth reading, it just no longer produces answers that are not answers. - Answer generation on CPU is slow. With
llama3.1:8b, three sources take about 33 seconds. The defaults (six passages, a 300-second model timeout) are set to complete on a CPU-only machine; exceeding them returns a 504 that names the remedy. A GPU changes this by an order of magnitude. See docs/OPERATIONS.md.
Search
- Query relevance is a fused rank score normalised against the best result in the same response. It is comparable within one result set, not across queries.
- MongoDB text search is English-stemmed by default. Other languages match less well.
- Relationship expansion only follows links from the strongest matches, bounded by
RELATA_SEARCH_MAX_EXPANDED_ITEMS.
Scale
- Designed for a personal or small-team corpus — thousands to low tens of thousands of items. Alias proposal compares new entities against up to 200 existing ones of the same type per run, which is bounded but not free at large entity counts.
- Relationship discovery indexes at most 500 confirmed aliases per workspace. Beyond that, further confirmed aliases stop contributing their signal; discovery weakens rather than breaking, and a warning is logged rather than the loss happening silently.
- One binary, deliberately — no sharding, no coordination layer, no service mesh. Running more than one instance against the same MongoDB does work: the job queue leases rather than locks, so instances share the workload and an instance that dies has its jobs requeued when the lease expires. That was verified with two instances, but it is a property of the queue design rather than a supported deployment topology, and nothing else is coordinated.
Formats
- No OCR. Scanned PDFs are preserved and flagged as having little extractable text.
- PDF extraction handles ordinary text layouts; complex multi-column layouts and tables may come out in an awkward order.
- DOCX extraction reads paragraph text and heading styles. Tables, footnotes, comments and tracked changes are not extracted.
Authentication
- No refresh tokens. The provider's access token establishes identity once and is then discarded rather than stored. A revoked upstream account keeps its Relata session until that session expires (idle 12h, absolute 7 days by default); revoke the Relata session too if that matters.
- No group or role mapping. Every federated user is admin of their own workspace. There is nothing to map a provider group onto, because there is no shared workspace to have roles in.
- No account linking. A local account and a federated account with the same email address are two separate people with two separate workspaces. Merging them is deliberate manual work, not something to infer from a matching address.
- Token scopes are coarse — three of them, checked per route, with no per-item restriction.
- Creating a token through the interface puts its value in the browser's history for one page
load; the page says so.
POST /api/v1/tokensreturns it in a response body instead.
Security
- Prompt-injection defences (delimited untrusted blocks, an unforgeable fence, strict output schemas, no tools available to the model) make injection much harder and bound its effect. They are not a proof. Treat generated answers as summaries of sources, not as authority.
- Relata trusts its operator. There is no protection against a malicious administrator.
- Sessions are stored server-side and revocable, but there is no device management, no two-factor authentication, and no password reset flow — recreate the account instead.
The threat model, including what is explicitly out of scope, is in docs/THREAT_MODEL.md.
Documentation
| Document | Contents |
|---|---|
| docs/ARCHITECTURE.md | Package layout, dependency rules, data model |
| docs/THREAT_MODEL.md | Trust boundaries, defences, known limitations |
| docs/SEARCH_AND_SCORING.md | The three scores and how each is computed |
| docs/INGESTION_PIPELINE.md | Stage by stage, with failure behaviour |
| docs/AI_PROVIDER_INTERFACE.md | Adding a provider without touching the pipeline |
| docs/BACKUP_AND_RESTORE.md | What to back up and how to restore it |
| docs/OPERATIONS.md | Running it: metrics, logs, common problems |
| docs/IMPLEMENTATION_PLAN.md | The plan this was built to |
| docs/AUTHENTICATION.md | Sign-in, workspaces and API tokens |
| docs/decisions/ | Architectural decision records |
Licence
Provided as-is for evaluation.