speed up batch writes

This commit is contained in:
Nicolò Boschi 2025-12-04 16:52:43 +01:00
parent 3402bf15ee
commit 91bc3b02bc
9 changed files with 66 additions and 56 deletions

View file

@ -84,7 +84,7 @@ class RecallRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={ model_config = ConfigDict(json_schema_extra={
"example": { "example": {
"query": "What did Alice say about machine learning?", "query": "What did Alice say about machine learning?",
"types": ["world", "bank"], "types": ["world", "interactions"],
"budget": "mid", "budget": "mid",
"max_tokens": 4096, "max_tokens": 4096,
"trace": True, "trace": True,
@ -417,7 +417,7 @@ class ReflectResponse(BaseModel):
{ {
"id": "456", "id": "456",
"text": "I discussed AI applications last week", "text": "I discussed AI applications last week",
"type": "bank" "type": "interactions"
} }
] ]
} }
@ -901,7 +901,7 @@ def _register_routes(app: FastAPI):
The type parameter is optional and must be one of: The type parameter is optional and must be one of:
- 'world': General knowledge about people, places, events, and things that happen - 'world': General knowledge about people, places, events, and things that happen
- 'bank': Memories about what the AI agent did, actions taken, and tasks performed - 'interactions': Memories about interactions, conversations, actions taken, and tasks performed
- 'opinion': The bank's formed beliefs, perspectives, and viewpoints - 'opinion': The bank's formed beliefs, perspectives, and viewpoints
Set include_entities=true to get entity observations alongside recall results. Set include_entities=true to get entity observations alongside recall results.
@ -914,10 +914,10 @@ def _register_routes(app: FastAPI):
try: try:
# Validate types # Validate types
valid_fact_types = ["world", "bank", "opinion"] valid_fact_types = ["world", "interactions", "opinion"]
# Default to world, agent, opinion if not specified (exclude observation by default) # Default to world, interactions, opinion if not specified (exclude observation by default)
fact_types = request.types if request.types else ["world", "bank", "opinion"] fact_types = request.types if request.types else ["world", "interactions", "opinion"]
for ft in fact_types: for ft in fact_types:
if ft not in valid_fact_types: if ft not in valid_fact_types:
raise HTTPException( raise HTTPException(

View file

@ -90,7 +90,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
search_result = await memory.recall_async( search_result = await memory.recall_async(
bank_id=bank_id, bank_id=bank_id,
query=query, query=query,
fact_type=["world", "bank", "opinion"], fact_type=["world", "interactions", "opinion"],
budget=Budget.LOW budget=Budget.LOW
) )

View file

@ -676,7 +676,7 @@ class MemoryEngine:
context: Context about when/why this memory was formed context: Context about when/why this memory was formed
event_date: When the event occurred (defaults to now) event_date: When the event occurred (defaults to now)
document_id: Optional document ID for tracking (always upserts if document already exists) document_id: Optional document ID for tracking (always upserts if document already exists)
fact_type_override: Override fact type ('world', 'bank', 'opinion') fact_type_override: Override fact type ('world', 'interactions', 'opinion')
confidence_score: Confidence score for opinions (0.0 to 1.0) confidence_score: Confidence score for opinions (0.0 to 1.0)
Returns: Returns:
@ -728,7 +728,7 @@ class MemoryEngine:
- "document_id" (optional): Document ID for this specific content item - "document_id" (optional): Document ID for this specific content item
document_id: **DEPRECATED** - Use "document_id" key in each content dict instead. document_id: **DEPRECATED** - Use "document_id" key in each content dict instead.
Applies the same document_id to ALL content items that don't specify their own. Applies the same document_id to ALL content items that don't specify their own.
fact_type_override: Override fact type for all facts ('world', 'bank', 'opinion') fact_type_override: Override fact type for all facts ('world', 'interactions', 'opinion')
confidence_score: Confidence score for opinions (0.0 to 1.0) confidence_score: Confidence score for opinions (0.0 to 1.0)
Returns: Returns:
@ -936,7 +936,7 @@ class MemoryEngine:
Args: Args:
bank_id: bank ID to recall for bank_id: bank ID to recall for
query: Recall query query: Recall query
fact_type: List of fact types to recall (e.g., ['world', 'bank']) fact_type: List of fact types to recall (e.g., ['world', 'interactions'])
budget: Budget level for graph traversal (low=100, mid=300, high=600 units) budget: Budget level for graph traversal (low=100, mid=300, high=600 units)
max_tokens: Maximum tokens to return (counts only 'text' field, default 4096) max_tokens: Maximum tokens to return (counts only 'text' field, default 4096)
Results are returned until token budget is reached, stopping before Results are returned until token budget is reached, stopping before
@ -2597,7 +2597,7 @@ Guidelines:
logger.info(f"[THINK] Search returned {len(all_results)} results") logger.info(f"[THINK] Search returned {len(all_results)} results")
# Split results by fact type for structured response # Split results by fact type for structured response
agent_results = [r for r in all_results if r.fact_type == 'bank'] agent_results = [r for r in all_results if r.fact_type == 'interactions']
world_results = [r for r in all_results if r.fact_type == 'world'] world_results = [r for r in all_results if r.fact_type == 'world']
opinion_results = [r for r in all_results if r.fact_type == 'opinion'] opinion_results = [r for r in all_results if r.fact_type == 'opinion']

View file

@ -61,7 +61,7 @@ class MemoryFact(BaseModel):
id: str = Field(description="Unique identifier for the memory fact") id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory") text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'bank', 'opinion', or 'observation'") fact_type: str = Field(description="Type of fact: 'world', 'interactions', 'opinion', or 'observation'")
entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact") entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact")
context: Optional[str] = Field(None, description="Additional context for the memory") context: Optional[str] = Field(None, description="Additional context for the memory")
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring") occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")

