# Knowledge Graphs

URL: https://cogitator.app/docs/memory/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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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`.

```typescript
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](#semantic-node-search). A node with `metadata.userId` is only put into that user's context (see [Multiple Users](/docs/advanced/multi-user)). Look nodes up with `getNode(id)` or `getNodeByName(agentId, name)`, and change them with `updateNode` / `updateEdge`, `deleteNode` / `deleteEdge`.

### Querying

```typescript
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:

```typescript
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):

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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

```typescript
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:

```typescript
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.

```typescript
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`](/docs/memory#context-building) to add graph context to every built prompt:

```typescript
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.
