Skip to content

Patterns

Knowledge-grounded agents

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.

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 document or turn.

  • Flagged contradictions - when a conversation contradicts a fact from a document, both values are kept and an uncertainty is raised, rather than one silently replacing the other.

  • Content addressing - uploading the same document twice is idempotent.

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.

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 response
async 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;
}

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
Sample output
{
  "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.

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:

  1. Writes return_window_days: 60 on the policy entity, with source kind turn, without superseding the document's value.

  2. Keeps return_window_days: 30, with source kind document, as a current value too.

  3. Raises an uncertainty, returned in the write's extraction.uncertainties as {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.

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.

Was this page helpful?