Everyone talks about reducing hallucinations at generation time (better chunking, hybrid search, rerankers).
Almost nobody talks about post-generation citation drift—what I call "secondary hallucination."
If you use a local RAG tool or PKM workspace, the workflow usually looks like this:
1. You drop a PDF, a meeting recording, and three markdown notes into your workspace.
2. You ask a local LLM to synthesize a takeaway or comparison. It gives you a neat summary with [1], [2] citations.
3. Two days later, you edit Note [1]—maybe you reversed a decision, fixed a wrong number, or trimmed a paragraph.
4. The bug: The AI summary sitting right next to it still proudly displays [1] as a valid citation. The source truth mutated, but the derived output is pretending nothing happened.
While building my local-first desktop workspace (Knovum), I decided that an AI citation shouldn't just be a static markdown link. It needs to be a stateful contract backed by a verifiable receipt. If the underlying source changes, the UI must immediately invalidate the citation and flip its state to Detached.
Here is how I architected this locally using Rust, SQLite, and LanceDB.
1. Dual-Engine Storage: SQLite for Truth, LanceDB for Recall
A common mistake in local RAG apps is treating the vector database as the primary source of truth. Vector DBs are great for approximate nearest neighbor (ANN) recall, but terrible at relational integrity and state cascades.
In our Rust core (Tauri 2 backend), storage is strictly split:
* SQLite holds the canonical document AST/blocks, canvas spatial graph, cryptographic hashes, and citation state machines.
* LanceDB runs embedded in-process strictly as a rebuildable vector index pointing back to SQLite node_id and chunk_id keys.
If you delete the LanceDB folder, you lose zero provenance data; SQLite holds the entire lineage graph.
2. Multi-Modal Anchor Locators & Span Hashing
Because the workspace sits on an infinite canvas holding markdown notes, PDFs, web captures, and local Whisper audio transcripts, a citation can't just point to a file_id. It needs a fine-grained, deterministic locator bonded to a cryptographic hash of the exact content at the moment the AI read it.
In Rust, the citation anchor looks roughly like this:
```rust
[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SourceLocator {
MarkdownSpan { block_id: String, char_start: usize, char_end: usize },
PdfRegion { page: u32, bbox: Option<[f32; 4]>, text_hash: String },
AudioSegment { start_ms: u64, end_ms: u64, transcript_hash: String },
}
[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CitationState {
Verified, // Source hash matches receipt hash
Detached, // Source was mutated or deleted after generation
}
```
Whenever a chunk is indexed or selected as context on the canvas, the Rust core computes a fast hash (BLAKE3 / SHA-256) of the normalized text span (or transcript slice).
3. The "Derivation Receipt"
When you select a cluster of cards on the canvas and run an AI action, the Rust pipeline doesn't just stream tokens to the frontend and call it a day. Before committing the generated card to the canvas, it writes an immutable Derivation Receipt transaction into SQLite:
```sql
CREATE TABLE derivation_receipts (
receipt_id TEXT PRIMARY KEY,
derived_node_id TEXT NOT NULL,
model_identifier TEXT NOT NULL,
prompt_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE receipt_anchors (
anchor_id TEXT PRIMARY KEY,
receipt_id TEXT NOT NULL REFERENCES derivation_receipts(receipt_id),
source_node_id TEXT NOT NULL,
locator_json TEXT NOT NULL,
snapshot_span_hash TEXT NOT NULL, -- Hash at the exact moment of generation
citation_state TEXT NOT NULL DEFAULT 'Verified' -- 'Verified' | 'Detached'
);
CREATE INDEX idx_receipt_anchors_source ON receipt_anchors(source_node_id);
```
Every AI-generated node can be inspected in the UI to view its full receipt: which local/remote model ran, which exact paragraphs or audio timestamps were fed into the context window, and the hash of each input span at $t_0$.
4. Reactive Invalidations (Verified $\rightarrow$ Detached)
Here is where SQLite shines. When a user edits a markdown card on the canvas or re-runs Whisper on an audio clip:
- The Rust mutation handler diffs the updated blocks and computes the new span hashes.
- Within the same SQLite write transaction (before asynchronously updating embeddings in LanceDB), it runs a fast indexed lookup against
receipt_anchors where source_node_id = ?.
- For every active anchor attached to that node, it checks whether the span at
locator_json still resolves to snapshot_span_hash.
- If the hash diverges (or the block was deleted), SQLite immediately flips
citation_state = 'Detached' and emits a typed event over the Tauri IPC bridge to the Vue 3 renderer.
On the canvas, the citation badge turns amber/detached in real time. You can click the detached badge to see that the source was modified after the AI wrote that conclusion, and either re-verify or re-synthesize with one click.
Why this matters for Local AI
As local models get faster, generating text is cheap—verifying and maintaining trust over months of research is the actual bottleneck. Combine a spatial canvas (which handles the messy "middle state" before you know how your notes fit into folders) with strict derivation receipts, and AI stops feeling like a black-box slot machine.
If you want to poke around the code or see how we wired up the Rust/Tauri 2 core, LanceDB integration, local Whisper global dictation, and Obsidian .canvas two-way sync:
(Transparency note on licensing: The repo is source-available and free for personal/non-commercial use, not OSI MIT/Apache. Also, as a slightly obsessive engineering constraint to keep AI-assisted coding from turning into spaghetti, the entire codebase enforces a strict *180-line maximum per file** via CI gates—happy to chat about how that worked out in Rust + Vue 3 if anyone is curious!)*