A knowledge-grounded agent answers from curated sources before consulting conversational memory. This pattern uploads canonical knowledge - product data, policies, technical documentation - as documents, and relies on provenance to stop a conversation from silently replacing what a document says.
Why ground agents in documents
Without a knowledge source, agents hallucinate or rely on stale training data for domain-specific questions. The common workaround - embedding documents in a vector store and retrieving chunks - improves recall but loses structure and provenance.
Uploading documents to SurrealDB Agent Memory gives you:
Structured facts - extraction turns each document into entities and attributes in the same graph as conversational memory, and keeps keyword-indexed passages for search.
Provenance - every fact records the kind of source it came from, such as
documentorturn.Flagged contradictions - when a conversation contradicts a fact from a document, both values are kept and an
uncertaintyis raised, rather than one silently replacing the other.Content addressing - uploading the same document twice is idempotent.
Step 1 - Upload the knowledge
Documents are uploaded as multipart/form-data, with the bytes in a file part. The title, source and other metadata go in a metadata part as one JSON object (title, source, mimeType, scopes, labels, observedAt). Separate form fields such as title are ignored.
import json
import os
import httpx
client = httpx.Client(
base_url=f"{os.environ['AGENT_MEMORY_ENDPOINT']}/api/v1/my-context",
headers={"Authorization": f"Bearer {os.environ['AGENT_MEMORY_API_KEY']}"},
)
# Upload a product specification as a JSON document
with open("product-specs.json", "rb") as f:
client.post(
"/documents",
data={"metadata": json.dumps({"title": "Product specifications"})},
files={"file": ("product-specs.json", f, "application/json")},
)
# Upload a policy document as Markdown
with open("returns-policy.md", "rb") as f:
client.post(
"/documents",
data={"metadata": json.dumps({"title": "Returns policy"})},
files={"file": ("returns-policy.md", f, "text/markdown")},
)const formData = new FormData();
formData.append("metadata", JSON.stringify({ title: "Product specifications" }));
formData.append(
"file",
new Blob([productSpecsJson], { type: "application/json" }),
"product-specs.json",
);
await fetch(`${process.env.AGENT_MEMORY_ENDPOINT}/api/v1/my-context/documents`, {
method: "POST",
headers: { "Authorization": `Bearer ${process.env.AGENT_MEMORY_API_KEY}` },
body: formData,
});The SDKs build the metadata part for you: memory.documents.upload("returns-policy.md", title="Returns policy") in Python.
Step 2 - Query the documents in the agent loop
Before generating a response, search the documents for relevant passages. Use documents.query for natural-language search over document chunks, or an entity read for exact lookups.
from surrealdb.memory import AsyncMemory
memory = AsyncMemory(context="my-context", api_key="sp-...")
async def grounded_response(session, user_message: str) -> str:
# Search the uploaded documents for relevant passages
knowledge = await memory.documents.query(
user_message,
mode="hybrid",
k=4,
)
# Retrieve the session's memory context for personalisation
ctx = await memory.sessions.context(session.id, user_message)
# Assemble the prompt with document passages first
system = "You are a product assistant. Answer from the knowledge provided."
# Each hit carries the matched chunk, its document and a score
if knowledge.results:
system += "\n\n## Reference documents\n"
for hit in knowledge.results:
system += f"\n{hit.chunk['text']} (source: {hit.document['title']})"
# Session context is one string, ready to place in a prompt
if ctx.context:
system += f"\n\n## User context\n{ctx.context}"
response = your_llm(system=system, user=user_message)
await memory.remember(user_message, session_id=session.id, role="user")
await memory.remember(response, session_id=session.id, role="assistant")
return responseasync function groundedResponse(session: Session, userMessage: string): Promise<string> {
const [knowledge, ctx] = await Promise.all([
memory.documents.query({ query: userMessage, mode: "hybrid", k: 4 }),
session.context({ query: userMessage }),
]);
let system = "You are a product assistant. Answer from the knowledge provided.";
if (knowledge.results.length > 0) {
system += "\n\n## Reference documents\n";
for (const hit of knowledge.results) {
system += `\n${hit.chunk.text} (source: ${hit.document.title})`;
}
}
if (ctx.context) {
system += `\n\n## User context\n${ctx.context}`;
}
const response = await yourLlm({ system, user: userMessage });
await memory.remember(userMessage, { sessionId: session.id, role: "user" });
await memory.remember(response, { sessionId: session.id, role: "assistant" });
return response;
}Step 3 - How documents and conversation meet
Extraction from documents and extraction from conversation write to the same entity graph. An entity is identified by its type and name, so when a user mentions a product that a document describes, both sets of facts attach to one entity, such as product/airpods_pro. Entity types come from a fixed lower-case set, and product, policy and organisation are all part of it.
For example, after the catalogue upload and a conversation in which Alice says she bought AirPods Pro, one entity read shows both sources:
GET /api/v1/{context_id}/entities/product/airpods_pro{
"entity": { "name": "airpods_pro", "entityType": "product", "memoryCategory": "knowledge" },
"attributes": [
{
"entity": "entity:product/airpods_pro",
"key": "price",
"value": "279",
"source": { "kind": "document", "title": "Product specifications" }
},
{
"entity": "entity:product/airpods_pro",
"key": "return_window_days",
"value": "30",
"source": { "kind": "document", "title": "Product specifications" }
}
],
"relations": [
{
"subject": "entity:person/alice",
"object": "entity:product/airpods_pro",
"label": "owns",
"source": { "kind": "turn" }
}
],
"truncated": { "attributes": false, "relations": false }
}The response is shortened: each entity, attribute and relation also carries its id, timestamps, confidence, scope and the rest of its source reference.
At retrieval time, a query about the user's AirPods returns the facts from the conversation (the user owns them) and the facts from the product catalogue (the specification) together. Each fact carries its source.kind, so the agent can tell which is which.
Step 4 - Handling conflicts between documents and conversation
When a user asserts something that contradicts a fact from a document, SurrealDB Agent Memory does not let either value silently replace the other. A contradiction between two kinds of source writes both values and raises an uncertainty. A new value from the same kind of source supersedes the old one as usual.
Example: the returns policy says 30 days, and a user says "I thought it was 60 days".
The reconciliation pipeline:
Writes
return_window_days: 60on the policy entity, with source kindturn, without superseding the document's value.Keeps
return_window_days: 30, with source kinddocument, as a current value too.Raises an
uncertainty, returned in the write'sextraction.uncertaintiesas{about, reason}:
{
"about": "entity.return_window_days (document vs turn)",
"reason": "cross-provenance contradiction: prior document fact = \"30\"; new turn fact = \"60\""
}Open uncertainties are listed by GET /api/v1/{context_id}/uncertainty. To settle one, send the value to keep to POST /api/v1/{context_id}/uncertainty/{id}/resolve as {"acceptedValue": "30"}. Until then, the agent can see both values and their sources, and can tell the user what the policy document says.
Exact entity retrieval
For lookups where you know the entity type and name (from a structured UI, a product SKU field, etc.), read the entity directly instead of searching. This hits GET /entities/{entity_type}/{entity_name}, not the document store - documents.get takes a document id.
entity = await memory.entities.get("product", "airpods_pro")const entity = await memory.entities.get("product", "airpods_pro");This is faster than semantic search and appropriate when the reference is unambiguous.