Cogitator
Memory & RAG

Knowledge Graphs

Extract entities and relationships from conversations, store them in a graph, run inference rules, and enrich agent context with structured knowledge.

Overview

Knowledge graphs give agents structured memory beyond flat conversation history. Instead of treating past messages as opaque text, the knowledge graph extracts entities (people, organizations, concepts) and relationships (knows, works_at, part_of) into a queryable graph.

This enables agents to:

  • Answer questions about relationships between entities
  • Discover indirect connections through graph traversal
  • Infer new facts from existing relationships
  • Build richer context by injecting relevant graph data into prompts

Architecture

Conversation ──► LLMEntityExtractor ──► GraphAdapter (storage)
                                              │
                                    GraphInferenceEngine (rules)
                                              │
                                    GraphContextBuilder (retrieval)
                                              │
                                        Agent Prompt

Entity Extraction

The LLMEntityExtractor uses an LLM to identify entities and relationships from text. It prompts the model for JSON output (responseFormat: { type: 'json_object' }), then validates and filters the results.

Pass it a Cogitator LLMBackend together with the model to extract with:

import { createLLMBackend } from '@cogitator-ai/core';
import { LLMEntityExtractor } from '@cogitator-ai/memory';

const backend = createLLMBackend('google', {
  providers: { google: { apiKey: process.env.GOOGLE_API_KEY! } },
});

const extractor = new LLMEntityExtractor(backend, {
  model: 'gemini-3.5-flash-lite',
  minConfidence: 0.7,
  maxEntitiesPerText: 20,
  maxRelationsPerText: 30,
});

const result = await extractor.extract(
  'Alice works at Acme Corp in New York. She knows Bob from the engineering team.'
);

console.log(result.entities);
// [
//   { name: 'Alice', type: 'person', confidence: 0.95 },
//   { name: 'Acme Corp', type: 'organization', confidence: 0.95 },
//   { name: 'New York', type: 'location', confidence: 0.9 },
//   { name: 'Bob', type: 'person', confidence: 0.9 },
// ]

console.log(result.relations);
// [
//   { sourceEntity: 'Alice', targetEntity: 'Acme Corp', type: 'works_at', confidence: 0.95 },
//   { sourceEntity: 'Acme Corp', targetEntity: 'New York', type: 'located_in', confidence: 0.85 },
//   { sourceEntity: 'Alice', targetEntity: 'Bob', type: 'knows', confidence: 0.9 },
// ]

Entities and relations below minConfidence are dropped, as are relations whose endpoints are not among the extracted entities. The result also carries the input text and a timestamp. Use extractBatch(texts, context) for several texts.

Instead of a backend you can pass any object with a chat({ model?, messages, responseFormat }) method that resolves to { content } (LLMBackendMinimal), for example to post-process responses yourself. With this form model is optional; when you set it, it is passed through in each chat request:

import { LLMEntityExtractor, type LLMBackendMinimal } from '@cogitator-ai/memory';

const custom: LLMBackendMinimal = {
  async chat({ model, messages, responseFormat }) {
    const response = await backend.chat({
      model: model ?? 'gemini-3.5-flash-lite',
      messages,
      responseFormat,
    });
    return { content: response.content };
  },
};

const customExtractor = new LLMEntityExtractor(custom, { minConfidence: 0.6 });
OptionDefaultDescription
model—Model to extract with; required with an LLMBackend (the constructor throws without it)
minConfidence0.7Minimum confidence to keep a result
maxEntitiesPerText20Max entities returned per text
maxRelationsPerText30Max relations returned per text

Entity Types

person, organization, location, concept, event, object (plus custom for nodes you add yourself)

Relation Types

knows, works_at, located_in, part_of, related_to, created_by, belongs_to, associated_with, causes, precedes (plus custom)

Extraction Context

Provide hints to improve extraction quality:

const result = await extractor.extract(text, {
  agentId: 'agent-1',
  entityTypeHints: ['person', 'organization'],
  relationTypeHints: ['works_at', 'knows'],
  existingEntities: ['Alice', 'Acme Corp'],
});

Type hints replace the default type lists in the prompt; existing entities help the LLM disambiguate references and avoid creating duplicates.

Graph Adapters

Two adapters implement the GraphAdapter interface. Both create their graph_nodes and graph_edges tables on first use (or when you call initialize()), and both scope everything by agentId.

PostgresGraphAdapter

Stores nodes and edges in PostgreSQL, with semantic node search via pgvector and recursive traversal using CTEs. Pass it a pg pool:

import { PostgresGraphAdapter } from '@cogitator-ai/memory';
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL!,
});

