first commit

This commit is contained in:
Andy Dixon
2026-08-08 19:42:49 +01:00
commit 00ef697b03
+547
View File
@@ -0,0 +1,547 @@
# 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](docs/ARCHITECTURE.md).
---
## Prerequisites
- **Go 1.26.5 or newer** — earlier 1.26 patches carry an `html/template` XSS 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
```bash
# 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:
```bash
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
```bash
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.
```bash
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](docs/decisions/0008-federated-identity-private-workspaces.md).
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](docs/AUTHENTICATION.md).
### API tokens
Programs authenticate with bearer tokens created in **Settings → API tokens**, carrying any of
`ingest`, `query` and `review`:
```bash
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 |
```bash
ollama pull nomic-embed-text
ollama pull llama3.1:8b
```
On a CPU-only machine, a smaller analysis model is much more comfortable:
```bash
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
```bash
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.
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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](docs/openapi.yaml).
---
## Running the tests
```bash
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:
```bash
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
```bash
make backup # capture MongoDB and the uploaded originals first
make reset # asks for confirmation, then removes all volumes and uploads
```
Or piecemeal:
```bash
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`](.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-v1` were 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:3b` returned `{"answer": "sufficient"}`)
is rejected as `degenerate_content` rather 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](docs/OPERATIONS.md#answer-generation-times-out).
**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/tokens` returns 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](docs/THREAT_MODEL.md).
---
## Documentation
| Document | Contents |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Package layout, dependency rules, data model |
| [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) | Trust boundaries, defences, known limitations |
| [docs/SEARCH_AND_SCORING.md](docs/SEARCH_AND_SCORING.md) | The three scores and how each is computed |
| [docs/INGESTION_PIPELINE.md](docs/INGESTION_PIPELINE.md) | Stage by stage, with failure behaviour |
| [docs/AI_PROVIDER_INTERFACE.md](docs/AI_PROVIDER_INTERFACE.md) | Adding a provider without touching the pipeline |
| [docs/BACKUP_AND_RESTORE.md](docs/BACKUP_AND_RESTORE.md) | What to back up and how to restore it |
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Running it: metrics, logs, common problems |
| [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md) | The plan this was built to |
| [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | Sign-in, workspaces and API tokens |
| [docs/decisions/](docs/decisions/) | Architectural decision records |
---
## Licence
Provided as-is for evaluation.