Files
nexa/docs/08-graphrag-architecture.md
Claude e8f0d9a2c2 Memory retention design + open-question re-asker + UNAS Pro specifics
Two design questions answered:

1. Web/news/Karakeep INTO long-term memory? Yes, but with per-source TTL.
   docs/08 gains a "Memory sources & retention" section that pins TTLs:
   memo/obsidian/karakeep = permanent, mail = 365d, mail_digest = 90d,
   rss = 30d, web_search = 90d, system = 30d. Every Qdrant point carries
   payload.expires_at; a daily prune workflow honours it. New commands
   in docs/05: #nexa:learn --permanent, #nexa:forget, #nexa:retain,
   #nexa:ask --web (SearXNG → crawl4ai-mcp → markitdown-mcp → embed).
   Phase 2.4 added to roadmap.

2. Re-ask unanswered open questions. Backoff schedule (3d → 7d → 21d →
   60d) tracked in GraphDB per question. Surface ONE question per day
   in the morning digest, but only when the digest is otherwise short
   (capacity guard < 800 chars). User reply parsed → question auto-
   resolved → docs/11 diff proposed (Phase 6.4 hook). New commands in
   docs/05: #nexa:digest, #nexa:remind, #nexa:answered. Phase 5.3 added.

UNAS Pro details from the UniFi Drive dashboard:
- It's a Ubiquiti UNAS Pro (UniFi Drive 4.1.16 on UniFi OS 5.0.17),
  SFP+, RAID 6, 19.96 TiB raw, 2.05 TiB used. Recorded in CLAUDE.md.
- SMB native paths: smb://192.168.1.31/<share> (mac) / \\192.168.1.31\
  <share> (win). UniFi recommends SMB as the modern path — matches our
  decision to default Nexa volumes to SMB.
- ⚠️ Storage-pool snapshots NOT configured ("Click to Setup"). Added as
  optimization #38: highest-leverage data-protection change in the
  homelab right now. Daily + weekly UniFi Drive snapshot, native, no
  agent. RAID 6 doesn't protect against rm -rf or accidental mass-
  delete; snapshots do.
- #39: Nexa workflows that mutate large state can use the same native
  snapshots for fast rollback (pre-snapshot → operate → verify).
2026-05-05 04:45:29 +00:00

11 KiB
Raw Permalink Blame History

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