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 PromptEntity 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 });| Option | Default | Description |
|---|---|---|
model | — | Model to extract with; required with an LLMBackend (the constructor throws without it) |
minConfidence | 0.7 | Minimum confidence to keep a result |
maxEntitiesPerText | 20 | Max entities returned per text |
maxRelationsPerText | 30 | Max 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,
});| Option | Default | Description |
|---|---|---|
pool | — | A pg Pool (or compatible) |
schema | cogitator | Schema holding the graph tables |
vectorDimensions | 768 | Size 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}`);
}Semantic Node Search
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
| Rule | Pattern | Conclusion | Confidence |
|---|---|---|---|
| transitive_knows | A knows B, B knows C | A related_to C | 0.6 |
| colleagues | A works_at X, B works_at X | A associated_with B (colleague) | 0.8 |
| location_hierarchy | A located_in B, B located_in C | A located_in C | 0.9 |
| part_of_hierarchy | A part_of B, B part_of C | A part_of C | 0.85 |
| causality_chain | A causes B, B causes C | A 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.
| Option | Type | Default | Description |
|---|---|---|---|
maxNodes | number | 20 | Maximum nodes to include in context |
maxEdges | number | 50 | Maximum edges to include in context |
maxDepth | number | 3 | Graph traversal depth from matched nodes |
includeInferred | boolean | true | Include edges created by inference rules |
tokensPerNode | number | 30 | Estimated tokens per node for budget |
tokensPerEdge | number | 15 | Estimated 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.
Hybrid Search
Combine BM25 keyword search with vector similarity using Reciprocal Rank Fusion for accurate, recall-maximizing retrieval.
RAG Pipeline
Build retrieval-augmented generation pipelines with @cogitator-ai/rag — document loading, chunking, embedding, retrieval, and reranking in a single composable pipeline.