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