Revoke a document
Evidence changes. A contract is superseded, a source is retracted, a data subject asks to be forgotten. Revocation removes one document and every artifact derived from it, so it is no longer retrievable and no longer citable — while every other document stays intact and answerable.
Python’s delete() (alias revoke()) is the exact inverse of ingest() and
unwinds the whole store. Go and JavaScript have no facade, so revocation is
whichever seam holds your evidence: the in-memory corpus, or the vector store.
rag.ingest(text="The employee shall not disclose…", document_id="nda-2021")rag.ingest(text="Employees accrue twenty days of leave…", document_id="leave")
result = rag.delete("nda-2021") # or rag.revoke("nda-2021")print(result.status, result.n_units) # deleted 1
# nda-2021 is gone — not retrieved, not citableprint(rag.ask("Can the employee disclose confidential information?").evidence.decision)# refused → "I can't answer that from the available evidence."
print(rag.ask("How many days of annual leave?").evidence.decision)# answered → every other document still answers// No facade: a revoke is a corpus filter — drop every Doc derived from the id,// and it stops being retrievable and citable at once.import ( "fmt" "strings"
"github.com/muthuishere/citenexus/golang/answer")
func revoke(corpus []answer.Doc, documentID string) ([]answer.Doc, int) { kept := make([]answer.Doc, 0, len(corpus)) removed := 0 for _, d := range corpus { if d.DocumentID == documentID || strings.HasPrefix(d.DocumentID, documentID+"::") { removed++ continue } kept = append(kept, d) } return kept, removed}
corpus := []answer.Doc{ {DocumentID: "nda-2021", Text: "The employee shall not disclose confidential information."}, {DocumentID: "leave", Text: "Employees accrue twenty days of annual leave."},}
corpus, removed := revoke(corpus, "nda-2021")fmt.Println("removed", removed, "| corpus now", len(corpus))// removed 1 | corpus now 1
fmt.Println(answer.Ask(corpus, "Can the employee disclose confidential information?", answer.DefaultTopK).Evidence.Decision)// refusedfmt.Println(answer.Ask(corpus, "How many days of annual leave?", answer.DefaultTopK).Evidence.Decision)// answered
_, again := revoke(corpus, "nda-2021") // idempotent: nothing left to removefmt.Println("second revoke removed", again)// second revoke removed 0// No facade: a revoke is a corpus filter — drop every document derived from the// id, and it stops being retrievable and citable at once.import { ask } from "@muthuishere/citenexus";
function revoke(corpus, documentId) { const kept = corpus.filter( (d) => d.document_id !== documentId && !d.document_id.startsWith(`${documentId}::`), ); return { kept, removed: corpus.length - kept.length };}
let corpus = [ { document_id: "nda-2021", text: "The employee shall not disclose confidential information." }, { document_id: "leave", text: "Employees accrue twenty days of annual leave." },];
const { kept, removed } = revoke(corpus, "nda-2021");corpus = kept;console.log("removed", removed, "| corpus now", corpus.length);// removed 1 | corpus now 1
console.log(ask(corpus, "Can the employee disclose confidential information?").evidence.decision);// refusedconsole.log(ask(corpus, "How many days of annual leave?").evidence.decision);// answered
console.log("second revoke removed", revoke(corpus, "nda-2021").removed); // idempotent// second revoke removed 0Revoking from a persistent store
Section titled “Revoking from a persistent store”When the port does hold your evidence in a store, revocation is a store call —
DeleteDocument / deleteDocument, the row-level primitive the Python facade is
built on. Both tabs below run against the repo’s local pgvector container
(docker compose --profile postgres up -d postgres).
result = rag.delete("nda-2021")print(result)# DeleteResult(document_id='nda-2021', status='deleted', removed_eu_ids=('nda-2021::0::0',))The facade drives the store for you — vector rows, structure, images, the raw blob, graph, wiki, and the manifest entry (see the table below).
import ( "context" "fmt"
"github.com/muthuishere/citenexus/golang/storage")
dsn := "postgres://citenexus:citenexus@localhost:15432/citenexus"store := storage.NewPostgresVectorStore(dsn, storage.TableNameFor("citenexus_docs", "workspace=default"), nil)defer store.Close(context.Background())
before, _ := store.Scan(nil)if err := store.DeleteDocument("nda-2021"); err != nil { panic(err)}after, _ := store.Scan(nil)fmt.Println("rows before:", len(before), "after:", len(after), "| remaining:", after[0]["document_id"])// rows before: 2 after: 1 | remaining: leaveimport { PostgresVectorStore, tableNameFor } from "@muthuishere/citenexus";
const store = new PostgresVectorStore({ dsn: "postgres://citenexus:citenexus@localhost:15432/citenexus", table: tableNameFor("citenexus_docs_js", "workspace=default"),});
const before = await store.scan();await store.deleteDocument("nda-2021");const after = await store.scan();console.log("rows before:", before.length, "after:", after.length, "| remaining:", after[0].document_id);// rows before: 2 after: 1 | remaining: leaveWhat gets removed
Section titled “What gets removed”A single ingest() writes several artifacts; Python’s delete() unwinds all of
them for that document_id:
| Artifact | Removed |
|---|---|
| Vector rows (the retrievable Evidence Units) | ✅ all rows carrying the document_id |
Structure index (knowledge/…/structure/{id}.json) |
✅ |
Per-document image blobs (raw/…/images/{id}/) |
✅ |
Content-addressed raw blob (raw/…/{checksum}) |
✅ only if no other document shares those bytes |
Graph (graph/…/graph.json) |
✅ rebuilt to contain nothing derived from the doc — when the graph signal is on |
| Wiki page + index entry | ✅ page dropped, index rewritten — when the wiki signal is on |
| Etag-manifest entry | ✅ removed last (the commit point) |
The lexical (BM25) index needs no separate step — it is derived from the vector rows and stops matching the document the moment its rows are gone.
The port stores unwind only the vector rows (that is all they own); anything
else your application derived from the document — a structure index you built
with buildStructure, a graph you built with buildComentionGraph — you rebuild
or drop yourself.
Idempotent and resumable
Section titled “Idempotent and resumable”delete() is safe to call twice and safe to interrupt:
print(rag.delete("nda-2021").status) # deleted — existed, removedprint(rag.delete("nda-2021").status) # absent — nothing left to do, no errorprint(rag.delete("never-ingested").status) # absentThe etag-manifest entry is written last. While it is present the document is considered logically present, so a revoke interrupted after some artifacts are removed but before the manifest entry is forgotten simply re-runs cleanly — it finishes removing what remains and forgets the entry, with no orphaned, still-retrievable evidence.
Both Go paths are idempotent by construction — the corpus filter removes 0 on a
second call (shown above), and DeleteDocument is a parameterized DELETE … WHERE document_id = $1 that succeeds on zero rows and is a no-op on a missing table:
fmt.Println(store.DeleteDocument("nda-2021")) // <nil>fmt.Println(store.DeleteDocument("nda-2021")) // <nil> — nothing left, no errorfmt.Println(store.DeleteDocument("never-ingested")) // <nil>There is no status to read back and no manifest, so there is nothing to resume
— a single DELETE is the whole commit.
Both JavaScript paths are idempotent by construction — the corpus filter removes
0 on a second call (shown above), and deleteDocument is a parameterized DELETE … WHERE document_id = $1 that resolves on zero rows and is a no-op on a missing
table:
await store.deleteDocument("nda-2021"); // resolvesawait store.deleteDocument("nda-2021"); // resolves — nothing left, no errorawait store.deleteDocument("never-ingested"); // resolvesThere is no status to read back and no manifest, so there is nothing to resume
— a single DELETE is the whole commit.
Observe it
Section titled “Observe it”A revoke fires the on_delete lifecycle hook (the mirror of on_ingest),
observe-only and never fatal — the same contract as every other hook:
from citenexus import CiteNexus, Hooks
rag = CiteNexus(store, embedder=…, generator=…, hooks=Hooks(on_delete=lambda r: audit_log(r.document_id, r.status)))No hook system in the Go port — the revoke is your own function, so log inside it:
corpus, removed := revoke(corpus, "nda-2021")auditLog("nda-2021", removed)No hook system in the JavaScript port — the revoke is your own function, so log inside it:
const { kept, removed } = revoke(corpus, "nda-2021");auditLog("nda-2021", removed);Across the ports
Section titled “Across the ports”Revocation reaches down to the storage seam in every port at parity. The Python facade orchestrates the full unwind; the Go, JS, and Rust cores expose the row-level primitive it is built on:
| Port | Surface |
|---|---|
| Python | rag.delete(document_id) / rag.revoke(document_id) — full orchestration |
| Rust core | LanceStore::delete_document(&self, document_id) (C-ABI citenexus_store_delete_document) |
| Go | PostgresVectorStore.DeleteDocument(documentID string) error — plus LanceVectorStore behind the citenexus_ffi build tag |
| JavaScript | PostgresVectorStore.deleteDocument(documentId): Promise<void> — plus the Lance store on the @muthuishere/citenexus/ingest subpath |