Sanctum Vector Memory
Sanctum is Paladin AI's long-term semantic memory system. It stores memories as vector embeddings, enabling similarity-based retrieval across sessions — unlike Garrison which stores sequential conversation history, Sanctum finds conceptually similar past experiences.
Sanctum is defined in crates/paladin-ports/src/output/sanctum_port.rs (the SanctumPort
trait) with adapter implementations in crates/paladin-memory/src/sanctum/.
Table of Contents
- Sanctum vs. Garrison
- Quick Start
- Sanctum Adapters
- SanctumPort Trait
- SanctumEntry and Memory Types
- Searching with SanctumQuery
- RAG — Retrieval-Augmented Generation
- Attaching to a Paladin
- Docker Setup (Qdrant)
- config.yml Reference
- Error Handling
- Best Practices
Sanctum vs. Garrison
| Garrison | Sanctum | |
|---|---|---|
| Storage | Sequential entries | Vector embeddings |
| Retrieval | Most recent N / keyword | Cosine similarity |
| Scope | Single conversation | Across all sessions |
| Use for | Conversation context | Knowledge base, RAG |
| Backend | In-memory / SQLite | In-memory / Qdrant |
| Requires embeddings | No (optional) | Yes |
Quick Start
Prerequisite: A running Qdrant instance. Use
make devto start the Docker Compose stack, ordocker run -p 6334:6334 qdrant/qdrant.
use paladin_memory::sanctum::QdrantSanctumAdapter;
use paladin_memory::services::rag_retrieval_service::RagRetrievalService;
use paladin_core::platform::container::sanctum::{Memory, MemoryType, SanctumEntry};
use paladin_ports::output::sanctum_port::{SanctumPort, SanctumQuery};
use paladin_ports::output::embedding_port::EmbeddingPort;
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sanctum = Arc::new(
QdrantSanctumAdapter::new("http://localhost:6334", "memories", 1536).await?
);
let embedder: Arc<dyn EmbeddingPort> = Arc::new(openai_embedder());
// Store a memory
let content = "Rust's borrow checker prevents data races at compile time.";
let embedding = embedder.embed_text(content).await?;
let memory = Memory::builder("agent-1".to_string(), content.to_string())
.memory_type(MemoryType::Semantic)
.importance(0.9)
.build()?;
let entry = SanctumEntry {
memory,
embedding: embedding.vector.clone(),
dimension: embedding.vector.len(),
};
sanctum.store(entry).await?;
// Semantic search
let query_vec = embedder.embed_text("memory safety in Rust").await?.vector;
let results = sanctum.search(SanctumQuery {
embedding: query_vec,
limit: 5,
filter: None,
}).await?;
for r in results {
println!("[score: {:.3}] {}", r.score, r.entry.memory.content);
}
Ok(())
}
Sanctum Adapters
Both in crates/paladin-memory/src/sanctum/.
QdrantSanctumAdapter
Production-grade vector store with HNSW indexing.
| Property | Value |
|---|---|
| Persistence | Qdrant database |
| Scale | Millions of vectors |
| Search | Cosine similarity, HNSW, <500ms at 100K vectors |
| Use case | Production deployments |
use paladin_memory::sanctum::QdrantSanctumAdapter;
let sanctum = QdrantSanctumAdapter::new(
"http://localhost:6334", // Qdrant URL
"paladin_memories", // Collection name
1536, // Vector dimension (match your embedding model)
).await?;
The collection is auto-created if it does not exist.
InMemorySanctum
Fast, ephemeral vector store for development and testing.
use paladin_memory::sanctum::InMemorySanctumAdapter;
let sanctum = InMemorySanctumAdapter::new(1536);
SanctumPort Trait
#[async_trait]
pub trait SanctumPort: Send + Sync {
/// Store a single memory with its embedding
async fn store(&self, entry: SanctumEntry) -> Result<(), SanctumError>;
/// Store multiple memories in a single batch operation
async fn store_batch(&self, entries: Vec<SanctumEntry>) -> Result<(), SanctumError>;
/// Search for semantically similar memories
async fn search(&self, query: SanctumQuery) -> Result<Vec<SanctumSearchResult>, SanctumError>;
/// Delete a memory by its ID
async fn delete(&self, id: &str) -> Result<bool, SanctumError>;
}
SanctumEntry and Memory Types
use paladin_core::platform::container::sanctum::{Memory, MemoryType, SanctumEntry};
let memory = Memory::builder("paladin-id".to_string(), "content here".to_string())
.memory_type(MemoryType::Semantic) // Semantic | Episodic | Procedural
.importance(0.8) // 0.0–1.0
.add_metadata("topic".to_string(), serde_json::json!("rust"))
.build()?;
let entry = SanctumEntry {
memory,
embedding: vec![0.1_f32; 1536], // Your embedding vector
dimension: 1536,
};
MemoryType variants:
| Variant | Description |
|---|---|
Semantic | Factual knowledge (recommended default) |
Episodic | Specific past events or interactions |
Procedural | How-to instructions and processes |
Searching with SanctumQuery
use paladin_ports::output::sanctum_port::{SanctumQuery, SanctumFilter};
// Basic similarity search
let results = sanctum.search(SanctumQuery {
embedding: query_vec,
limit: 10,
filter: None,
}).await?;
// With metadata filter
let results = sanctum.search(SanctumQuery {
embedding: query_vec,
limit: 5,
filter: Some(SanctumFilter {
paladin_id: Some("agent-1".to_string()),
memory_type: Some(MemoryType::Semantic),
min_importance: Some(0.7),
..Default::default()
}),
}).await?;
// Each result contains:
// result.entry → the SanctumEntry
// result.score → cosine similarity (0.0–1.0, higher = more similar)
RAG — Retrieval-Augmented Generation
The RagRetrievalService (camelCase Rag, not RAG) in
crates/paladin-memory/src/services/rag_retrieval_service.rs automates memory retrieval and
injection into the Paladin's prompt context:
use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin_ports::output::sanctum_port::SanctumPort;
use paladin_ports::output::embedding_port::EmbeddingPort;
use std::sync::Arc;
let paladin = PaladinBuilder::new(llm_port)
.system_prompt("You are a knowledgeable assistant.")
.with_sanctum(sanctum_port) // Vector store
.with_embedding_port(embedder) // Embedding provider
.build()
.await?;
When Sanctum and an embedding port are both attached, the Paladin will automatically:
- Embed the user's input query.
- Retrieve the top-K most similar memories from Sanctum.
- Prepend retrieved context to the prompt before the LLM call.
- Extract and store important information from the response.
The RAG retrieval config is controlled via config.yml:
rag:
enabled: true
top_k: 5
min_score: 0.7
inject_into_prompt: true
Calling RagRetrievalService Directly
Construct the service over a SanctumPort and EmbeddingPort, optionally inject an exact
token counter via with_token_counter (the default is a heuristic estimator), and call the
timeout-bounded retrieval entry point, retrieve_context_with_timeout:
use paladin_memory::sanctum::InMemorySanctum;
use paladin_memory::services::rag_retrieval_service::{
RagConfig, RagRetrievalService, retrieve_context_with_timeout,
};
use paladin_memory::token_counter::HeuristicTokenCounter;
use paladin_ports::output::embedding_port::{Embedding, EmbeddingError, EmbeddingPort};
use paladin_ports::output::sanctum_port::SanctumPort;
/// A deterministic, no-network embedder — enough to drive the RAG example without a
/// real embedding provider.
struct MockEmbedder;
#[async_trait]
impl EmbeddingPort for MockEmbedder {
async fn embed_text(&self, text: &str) -> Result<Embedding, EmbeddingError> {
Ok(Embedding {
vector: vec![0.0_f32; 8],
model: "mock-embedder".to_string(),
dimension: 8,
token_count: Some(text.split_whitespace().count() as u32),
})
}
async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Embedding>, EmbeddingError> {
let mut out = Vec::with_capacity(texts.len());
for text in texts {
out.push(self.embed_text(text).await?);
}
Ok(out)
}
fn dimension(&self) -> usize {
8
}
fn model_name(&self) -> &str {
"mock-embedder"
}
}
/// Build a `RagRetrievalService` (camelCase `Rag`, not `RAG`) over an in-memory
/// Sanctum, inject an exact token counter via `with_token_counter`, and call the
/// timeout-bounded retrieval entry point. Returns a `RagRetrievalResult` — the
/// retained memories plus the Commissary's `shed: Vec<ShedItem>` accounting for
/// anything dropped to fit the token budget.
pub async fn retrieve_with_timeout() -> Result<(), Box<dyn std::error::Error>> {
let sanctum: Arc<dyn SanctumPort> = Arc::new(InMemorySanctum::new(1_000));
let embedding: Arc<dyn EmbeddingPort> = Arc::new(MockEmbedder);
let service = RagRetrievalService::new(sanctum, embedding, RagConfig::default())
.with_token_counter(Arc::new(HeuristicTokenCounter));
let result =
retrieve_context_with_timeout(&service, "agent-1", "memory safety in Rust", 5).await?;
println!(
"retained={} shed={}",
result.memories.len(),
result.shed.len()
);
Ok(())
}
retrieve_context_with_timeout wraps RagRetrievalService::retrieve_context, which rations
the ranked search results through the Commissary and returns a RagRetrievalResult:
pub struct RagRetrievalResult {
pub memories: Vec<RagRetainedMemory>, // retained, descending relevance order
pub shed: Vec<ShedItem>, // memories dropped entirely to fit the budget
pub prompt_tokens: u32,
pub allotted_tokens: u32,
pub exact_tally: bool,
}
Every memory that does not survive rationing appears in shed, labelled by its memory UUID —
nothing is dropped silently. Retrieval failures surface as a typed RagRetrievalError
(Sanctum, Commissary, BudgetTooLarge, UnmatchedDispensedLabel, DuplicateMemoryId).
Rendering the Result — the Omission Marker
RagRetrievalService::format_for_prompt renders a RagRetrievalResult into prompt context —
the same renderer the facade's PaladinExecutionService::format_retrieved_context mirrors, so
the two can never drift — and appends a trailing omission-marker line whenever shed is
non-empty, naming how many lower-relevance memories were dropped entirely and the token budget
they were rationed against:
use paladin_memory::services::rag_retrieval_service::{
RagRetrievalResult, RagRetrievalService as Service,
};
/// Render a `RagRetrievalResult` into prompt context exactly as
/// `RagRetrievalService::format_for_prompt` does — the same renderer the facade's
/// `PaladinExecutionService::format_retrieved_context` mirrors — appending the shared
/// RAG omission marker whenever memories were shed to stay within the token budget.
pub fn format_result(service: &Service, result: &RagRetrievalResult) -> String {
service.format_for_prompt(result)
}
Attaching to a Paladin
use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin_memory::sanctum::QdrantSanctumAdapter;
use std::sync::Arc;
let sanctum = Arc::new(
QdrantSanctumAdapter::new("http://localhost:6334", "memories", 1536).await?
);
let embedder = Arc::new(openai_embedder());
let paladin = PaladinBuilder::new(llm_port)
.system_prompt("You are a knowledge-augmented assistant.")
.with_sanctum(sanctum)
.with_embedding_port(embedder)
.build()
.await?;
Docker Setup (Qdrant)
The development Docker Compose stack includes Qdrant:
make dev # Starts Redis, MinIO, MySQL, and Qdrant
# or individually:
docker run -p 6334:6334 -p 6333:6333 qdrant/qdrant
Default connection: http://localhost:6334 (gRPC) / http://localhost:6333 (REST dashboard).
config.yml Reference
sanctum:
type: qdrant # "qdrant" or "in_memory"
url: "http://localhost:6334"
collection: paladin_memories
vector_dimension: 1536 # Must match embedding model output dimension
rag:
enabled: true
top_k: 5 # Number of similar memories to retrieve
min_score: 0.7 # Minimum cosine similarity threshold
inject_into_prompt: true
memory_extraction:
enabled: true
strategy: selective # "all" or "selective"
Error Handling
SanctumError variants:
| Variant | Cause | Recovery |
|---|---|---|
StorageError(String) | Qdrant unavailable / capacity | Check Qdrant status |
SearchError(String) | Invalid query / timeout | Reduce top_k, check query embedding |
DimensionMismatch { expected, got } | Wrong embedding size | Ensure all vectors match vector_dimension |
NotFound | Entry ID does not exist | Expected on first access |
ConfigError(String) | Bad adapter configuration | Check URL and collection name |
Best Practices
- Match dimensions — set
vector_dimensionto exactly the output size of your embedding model (OpenAItext-embedding-3-small= 1536,text-embedding-3-large= 3072). - Use
store_batch()when loading a knowledge base — it is significantly faster than individualstore()calls. - Set
min_scoreinSanctumQueryto filter out low-quality matches; 0.7 is a good starting point. - Separate collections per agent or per use-case to avoid cross-contamination in multi-agent systems.
- Use
InMemorySanctumin tests to avoid requiring a running Qdrant instance.