LlamaIndex¶
Provena integrates with LlamaIndex through a node postprocessor that logs every retrieved node to your audit trail without altering query results.
Installation¶
This installs llama-index-core>=0.10 as a dependency.
Quick start¶
from provena import ContextTrail
from provena.integrations.llamaindex import ProvenaPostprocessor
trail = ContextTrail()
postprocessor = ProvenaPostprocessor(trail=trail)
query_engine = index.as_query_engine(
node_postprocessors=[postprocessor],
)
How it works¶
ProvenaPostprocessor extends LlamaIndex's BaseNodePostprocessor. It
implements the _postprocess_nodes method, which is called after retrieval
and before the response synthesizer.
Pass-through behavior¶
The postprocessor returns nodes unchanged. It observes and logs each node but never modifies content, scores, or ordering. Your query results are identical whether Provena is attached or not.
def _postprocess_nodes(self, nodes, query_bundle=None):
for node_with_score in nodes:
node = node_with_score.node
content = node.text # (1)!
provenance = _extract_llamaindex_provenance(node) # (2)!
metadata = {}
if node_with_score.score is not None:
metadata["score"] = node_with_score.score # (3)!
if query_bundle:
metadata["query"] = query_bundle.query_str # (4)!
self.trail.log(
content=content,
source=ContextSource.RETRIEVER,
source_name="llamaindex",
provenance=provenance,
metadata=metadata,
)
return nodes # unchanged
- Each node's
textattribute is captured as the logged content. - Provenance metadata is auto-extracted from
node.metadata(see below). - The retrieval similarity score is stored in the record's metadata.
- The original query string is also captured when available.
Score and query tracking
Each trail record's metadata includes the retrieval score (float) and
the query string that triggered the retrieval. This lets you audit
not just what was retrieved but why and how relevant it was.
Provenance auto-extraction¶
Provena automatically extracts provenance metadata from each node's
metadata dictionary:
| Node metadata key | Provena field | Notes |
|---|---|---|
source |
source_url |
Checked first |
file_path |
source_url |
Fallback if source is absent |
author |
author |
Mapped directly |
# A node with this metadata:
node = TextNode(
text="...",
metadata={
"file_path": "/data/contracts/agreement_v2.pdf",
"author": "Legal Department",
},
)
# Produces this ProvenanceMetadata:
# ProvenanceMetadata(
# source_url="/data/contracts/agreement_v2.pdf",
# author="Legal Department",
# )
Provenance validation
Nodes that include source, file_path, or author in their metadata
will receive VALID or INCOMPLETE provenance status. Nodes with no
origin metadata are marked MISSING, making gaps visible in your
compliance reports.
Full working example¶
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.llms.openai import OpenAI
from provena import ContextTrail
from provena.integrations.llamaindex import ProvenaPostprocessor
# --- Load and index documents ---
documents = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(documents)
# --- Create the Provena trail and postprocessor ---
trail = ContextTrail(storage_path="rag_audit.db")
postprocessor = ProvenaPostprocessor(trail=trail)
# --- Build the query engine with governance ---
query_engine = index.as_query_engine(
llm=OpenAI(model="gpt-4o"),
similarity_top_k=3,
node_postprocessors=[postprocessor],
)
# --- Run a query ---
response = query_engine.query("What are the key terms of the agreement?")
print(response)
Verifying the trail¶
After running a query, inspect and verify the audit trail:
# Check what was logged
summary = trail.summary()
print(f"Total records: {summary['total']}")
print(f"Sources: {summary['sources']}")
print(f"Provenance: {summary['provenance']}")
# Verify hash chain integrity
verdict = trail.verify_chain()
assert verdict.intact, f"Chain broken: {verdict.details}"
print(f"Chain intact: {verdict.total_records} records verified")
# Inspect individual records with scores
records = trail.query(source="retriever", limit=10)
for r in records:
meta = r.get("metadata_json", "{}")
print(
f" [{r['provenance_status']}] {r['source_name']}"
f" - score={r.get('metadata', {}).get('score', 'N/A')}"
)
trail.close()
Expected output:
Total records: 3
Sources: {'retriever': 3}
Provenance: {'VALID': 2, 'MISSING': 1}
Chain intact: 3 records verified
[VALID] llamaindex - score=0.92
[VALID] llamaindex - score=0.87
[MISSING] llamaindex - score=0.81
Combining with other postprocessors¶
ProvenaPostprocessor can be stacked with other LlamaIndex postprocessors.
Place it last in the list so it logs the final set of nodes that reach the
response synthesizer:
from llama_index.core.postprocessor import SimilarityPostprocessor
query_engine = index.as_query_engine(
node_postprocessors=[
SimilarityPostprocessor(similarity_cutoff=0.7), # filter first
ProvenaPostprocessor(trail=trail), # then log
],
)
Context manager pattern¶
Use the trail as a context manager so the database is always closed:
with ContextTrail(storage_path="audit.db") as trail:
postprocessor = ProvenaPostprocessor(trail=trail)
query_engine = index.as_query_engine(
node_postprocessors=[postprocessor],
)
response = query_engine.query("Summarize the compliance requirements")
verdict = trail.verify_chain()
print(f"Chain intact: {verdict.intact}")
# trail.close() is called automatically