const graph = new PostgresGraphAdapter({
  pool,
  schema: 'cogitator',
  vectorDimensions: 1536,
});
OptionDefaultDescription
pool—A pg Pool (or compatible)
schemacogitatorSchema holding the graph tables
vectorDimensions768Size of the node embedding column

The adapter does not create the schema or the vector extension: both must exist — PostgresAdapter.connect() creates them.

SQLiteGraphAdapter

Stores the graph in a SQLite file via better-sqlite3 (pnpm add better-sqlite3). Node embeddings are stored as JSON and searched with in-process cosine similarity.

import { SQLiteGraphAdapter } from '@cogitator-ai/memory';

const graph = new SQLiteGraphAdapter({ path: './graph.db', walMode: true });
await graph.initialize();

// ...

await graph.close();

path can be :memory:; walMode defaults to true (ignored for :memory:).

Adding Nodes and Edges

Node and edge methods return a MemoryResult — unwrap() it (imported from @cogitator-ai/memory) or check success. Both adapters report database errors as { success: false, error } rather than throwing, including failures inside traverse, findShortestPath and mergeNodes.

const alice = unwrap(
  await graph.addNode({
    agentId: 'agent-1',
    type: 'person',
    name: 'Alice',
    aliases: ['alice'],
    description: 'Engineer at Acme Corp',
    properties: { role: 'backend' },
    confidence: 0.95,
    source: 'extracted',
    metadata: { userId: 'alice' },
  })
);

const acme = unwrap(
  await graph.addNode({
    agentId: 'agent-1',
    type: 'organization',
    name: 'Acme Corp',
    aliases: ['Acme'],
    properties: {},
    confidence: 0.95,
    source: 'extracted',
  })
);

await graph.addEdge({
  agentId: 'agent-1',
  sourceNodeId: alice.id,
  targetNodeId: acme.id,
  type: 'works_at',
  weight: 1.0,
  bidirectional: false,
  properties: {},
  confidence: 0.95,
  source: 'extracted',
});

source is 'extracted', 'user' or 'inferred'. Pass embedding on a node to make it findable by semantic search. A node with metadata.userId is only put into that user's context (see Multiple Users). Look nodes up with getNode(id) or getNodeByName(agentId, name), and change them with updateNode / updateEdge, deleteNode / deleteEdge.

Querying

const people = unwrap(
  await graph.queryNodes({
    agentId: 'agent-1',
    types: ['person'],
    namePattern: 'Ali',
    minConfidence: 0.8,
    limit: 10,
  })
);

const edges = unwrap(
  await graph.queryEdges({
    agentId: 'agent-1',
    types: ['works_at', 'knows'],
    minConfidence: 0.7,
  })
);

const neighbors = unwrap(await graph.getNeighbors(alice.id, 'outgoing'));
const stats = unwrap(await graph.getGraphStats('agent-1'));

namePattern is a case-insensitive substring match. getNeighbors returns { node, edge } pairs, and getGraphStats returns node/edge counts by type. clearGraph(agentId) deletes an agent's whole graph.

Graph Traversal

Traverse the graph from a starting node, following edges up to a specified depth:

const traversal = unwrap(
  await graph.traverse({
    agentId: 'agent-1',
    startNodeId: alice.id,
    maxDepth: 2,
    direction: 'both',
    edgeTypes: ['knows', 'works_at'],
    minConfidence: 0.6,
  })
);

for (const path of traversal.paths) {
  const names = path.nodes.map((n) => n.name).join(' -> ');
  console.log(`${names} (weight: ${path.totalWeight})`);
}

console.log(traversal.visitedNodes.length, traversal.visitedEdges.length);

Shortest Path

Find the shortest path between two nodes (the last argument is the maximum depth):

const path = unwrap(await graph.findShortestPath('agent-1', alice.id, acme.id, 5));

if (path) {
  console.log(`Path length: ${path.length}, weight: ${path.totalWeight}`);
}

When nodes have embeddings, search by meaning. A vector is required:

const vector = await embeddingService.embed('backend engineering');
const nodes = unwrap(
  await graph.searchNodesSemantic({
    agentId: 'agent-1',
    vector,
    limit: 5,
    threshold: 0.7,
    entityTypes: ['person', 'concept'],
  })
);

Node Merging

Merge duplicate nodes into a target node: edges are redirected to it, the duplicates' names and aliases become its aliases, and the duplicates are deleted:

const merged = unwrap(await graph.mergeNodes(primaryNodeId, [duplicateId1, duplicateId2]));

Inference Engine

The GraphInferenceEngine applies rules to discover implicit relationships. It ships with default rules (pass false as the second constructor argument to start without them) and supports custom ones.

import { GraphInferenceEngine } from '@cogitator-ai/memory';

const engine = new GraphInferenceEngine(graph);

