baseline: docs as of pre-reorg audit (2026-05-20)
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
# 08 — GraphRAG: Structural Knowledge & Relations
|
||||
|
||||
Decision: graph layer = **Ontotext GraphDB** with **SPARQL** (resolved in [11/Q1](./11-open-questions.md)).
|
||||
Rationale: SPARQL + RDF lets Nexa's memory be browsed and queried with the same standard tooling that's used for any open-data corpus, and it leaves the door open for SHACL / OWL reasoning later.
|
||||
|
||||
---
|
||||
|
||||
## Two-pillar memory
|
||||
|
||||
| Pillar | Question it answers | Backed by |
|
||||
|--------|---------------------|-----------|
|
||||
| **Qdrant** (vectors) | *"What is similar / relevant?"* | Cosine search over embeddings |
|
||||
| **GraphDB** (RDF) | *"What is connected? What depends on what? Who is involved?"* | SPARQL over a typed graph |
|
||||
|
||||
Both pillars are queried in parallel for `#nexa:ask` and merged before SAIA generates the final answer.
|
||||
|
||||
---
|
||||
|
||||
## RDF schema
|
||||
|
||||
Compact, opinionated. One namespace, one ontology file, no v2/v3 inheritance pain.
|
||||
|
||||
```turtle
|
||||
@prefix nexa: <https://nuclide.systems/nexa/ontology#> .
|
||||
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
||||
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
||||
@prefix prov: <http://www.w3.org/ns/prov#> .
|
||||
|
||||
# Classes
|
||||
nexa:Project a rdfs:Class .
|
||||
nexa:Task a rdfs:Class .
|
||||
nexa:Person a rdfs:Class .
|
||||
nexa:Technology a rdfs:Class .
|
||||
nexa:Topic a rdfs:Class .
|
||||
nexa:Note a rdfs:Class . # Memos / Obsidian / mail digests
|
||||
nexa:File a rdfs:Class .
|
||||
|
||||
# Properties
|
||||
nexa:owns a rdf:Property ; rdfs:domain nexa:Person ; rdfs:range nexa:Task .
|
||||
nexa:uses a rdf:Property ; rdfs:domain nexa:Task ; rdfs:range nexa:Technology .
|
||||
nexa:dependsOn a rdf:Property ; rdfs:domain nexa:Task ; rdfs:range nexa:Task .
|
||||
nexa:childOf a rdf:Property ; rdfs:domain nexa:Task ; rdfs:range nexa:Project .
|
||||
nexa:mentions a rdf:Property ; rdfs:domain nexa:Note ; rdfs:range nexa:Topic .
|
||||
nexa:scheduledFor a rdf:Property ; rdfs:domain nexa:Task ; rdfs:range xsd:dateTime .
|
||||
|
||||
# Datatype properties
|
||||
nexa:status a rdf:Property ; rdfs:range xsd:string . # "needs-action" | "in-progress" | "done"
|
||||
nexa:context a rdf:Property ; rdfs:range xsd:string . # "work" | "personal"
|
||||
nexa:urgency a rdf:Property ; rdfs:range xsd:integer . # 1–5
|
||||
nexa:contentHash a rdf:Property ; rdfs:range xsd:string . # for de-dup
|
||||
|
||||
# Cross-pillar / multimodality
|
||||
nexa:modality a rdf:Property ; rdfs:range xsd:string . # "text" | "image"
|
||||
nexa:mediaUri a rdf:Property ; rdfs:range xsd:anyURI . # memos://… , nextcloud://… , obsidian://…
|
||||
nexa:vectorCollection a rdf:Property ; rdfs:range xsd:string . # "nexa_knowledge_text" | "nexa_knowledge_visual"
|
||||
nexa:vectorId a rdf:Property ; rdfs:range xsd:string . # Qdrant point ID
|
||||
nexa:pendingVisualIndex a rdf:Property ; rdfs:range xsd:boolean . # set true on image notes until Phase 3.2 backfills them
|
||||
```
|
||||
|
||||
`nexa:vectorId` + `nexa:vectorCollection` together are the **bridge** between graph and vector store. A SPARQL hit can trigger a vector lookup, and a Qdrant payload's `graph_iri` field walks back the other way.
|
||||
|
||||
`nexa:modality`, `nexa:mediaUri` and `nexa:pendingVisualIndex` exist from Phase 3.1 even though only the text path is wired up. Image attachments captured in 3.1 are recorded as `nexa:Note` with `modality "image"` and `pendingVisualIndex true`, then picked up by the Phase-3.2 backfill workflow — no data loss across phases.
|
||||
|
||||
---
|
||||
|
||||
## Sync flows
|
||||
|
||||
### 1. Memos → GraphDB (real-time)
|
||||
|
||||
```
|
||||
Memo content: "Muss JWT-Middleware für Auth-Service refaktorieren"
|
||||
│
|
||||
▼ SAIA extracts entities + relations as JSON
|
||||
│ { tasks: [{title, urgency}], technologies: [...],
|
||||
│ relations: [{type:"uses", from:..., to:...}] }
|
||||
│
|
||||
▼ n8n turns JSON into a SPARQL UPDATE
|
||||
│
|
||||
└──▶ INSERT DATA { ... } against GraphDB repo "nexa_knowledge"
|
||||
```
|
||||
|
||||
### 2. Obsidian → GraphDB (`#nexa:sync-obsidian`)
|
||||
|
||||
For each Obsidian note: parse front-matter + headings → emit `nexa:Project`, `nexa:Task`, `nexa:Note` triples; `nexa:mentions` for `[[wikilinks]]`.
|
||||
|
||||
### 3. Nextcloud Tasks ↔ GraphDB (bidirectional)
|
||||
|
||||
n8n trigger on Nextcloud CalDAV/Tasks change → `INSERT/DELETE DATA` to keep `nexa:status` and `nexa:scheduledFor` in sync.
|
||||
|
||||
---
|
||||
|
||||
## Example SPARQL queries
|
||||
|
||||
### Q1 — All open tasks involving JWT, by urgency
|
||||
|
||||
```sparql
|
||||
PREFIX nexa: <https://nuclide.systems/nexa/ontology#>
|
||||
|
||||
SELECT ?taskTitle ?urgency ?projectName
|
||||
WHERE {
|
||||
?tech rdfs:label "JWT" .
|
||||
?task nexa:uses ?tech ;
|
||||
rdfs:label ?taskTitle ;
|
||||
nexa:status ?status ;
|
||||
nexa:urgency ?urgency .
|
||||
FILTER (?status IN ("needs-action", "in-progress"))
|
||||
OPTIONAL { ?task nexa:childOf ?project . ?project rdfs:label ?projectName . }
|
||||
}
|
||||
ORDER BY DESC(?urgency)
|
||||
```
|
||||
|
||||
### Q2 — What does Auth-Service transitively depend on?
|
||||
|
||||
```sparql
|
||||
PREFIX nexa: <https://nuclide.systems/nexa/ontology#>
|
||||
|
||||
SELECT DISTINCT ?dep ?label
|
||||
WHERE {
|
||||
?root rdfs:label "Auth-Service" .
|
||||
?root nexa:dependsOn+ ?dep .
|
||||
?dep rdfs:label ?label .
|
||||
}
|
||||
```
|
||||
|
||||
(`+` is SPARQL property-paths — transitive closure, free.)
|
||||
|
||||
### Q3 — Topics with the most note-mentions in the last day
|
||||
|
||||
```sparql
|
||||
PREFIX nexa: <https://nuclide.systems/nexa/ontology#>
|
||||
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
|
||||
|
||||
SELECT ?topic (COUNT(?note) AS ?n)
|
||||
WHERE {
|
||||
?note a nexa:Note ;
|
||||
prov:generatedAtTime ?ts ;
|
||||
nexa:mentions ?topic .
|
||||
FILTER (?ts > NOW() - "P1D"^^xsd:duration)
|
||||
}
|
||||
GROUP BY ?topic
|
||||
ORDER BY DESC(?n)
|
||||
LIMIT 10
|
||||
```
|
||||
|
||||
### Q4 — Cross-pillar: "find vectors for tasks blocking project X"
|
||||
|
||||
```sparql
|
||||
PREFIX nexa: <https://nuclide.systems/nexa/ontology#>
|
||||
|
||||
SELECT ?taskTitle ?vectorId
|
||||
WHERE {
|
||||
?proj rdfs:label "Nexa" .
|
||||
?task nexa:childOf ?proj ;
|
||||
nexa:status "in-progress" ;
|
||||
nexa:vectorId ?vectorId ;
|
||||
rdfs:label ?taskTitle .
|
||||
}
|
||||
```
|
||||
|
||||
n8n then takes each `?vectorId`, fetches the embedding from Qdrant, and runs a "more like this" search for richer context.
|
||||
|
||||
---
|
||||
|
||||
## GraphRAG answer pipeline (`#nexa:ask`)
|
||||
|
||||
```
|
||||
#nexa:ask <question>
|
||||
│
|
||||
┌──────┴──────┐
|
||||
▼ ▼
|
||||
[Qdrant] [GraphDB]
|
||||
semantic structural
|
||||
top-k SPARQL — auto-generated
|
||||
notes paths / dependencies
|
||||
│ │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
merge + rank
|
||||
│
|
||||
▼
|
||||
SAIA prompt:
|
||||
"Given these passages and these relations, answer …"
|
||||
│
|
||||
▼
|
||||
comment under the original memo
|
||||
```
|
||||
|
||||
Auto-generation of SPARQL: SAIA is given the ontology (above) as a system prompt and asked to emit a `SELECT`/`CONSTRUCT` query for the user's natural-language question. n8n executes it, falls back to a templated query on parse failure.
|
||||
|
||||
---
|
||||
|
||||
## n8n integration sketch
|
||||
|
||||
### Workflow: Graph-Sync Trigger
|
||||
|
||||
```
|
||||
[Memos Webhook]
|
||||
│
|
||||
[Parse Content]
|
||||
│
|
||||
[SAIA: Extract entities + relations as JSON]
|
||||
│
|
||||
[Build SPARQL UPDATE INSERT DATA { ... }]
|
||||
│
|
||||
[HTTP POST → /repositories/nexa_knowledge/statements]
|
||||
│
|
||||
[Index in Qdrant; write Qdrant point id back via second SPARQL UPDATE]
|
||||
```
|
||||
|
||||
### Workflow: Question Router
|
||||
|
||||
```
|
||||
[#nexa:ask Query]
|
||||
│
|
||||
┌─┴────────────────┐
|
||||
▼ ▼
|
||||
[SAIA: NL → SPARQL] [Qdrant: kNN]
|
||||
│ │
|
||||
[POST → SPARQL endpoint]
|
||||
│ │
|
||||
└──────┬───────────┘
|
||||
▼
|
||||
rank + merge → SAIA answer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graph-management commands
|
||||
|
||||
### `#nexa:graph-status`
|
||||
|
||||
Returns triple count, class histogram, most-connected entity. Implemented as one SPARQL `SELECT (COUNT)`.
|
||||
|
||||
### `#nexa:graph-trace [entity]`
|
||||
|
||||
Returns the 1-hop (and optionally 2-hop) neighbourhood — a `DESCRIBE <iri>` plus a templated outgoing/incoming query.
|
||||
|
||||
### `#nexa:graph-rebuild`
|
||||
|
||||
Clears the named graph and replays Obsidian + Memos. SPARQL: `CLEAR GRAPH <https://nuclide.systems/nexa/runtime>` followed by the import workflow.
|
||||
|
||||
---
|
||||
|
||||
## Why two stores
|
||||
|
||||
| Scenario | Qdrant | GraphDB | Best |
|
||||
|----------|--------|---------|------|
|
||||
| "Which note was similar to this one?" | ✅ | ❌ | Qdrant |
|
||||
| "What blocks this task?" | ❌ | ✅ | GraphDB |
|
||||
| "Explain this project" | ✅ context | ✅ structure | both |
|
||||
| "All JWT-related open work" | ✅ semantic | ✅ crisp | both |
|
||||
|
||||
Combined: **complete understanding** rather than a search index *or* a structure index.
|
||||
|
||||
---
|
||||
|
||||
## Memory sources & retention
|
||||
|
||||
Not every embedding deserves to live forever. Nexa indexes from several source types and each has its own expected lifetime. The contract: every Qdrant point carries `payload.source_type` and `payload.expires_at` (epoch seconds, or `null` for permanent). A daily prune workflow runs `DELETE WHERE expires_at < NOW()` on each collection and mirrors the deletion in GraphDB.
|
||||
|
||||
| `source_type` | Where it comes from | Default TTL | Rationale |
|
||||
|---------------|--------------------|-------------|-----------|
|
||||
| `memo` | Memos webhook | **permanent** | User-authored, low volume, high signal. |
|
||||
| `obsidian` | Nextcloud `Notizen/` via WebDAV | **permanent** | User-authored knowledge base. |
|
||||
| `mail` | Nextcloud Mail (single account) | 365 d | Audit trail + searchable past correspondence. Mail digests are derived, not stored as their own embeddings. |
|
||||
| `mail_digest` | Daily digest output | 90 d | Summarised content; the source mails persist longer. |
|
||||
| `karakeep` | Karakeep saved links | **permanent** | User explicitly bookmarked. |
|
||||
| `rss` | Phase-2.2 morning digest feed items | 30 d | News signal decays fast; keep recent for "what was that article last week?". |
|
||||
| `web_search` | On-demand fetch via crawl4ai-mcp / markitdown-mcp during `#nexa:ask` | 90 d | Useful for "what did we look at last quarter?" but not eternal. |
|
||||
| `system` | Backrest / Proxmox / n8n alerts via `nexa.system` ntfy topic | 30 d | Operational telemetry; old alerts have little RAG value. |
|
||||
| `task` | Nextcloud Tasks ↔ GraphDB sync | until task deleted | Mirrors source-of-truth. |
|
||||
|
||||
**External sources go through the same pipeline as memos** — fetch → markitdown-mcp → embed via TEI → upsert into `nexa_knowledge_text` with the appropriate `source_type` + `expires_at`. The graph node carries `nexa:source`, `nexa:fetchedAt`, `nexa:sourceUri`, and `nexa:contentHash` for de-dup (so the same article fetched twice doesn't create two points).
|
||||
|
||||
### Web search loop
|
||||
|
||||
`#nexa:ask` first searches existing memory. If the merged confidence is below a threshold (or the user adds `--web` to the command), Nexa runs a SearXNG query through `redis-searxng`, picks the top 3 results, fetches them through `crawl4ai-mcp` + `markitdown-mcp`, embeds the cleaned markdown, and **answers from the augmented context**. The fetched pages stay in memory (TTL 90 d) so the next related question doesn't re-fetch.
|
||||
|
||||
This means the homelab's existing `*-mcp` containers are part of Nexa's data plane, not just decoration — see [docs/12 #8](./12-optimization-opportunities.md#8).
|
||||
|
||||
### Manual overrides
|
||||
|
||||
- `#nexa:learn <text> --permanent` overrides the default TTL.
|
||||
- `#nexa:forget <iri-or-search>` triggers an immediate Qdrant delete + GraphDB `DELETE WHERE { ?n nexa:vectorId "..." . }`.
|
||||
- `#nexa:retain <source_type> <days>` rewrites the default for that source type (stored in the `_config` namespace, picked up by the next prune run).
|
||||
Reference in New Issue
Block a user