# 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: . @prefix rdf: . @prefix rdfs: . @prefix xsd: . @prefix 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: 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: 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: PREFIX xsd: 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: 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 │ ┌──────┴──────┐ ▼ ▼ [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 ` plus a templated outgoing/incoming query. ### `#nexa:graph-rebuild` Clears the named graph and replays Obsidian + Memos. SPARQL: `CLEAR GRAPH ` 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 --permanent` overrides the default TTL. - `#nexa:forget ` triggers an immediate Qdrant delete + GraphDB `DELETE WHERE { ?n nexa:vectorId "..." . }`. - `#nexa:retain ` rewrites the default for that source type (stored in the `_config` namespace, picked up by the next prune run).