const inferred = await engine.infer('agent-1', {
  minConfidence: 0.6,
  maxInferences: 100,
});

console.log(`Found ${inferred.length} inferred relationships`);

infer() returns InferredEdge objects (edges with the ruleId and the supportingPath of edge ids). Pairs that already have an edge of the concluded type are skipped. Options: ruleIds (apply only these rules, enabled or not), minConfidence, maxInferences, and nodeFilter (only paths starting at these node ids). An inferred edge's confidence is the lowest of the rule's confidence and the confidences of the edges in its path.

Default Rules

RulePatternConclusionConfidence
transitive_knowsA knows B, B knows CA related_to C0.6
colleaguesA works_at X, B works_at XA associated_with B (colleague)0.8
location_hierarchyA located_in B, B located_in CA located_in C0.9
part_of_hierarchyA part_of B, B part_of CA part_of C0.85
causality_chainA causes B, B causes CA causes C (indirect)0.7

Custom Rules

const ruleId = engine.registerRule({
  name: 'team_membership',
  description: 'People who belong to the same team are associated',
  pattern: {
    edgeTypes: ['belongs_to', 'belongs_to'],
    minPathLength: 2,
    maxPathLength: 2,
    nodeTypeConstraints: {
      0: ['person'],
      1: ['concept'],
      2: ['person'],
    },
  },
  conclusion: {
    edgeType: 'associated_with',
    label: 'teammate',
    weightFormula: 'min',
    bidirectional: true,
  },
  confidence: 0.75,
  enabled: true,
});

Weight formulas: min, max, average, product — applied to the weights of edges in the matched path. registerRule returns the rule id; manage rules with getRules(), enableRule(id), disableRule(id) and removeRule(id).

Materializing Inferences

Save inferred edges back to the graph:

const materialized = unwrap(await engine.materialize(inferred));
console.log(`Materialized ${materialized.length} edges`);

Materialized edges keep source: 'inferred' and record the rule and supporting path in metadata.inferredBy / metadata.supportingPath. Edges that fail to insert are skipped.

Graph Context Builder

The GraphContextBuilder retrieves relevant graph data for a given input and formats it as structured text to inject into the agent's prompt.

import { GraphContextBuilder } from '@cogitator-ai/memory';

const graphContext = new GraphContextBuilder(graph, embeddingService, {
  maxNodes: 20,
  maxEdges: 50,
  maxDepth: 3,
});

const context = await graphContext.buildContext('agent-1', 'What does Alice do at Acme Corp?', {
  userId: 'alice',
});

console.log(context.formattedContext);
// ## Knowledge Graph Context
//
// ### Entities
// - **Alice** (Person): Engineer at Acme Corp [aka: alice]
// - **Acme Corp** (Organization) [aka: Acme]
//
// ### Relationships
// - Alice → works at → Acme Corp

console.log(`Nodes: ${context.nodes.length}, Edges: ${context.edges.length}`);
console.log(`Estimated tokens: ${context.tokenCount}`);

The embedding service is optional. With one, the builder first finds up to 5 nodes by semantic search (similarity ≥ 0.6); it then matches keywords from the input against node names and traverses outward from the matched nodes to gather related context. Nodes are ranked by name match, alias match, keyword overlap, access frequency, and confidence. tokenCount is an estimate based on tokensPerNode / tokensPerEdge.

buildContext also takes per-call options: maxNodes, maxEdges, maxDepth, includeInferred, entityTypes (for the semantic search) and userId — nodes whose metadata.userId belongs to another user, and edges touching them, are left out.

OptionTypeDefaultDescription
maxNodesnumber20Maximum nodes to include in context
maxEdgesnumber50Maximum edges to include in context
maxDepthnumber3Graph traversal depth from matched nodes
includeInferredbooleantrueInclude edges created by inference rules
tokensPerNodenumber30Estimated tokens per node for budget
tokensPerEdgenumber15Estimated tokens per edge for budget

Using Graph Context in the Context Builder

Pass the GraphContextBuilder to the memory ContextBuilder to add graph context to every built prompt:

import { ContextBuilder } from '@cogitator-ai/memory';

const builder = new ContextBuilder(
  {
    maxTokens: 8192,
    strategy: 'recent',
    includeGraphContext: true,
    graphContextOptions: { maxNodes: 15, includeInferred: false },
  },
  { memoryAdapter: memory, graphContextBuilder: graphContext }
);

const built = await builder.build({
  threadId: 'thread-1',
  agentId: 'agent-1',
  userId: 'alice',
  currentInput: 'Where does Alice work?',
});

console.log(built.graphContext?.formattedContext);

Graph context needs a currentInput, gets up to 15% of the token budget (the builder retries with fewer nodes when it does not fit), and is appended to the system message.

On this page