Skip to content

08 — GraphRAG: Structural Knowledge & Relations

Decision: graph layer = Ontotext GraphDB with SPARQL (resolved in 11/Q1). 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.

@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

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?

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

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"

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.

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).