Agent Memory & RAG¶
An agent needs memory that outlives a session: notes, summaries, preferences, incident context. ApexBase keeps that memory in one local file and ranks it with SQL, BM25, and vector similarity in one engine. This page builds a tested memory store, wraps recall as an LLM tool, and bounds memory use.
Why A Local Memory Engine¶
A typical agent stack glues together a relational store, an FTS engine, a vector service, and application code that keeps them consistent. ApexBase collapses that stack into one embedded engine over one file.
| Concern | Glue stack | ApexBase |
|---|---|---|
| Storage | database + vector service | one .apex file, V5 format |
| Text ranking | external BM25 service | CREATE FTS INDEX, MATCH() |
| Vector ranking | external ANN service | vector column + topk_distance |
| Consistency | copy jobs, re-embedding | row writes update every index |
| Operations | server, ports, credentials | embedded library |
Back up or move one directory; the agent links the library, and PG Wire and
Flight are opt-in. One commit boundary covers rows, BM25, and vectors, so
BEGIN ... ROLLBACK discards a memory write consistently. Query budgets and
spill cap operator state, and results convert to Arrow, Pandas, and Polars.
Build A Memory Store¶
import numpy as np
from datetime import datetime, timezone
from apexbase import ApexClient
def toy_embed(text, dim=8):
vector = np.zeros(dim, dtype=np.float32)
for i in range(len(text) - 1):
vector[(ord(text[i]) * 31 + ord(text[i + 1])) % dim] += 1.0
norm = np.linalg.norm(vector)
return vector / norm if norm else vector
class MemoryStore:
def __init__(self, path, embed=toy_embed):
self.db = ApexClient(path, durability="safe")
if "memories" not in self.db.list_tables(): # idempotent open
self.db.create_table("memories", {
"content": "text", "source": "text",
"created_at": "text", "embedding": "float16_vector",
})
self.db.execute("CREATE FTS INDEX ON memories(content)")
self.db.use_table("memories")
self._embed = embed
def remember(self, content, source="manual"):
self.db.store([{
"content": content, "source": source,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"embedding": self._embed(content),
}])
def recall(self, query, k=5, source=None):
hits = self.db.hybrid_search(
self._embed(query), query, k=k * 4, # over-fetch for filtering
vector_col="embedding", metric="cosine",
)
rows = self.db.retrieve_many([int(i) for i in hits.get_ids()]).to_dict()
memories = [{key: row[key] for key in ("content", "source", "created_at")}
for row in rows]
if source is not None:
memories = [m for m in memories if m["source"] == source]
return memories[:k]
mem = MemoryStore("./agent-memory")
mem.remember("Blue-green rollback restores the previous release", source="runbook")
mem.remember("Postgres pool exhausted during incident 42", source="incident/42")
mem.remember("Retry policy caps backoff at 30 seconds", source="runbook")
for memory in mem.recall("how do we roll back a deploy?", k=2):
print(memory["content"], "|", memory["source"])
Blue-green rollback restores the previous release | runbook
Retry policy caps backoff at 30 seconds | runbook
What the class does:
- Persistent, idempotent open.
durability="safe"fsyncs writes an agent must not lose; thelist_tables()check creates the schema and FTS index only when the file is new, and FTS reconciles its generation with the table on reopen. - Schema.
content/sourceare text,created_atis ISO-8601 text, andembeddingisfloat16_vector(half the disk offloat32_vector). remember(). Onestore([{...}])row write updates FTS and the vector column together, so recall never sees a half-indexed row.recall().hybrid_searchfuses a cosinetopk_distancebranch and a BM25 branch on_id, thenretrieve_many()fetches the rows.k * 4over-fetches so a source filter still has candidates.
toy_embed is a deterministic placeholder; replace it with your model's
function and keep a fixed dimension.
Use a persistent database
In 2.0.0 hybrid_search needs the persistent vector-scan lane; an
ApexClient(":memory:") client raises
RuntimeError: topk_distance(raw=True): column 'embedding' is not served by
the direct vector scan.
Recency And Scope¶
created_at is ISO-8601 text with a UTC offset, so ORDER BY sorts
chronologically as long as every writer uses the same offset.
recent = mem.db.execute(
"SELECT content, source, created_at FROM memories "
"ORDER BY created_at DESC LIMIT 10").to_dict()
scoped = mem.db.execute(
"SELECT content, created_at FROM memories WHERE source = ? "
"ORDER BY created_at DESC", params=["runbook"]).to_dict()
replace() rewrites the whole row, so carry the fields you keep; delete()
removes by internal id.
row = mem.db.retrieve(2) # internal id from retrieve()/get_ids()
mem.db.replace(2, {"content": "redacted", "source": row["source"],
"created_at": row["created_at"], # keep the recency key
"embedding": mem._embed("redacted")})
mem.db.execute("DELETE FROM memories WHERE created_at < ?",
params=["2026-01-01T00:00:00+00:00"])
mem.db.delete(id=3)
Known issue: avoid SQL UPDATE on vector tables
On 2.0.0, a SQL UPDATE on a table with a vector column permanently
disables the direct vector scan used by hybrid_search() (and
topk_distance(raw=True)), even after flush() or reopen. Use replace()
or delete() plus a fresh store() for edits and forgetting. Non-raw
topk_distance() and SQL cosine_distance() keep working after an UPDATE.
For retention, delete by created_at; no index rebuild is needed.
Expose Recall As A Tool¶
Declare the tool once and keep the database boundary in your handler. No LLM
call happens here: your runtime parses the model's tool call and invokes
dispatch_tool().
import json
RECALL_TOOL = {
"name": "recall_memory",
"description": "Search the agent's long-term memory for relevant notes.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"k": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
"source": {"type": "string"},
},
"required": ["query"],
},
}
def dispatch_tool(mem, name, arguments):
if name != "recall_memory":
raise ValueError(f"unknown tool: {name}")
args = json.loads(arguments)
memories = mem.recall(args["query"], k=args.get("k", 5), source=args.get("source"))
return json.dumps({"memories": memories})
dispatch_tool(mem, "recall_memory", '{"query": "rollback", "k": 1}')
# -> {"memories": [{"content": "Blue-green rollback ...", "source": "runbook"}]}
Serve RECALL_TOOL with your model's tool definitions, validate arguments as
JSON, and return one JSON object per call. source scopes memory per user or
session without a second table.
Stream Large Tool Outputs¶
Do not materialize tool results that leave memory. execute_stream() yields
pyarrow.RecordBatch objects, one row group at a time:
import os
os.environ["APEX_QUERY_MEMORY_MB"] = "256" # per-query operator budget
os.environ["APEX_PROCESS_BUDGET_MB"] = "1024" # process admission budget
os.environ["APEX_SPILL_DIR"] = "/var/tmp/apex-spill"
from apexbase import ApexClient
db = ApexClient("./agent-memory", durability="safe")
db.use_table("memories")
for batch in db.execute_stream("SELECT _id, content FROM memories"):
for row in batch.to_pylist():
print(row["content"]) # hand one memory at a time to the model layer
db.close()
| Variable | Default | Effect |
|---|---|---|
APEX_QUERY_MEMORY_MB |
1024 |
Per-query operator budget; ORDER BY/GROUP BY/DISTINCT/JOIN spill instead of failing |
APEX_PROCESS_BUDGET_MB |
disabled | Process-wide admission budget with a bounded queue and explicit [APEX_ADMISSION_*] rejections |
APEX_SPILL_DIR |
temp apex_spill/ |
Root directory for out-of-core run files |
Set these before the client opens. Streaming covers plain projections;
ORDER BY, GROUP BY, and joins are delivered by their spill operators as one
batch. Prefer execute_stream() over execute() for large results.
Hybrid SQL For Structured Filters¶
When a filter belongs in SQL (hard scope, date range, join), rank directly.
MATCH() takes a string literal; cosine_distance() takes an inline array
literal, so build it from Python:
query = toy_embed("how do we roll back a deploy?")
vector_literal = "[" + ", ".join(f"{value:.6f}" for value in query) + "]"
rows = mem.db.execute(f"""
SELECT content, source,
cosine_distance(embedding, {vector_literal}) AS distance
FROM memories
WHERE MATCH('rollback') AND source = 'runbook'
ORDER BY distance LIMIT 5
""").to_dict()
The predicate constrains scope, BM25 selects text candidates, and
cosine_distance() orders the survivors. Bound lists expand into several
arguments, so inline the vector literal as shown.
Known issue: zero text candidates break cosine_distance
On 2.0.0, MATCH() combined with cosine_distance() raises
RuntimeError: array_distance: FixedSizeList dim 0 != query dim 8 when
MATCH() returns no rows: the empty candidate set reaches the distance
kernel as a zero-dimension list. Prefer hybrid_search for recall, or
guard the SQL path with search_text():
if len(mem.db.search_text("rollback")) == 0:
rows = []
else:
rows = mem.db.execute(f"""
SELECT content, source,
cosine_distance(embedding, {vector_literal}) AS distance
FROM memories
WHERE MATCH('rollback') AND source = 'runbook'
ORDER BY distance LIMIT 5
""").to_dict()
Choose A Retrieval Mode¶
| Mode | Use when | API |
|---|---|---|
| FTS only | Exact terms, names, error codes, quoted fragments | search_text(), MATCH(), FTS_SCORE() |
| Vector only | Paraphrases with no shared terms | topk_distance(), cosine_distance() |
| Hybrid | Free-form agent notes where terms and meaning both matter | hybrid_search() |
Start hybrid for recall, then measure. A hard keyword filter usually fits SQL
with MATCH(); a purely semantic query is cheaper with vector-only. For large
corpora, build an IVF index and pass nprobe= to topk_distance(); rebuild
when vector_index_info()["stale"] is true.
Where To Go Next¶
- Full-Text Search Guide for
MATCH()and FTS lifecycle. - Vector Quantization Guide and Float16 Vector Guide for vector storage.
- Python Client and SQL Guide for the APIs above.
- Performance for measured ingest and search numbers.
- Python API Reference for
hybrid_search.