View file

@ -50,7 +50,7 @@ class Fact(BaseModel):
""" """
# Required fields # Required fields
fact: str = Field(description="Combined fact text: what | when | where | who | why") fact: str = Field(description="Combined fact text: what | when | where | who | why")
fact_type: Literal["world", "bank", "opinion"] = Field(description="Perspective: world/bank/opinion") fact_type: Literal["world", "interactions", "opinion"] = Field(description="Perspective: world/interactions/opinion")
# Optional temporal fields # Optional temporal fields
occurred_start: Optional[str] = None occurred_start: Optional[str] = None
@ -581,20 +581,20 @@ Text:
continue continue
# Critical field: fact_type # Critical field: fact_type
# LLM uses "assistant" but we convert to "bank" for storage # LLM uses "assistant" but we convert to "interactions" for storage
fact_type = llm_fact.get('fact_type') fact_type = llm_fact.get('fact_type')
# Convert "assistant" → "bank" for storage # Convert "assistant" → "interactions" for storage
if fact_type == 'assistant': if fact_type == 'assistant':
fact_type = 'bank' fact_type = 'interactions'
# Validate fact_type (after conversion) # Validate fact_type (after conversion)
if fact_type not in ['world', 'bank', 'opinion']: if fact_type not in ['world', 'interactions', 'opinion']:
# Try to fix common mistakes - check if they swapped fact_type and fact_kind # Try to fix common mistakes - check if they swapped fact_type and fact_kind
fact_kind = llm_fact.get('fact_kind') fact_kind = llm_fact.get('fact_kind')
if fact_kind == 'assistant': if fact_kind == 'assistant':
fact_type = 'bank' fact_type = 'interactions'
elif fact_kind in ['world', 'bank', 'opinion']: elif fact_kind in ['world', 'interactions', 'opinion']:
fact_type = fact_kind fact_type = fact_kind
else: else:
# Default to 'world' if we can't determine # Default to 'world' if we can't determine

View file

@ -529,53 +529,63 @@ async def create_semantic_links_batch(
raise raise
async def insert_entity_links_batch(conn, links: List[tuple], chunk_size: int = 5000): async def insert_entity_links_batch(conn, links: List[tuple], chunk_size: int = 50000):
""" """
Insert all entity links in bulk using unnest for efficiency. Insert all entity links using COPY to temp table + INSERT for maximum speed.
Uses PostgreSQL unnest() to insert many rows in a single query, Uses PostgreSQL COPY (via copy_records_to_table) for bulk loading,
which is much faster than executemany over high-latency connections. then INSERT ... ON CONFLICT from temp table. This is the fastest
method for bulk inserts with conflict handling.
Args: Args:
conn: Database connection conn: Database connection
links: List of tuples (from_unit_id, to_unit_id, link_type, weight, entity_id) links: List of tuples (from_unit_id, to_unit_id, link_type, weight, entity_id)
chunk_size: Number of rows per batch (default 5000) chunk_size: Number of rows per batch (default 50000)
""" """
if not links: if not links:
return return
import uuid as uuid_mod import uuid as uuid_mod
# Process in chunks to avoid query size limits # Create temp table for bulk loading
for i in range(0, len(links), chunk_size): await conn.execute("""
chunk = links[i:i + chunk_size] CREATE TEMP TABLE IF NOT EXISTS _temp_entity_links (
from_unit_id uuid,
to_unit_id uuid,
link_type text,
weight float,
entity_id uuid
) ON COMMIT DROP
""")
# Separate into arrays for unnest # Clear any existing data in temp table
from_ids = [] await conn.execute("TRUNCATE _temp_entity_links")
to_ids = []
link_types = []
weights = []
entity_ids = []
for from_id, to_id, link_type, weight, entity_id in chunk: # Convert links to proper format for COPY
from_ids.append(uuid_mod.UUID(from_id) if isinstance(from_id, str) else from_id) records = []
to_ids.append(uuid_mod.UUID(to_id) if isinstance(to_id, str) else to_id) for from_id, to_id, link_type, weight, entity_id in links:
link_types.append(link_type) records.append((
weights.append(weight) uuid_mod.UUID(from_id) if isinstance(from_id, str) else from_id,
entity_ids.append( uuid_mod.UUID(to_id) if isinstance(to_id, str) else to_id,
uuid_mod.UUID(str(entity_id)) if entity_id and not isinstance(entity_id, uuid_mod.UUID) link_type,
else entity_id weight,
) uuid_mod.UUID(str(entity_id)) if entity_id and not isinstance(entity_id, uuid_mod.UUID) else entity_id
))
# Use unnest to insert all rows in one query # Bulk load using COPY (fastest method)
await conn.execute( await conn.copy_records_to_table(
""" '_temp_entity_links',
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id) records=records,
SELECT * FROM unnest($1::uuid[], $2::uuid[], $3::text[], $4::float[], $5::uuid[]) columns=['from_unit_id', 'to_unit_id', 'link_type', 'weight', 'entity_id']
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING )
""",
from_ids, to_ids, link_types, weights, entity_ids # Insert from temp table with ON CONFLICT (single query for all rows)
) await conn.execute("""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
SELECT from_unit_id, to_unit_id, link_type, weight, entity_id
FROM _temp_entity_links
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""")
async def create_causal_links_batch( async def create_causal_links_batch(

View file

@ -75,7 +75,7 @@ class ExtractedFact:
This is the raw output from fact extraction before processing. This is the raw output from fact extraction before processing.
""" """
fact_text: str fact_text: str
fact_type: str # "world", "bank", "opinion", "observation" fact_type: str # "world", "interactions", "opinion", "observation"
entities: List[str] = field(default_factory=list) entities: List[str] = field(default_factory=list)
occurred_start: Optional[datetime] = None occurred_start: Optional[datetime] = None
occurred_end: Optional[datetime] = None occurred_end: Optional[datetime] = None

View file

@ -104,7 +104,7 @@ class MemoryUnit(Base):
name="memory_units_document_fkey", name="memory_units_document_fkey",
ondelete="CASCADE", ondelete="CASCADE",
), ),
CheckConstraint("fact_type IN ('world', 'bank', 'opinion', 'observation')"), CheckConstraint("fact_type IN ('world', 'interactions', 'opinion', 'observation')"),
CheckConstraint("confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)"), CheckConstraint("confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)"),
CheckConstraint( CheckConstraint(
"(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR " "(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "

View file

@ -1141,7 +1141,7 @@ provides-extras = ["test"]
[[package]] [[package]]
name = "hindsight-api" name = "hindsight-api"
version = "0.0.18" version = "0.0.17"
source = { editable = "hindsight-api" } source = { editable = "hindsight-api" }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
@ -1243,7 +1243,7 @@ dev = [
[[package]] [[package]]
name = "hindsight-client" name = "hindsight-client"
version = "0.0.18" version = "0.0.17"
source = { editable = "hindsight-clients/python" } source = { editable = "hindsight-clients/python" }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@ -1275,7 +1275,7 @@ provides-extras = ["test"]
[[package]] [[package]]
name = "hindsight-dev" name = "hindsight-dev"
version = "0.0.18" version = "0.0.17"
source = { editable = "hindsight-dev" } source = { editable = "hindsight-dev" }
dependencies = [ dependencies = [
{ name = "hindsight-api" }, { name = "hindsight-api" },