Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,49 @@ DOCUMENTDB_DB=copilot_memory
## Logging level: trace, debug, info, warn, error, fatal.
MEMORY_LOG_LEVEL=info

## ─────────────────────────────────────────────────────────────────────────
## DocumentDB Search (embedding-backed vector search)
## ─────────────────────────────────────────────────────────────────────────
## Adds semantic recall to the knowledge-graph `search_nodes` tool: entity
## text is embedded and indexed as a vector in DocumentDB, then blended with
## the existing keyword (`$text`) search via reciprocal-rank fusion. Entirely
## optional — if the provider is `none` or the embedding backend is
## unreachable, search transparently falls back to keyword-only.

## Embedding provider: local | ollama | openai | azure-openai | none
## `local` and `ollama` both talk to a local Ollama server. Default: local.
MEMORY_EMBEDDING_PROVIDER=local

## Embedding model. Defaults per provider:
## local / ollama -> nomic-embed-text
## openai / azure-openai -> text-embedding-3-small
# MEMORY_EMBEDDING_MODEL=nomic-embed-text

## Base URL override for local/ollama (default http://localhost:11434)
## or openai (default https://api.openai.com/v1).
# MEMORY_EMBEDDING_BASE_URL=http://localhost:11434

## Credentials for hosted providers.
# MEMORY_EMBEDDING_API_KEY=
## Azure OpenAI: full resource endpoint, e.g. https://my-res.openai.azure.com
# MEMORY_EMBEDDING_ENDPOINT=
# MEMORY_EMBEDDING_API_VERSION=2024-02-01

## Optional: force the vector dimensionality. Normally probed from the model
## at startup; set only if you need to override (mismatch logs a warning).
# MEMORY_EMBEDDING_DIMENSIONS=

## Vector index tuning (DocumentDB cosmosSearch).
## Index kind: vector-ivf (default) | vector-hnsw
# MEMORY_VECTOR_INDEX_KIND=vector-ivf
## Similarity metric: COS (default) | L2 | IP — must match the model's vectors.
# MEMORY_VECTOR_SIMILARITY=COS
## IVF only: number of lists (default 100).
# MEMORY_VECTOR_NUM_LISTS=100
## HNSW only: graph degree M (default 16) and construction ef (default 64).
# MEMORY_VECTOR_HNSW_M=16
# MEMORY_VECTOR_HNSW_EF_CONSTRUCTION=64

## Sync daemon polling interval (used by `documentdb-memory sessions sync --watch`).
## Accepts durations like 10s, 30s, 1m, 5m.
SYNC_INTERVAL=30s
Expand Down
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ ones that come up daily:
| `COPILOT_SESSION_STORE` | `~/.copilot/session-store.db` | SQLite source for the sync daemon. |
| `MEMORY_LOG_LEVEL` | `info` | Pino log level (`debug` is useful when things misbehave). |
| `SYNC_INTERVAL` | `30s` | How often `sessions sync --watch` polls. |
| `MEMORY_EMBEDDING_PROVIDER` | `local` | DocumentDB Search backend (`local`/`ollama`/`openai`/`azure-openai`/`none`). |

The compose-managed DocumentDB enforces TLS server-side with a
self-signed cert, so the internal URI looks like
Expand All @@ -158,14 +159,88 @@ The same database reached from the host (where TLS isn't required) is
`mongodb://localadmin:Admin100@localhost:10260/?tls=false`. The compose
stack uses the first; the deploy templates default to the second.

## DocumentDB Search — semantic recall for the knowledge graph

By default the `search_nodes` tool matches entities by keyword (a Mongo
`$text` index). **DocumentDB Search** adds an embedding-backed vector index
on top and blends the two, so a query recalls entities that are *semantically*
related even when they share no words with the query — e.g. searching `k8s`
surfaces an entity named `Kubernetes`. This follows the hybrid dense-vector +
knowledge-graph retrieval model popularized by
[OmniMem](https://omnimem.org).

It is **entirely optional and degrades gracefully**: if the provider is
`none`, has no credentials, or the embedding backend is unreachable, search
silently falls back to keyword-only and entities are still stored. This keeps
the server a drop-in replacement for the upstream memory server.

### How it works

- On `create_entities` / `add_observations`, the entity's name + type +
observations are embedded and stored alongside the document.
- A DocumentDB vector index (`cosmosSearch`) is created automatically at
startup when an embedder is available.
- `search_nodes` runs the keyword search and the vector kNN search in
parallel, then fuses the two ranked lists with reciprocal-rank fusion (RRF).
The relation-containment rule (returned relations only reference returned
entities) is preserved.

### Enabling it

Pick an embedding provider via `MEMORY_EMBEDDING_PROVIDER` (default `local`):

| Provider | Backend | Needs |
| -------------- | ----------------------------------------- | ---------------------------------------- |
| `local` / `ollama` | Local [Ollama](https://ollama.com) server | Ollama running (default `nomic-embed-text`) |
| `openai` | OpenAI embeddings API | `MEMORY_EMBEDDING_API_KEY` |
| `azure-openai` | Azure OpenAI deployment | `MEMORY_EMBEDDING_ENDPOINT` + `MEMORY_EMBEDDING_API_KEY` |
| `none` | disabled (keyword-only) | — |

For the default local setup, the `compose.full.yml` stack already bundles an
`ollama` service and pulls `nomic-embed-text` automatically before the MCP
server starts — so DocumentDB Search works out of the box:

```bash
docker compose -f compose.full.yml up -d
```

The MCP container reaches it over the compose network at
`http://ollama:11434` (not `localhost`, which inside a container would point
at the container itself). Models persist in the `ollama-models` volume.

Running the MCP server as a **host binary** instead? Point it at a local
Ollama yourself:

```bash
ollama pull nomic-embed-text # one-time
# MEMORY_EMBEDDING_PROVIDER=local is already the default (localhost:11434)
documentdb-memory-mcp
```

All embedding/index knobs (`MEMORY_EMBEDDING_*`, `MEMORY_VECTOR_*`) are
documented inline in [`.env.example`](./.env.example).

### Backfilling existing entities

Entities created before DocumentDB Search was enabled (or before you switched
models) have no vector yet. Backfill them with the CLI:

```bash
# embed only entities missing / stale vectors
documentdb-memory graph reembed

# force re-embed every entity (e.g. after changing model)
documentdb-memory graph reembed --all
```

## What's where

```
.
├── README.md ← you are here
├── .env.example ← every config knob, commented
├── compose.yml ← DocumentDB + DocumentDBFUSE
├── compose.full.yml ← + MCP server + sync daemon
├── compose.full.yml ← + MCP server + sync daemon + ollama
├── compose.fuse-host-bind.yml ← overlay to bind FUSE to host
├── compose.dev.yml ← live-reload variant for hacking on src/
├── deploy/ ← launchd + systemd templates for host install
Expand Down
70 changes: 70 additions & 0 deletions compose.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
# ~/.copilot/session-store.db into
# DocumentDB on a poll interval.
#
# ollama — local embedding backend for DocumentDB
# Search. The MCP server's default
# `local` provider talks to it over the
# compose network at http://ollama:11434.
#
# ollama-pull — one-shot init container that pulls the
# embedding model into the shared ollama
# volume, then exits. The MCP server waits
# for it to finish before starting.
#
# Bring up:
#
# docker compose -f compose.full.yml up -d
Expand Down Expand Up @@ -55,6 +65,11 @@ services:
depends_on:
documentdb:
condition: service_started
# Block startup until the embedding model has been pulled, so the
# first search already benefits from DocumentDB Search instead of
# silently falling back to keyword-only on a cold model cache.
ollama-pull:
condition: service_completed_successfully
environment:
# Hardcoded to the in-network service name so this works even when
# the user's .env has a localhost-based DOCUMENTDB_URI for
Expand All @@ -63,6 +78,12 @@ services:
DOCUMENTDB_URI: mongodb://localadmin:Admin100@documentdb:10260/?directConnection=true&tls=true&tlsInsecure=true
DOCUMENTDB_DB: ${DOCUMENTDB_DB:-copilot_memory}
MEMORY_LOG_LEVEL: ${MEMORY_LOG_LEVEL:-info}
# DocumentDB Search: the `local` provider points at the in-network
# ollama service (NOT localhost — that would resolve to this
# container). Override MEMORY_EMBEDDING_PROVIDER=none to disable.
MEMORY_EMBEDDING_PROVIDER: ${MEMORY_EMBEDDING_PROVIDER:-local}
MEMORY_EMBEDDING_MODEL: ${MEMORY_EMBEDDING_MODEL:-nomic-embed-text}
MEMORY_EMBEDDING_BASE_URL: ${MEMORY_EMBEDDING_BASE_URL:-http://ollama:11434}
# No `ports:` on purpose — the MCP protocol speaks stdio, not TCP.
# External clients attach via `docker exec -i`.
stdin_open: true
Expand Down Expand Up @@ -114,3 +135,52 @@ services:
# source path (e.g. to point at a non-default Copilot home).
- ${COPILOT_DIR:-${HOME}/.copilot}:/copilot:ro
restart: unless-stopped

ollama:
# Local embedding backend for DocumentDB Search. CPU-only by default;
# nomic-embed-text is tiny (~275MB) so CPU inference is plenty fast
# for the short entity strings we embed. To use a host GPU, add a
# `deploy.resources.reservations.devices` block per the ollama docs.
image: ollama/ollama:latest
container_name: documentdb-agentic-memory-ollama
# No host `ports:` — only the in-network services need it. Uncomment
# to also reach it from the host (e.g. for `ollama` CLI debugging):
# ports:
# - "11434:11434"
volumes:
# Persist pulled models across restarts so we don't re-download on
# every `up`.
- ollama-models:/root/.ollama
healthcheck:
# `ollama list` exits non-zero until the server is accepting API
# calls, which is exactly the readiness signal we want.
test: ["CMD", "ollama", "list"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
restart: unless-stopped

ollama-pull:
# One-shot: wait for ollama to be healthy, pull the embedding model
# into the shared volume, then exit 0. The MCP server depends on this
# completing successfully (service_completed_successfully), so the
# first request already has a warm model instead of racing the pull.
image: ollama/ollama:latest
container_name: documentdb-agentic-memory-ollama-pull
depends_on:
ollama:
condition: service_healthy
environment:
# Point the ollama CLI at the server container rather than its own
# (unstarted) localhost daemon.
OLLAMA_HOST: http://ollama:11434
entrypoint:
- /bin/sh
- -c
- ollama pull "${MEMORY_EMBEDDING_MODEL:-nomic-embed-text}"
restart: "no"

volumes:
ollama-models:
name: documentdb-agentic-memory-ollama-models
65 changes: 59 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ differs.
Indexes (declared in `src/storage/graph/schema.ts`):

- `graph_entities`: text index over `_id`, `entityType`, `observations.text`
(powers `search_nodes`); `entityType` ascending; `updatedAt` ascending.
(powers keyword `search_nodes`); `entityType` ascending; `updatedAt`
ascending. When DocumentDB Search is enabled, a `cosmosSearch` vector index
over the `embedding` field is also created (see below).
- `graph_relations`: `from`, `to`, `relationType`, `createdAt` (each
separately, ascending).

Expand All @@ -91,14 +93,56 @@ delete_entities delete_relations delete_observations
read_graph search_nodes open_nodes
```

`search_nodes` uses the text index; results include any relation whose
**both** endpoints are in the match set — the same containment rule the
upstream server applies.
`search_nodes` matches on the text index by default. When **DocumentDB
Search** is enabled it becomes a hybrid search (keyword + vector, fused with
RRF — see below). Either way, results include any relation whose **both**
endpoints are in the match set — the same containment rule the upstream
server applies.

Writes are race-safe: `createEntities` / `createRelations` use
`insertMany({ ordered: false })` and swallow E11000 duplicate-key errors,
so concurrent sessions adding the same entity converge instead of crashing.

### DocumentDB Search (embedding-backed vector search)

An optional layer that adds semantic recall to `search_nodes`, modeled on the
hybrid dense-vector + knowledge-graph retrieval used by
[OmniMem](https://omnimem.org). It is **off-by-default-safe**: if no embedder
is configured or the backend is unreachable, everything degrades to the
keyword-only path above and entities are still stored, preserving drop-in
compatibility with the upstream server.

**Data model.** `EntityDoc` gains three optional fields
(`src/storage/graph/schema.ts`): `embedding` (the vector), `embeddingModel`
(which model produced it, so stale vectors can be detected), and
`embeddingText` (the exact text embedded — name + type + observations joined
by newline, via `entityEmbeddingText()`).

**Embedders.** A pluggable `Embedder` interface
(`src/shared/embeddings/`) with three HTTP implementations — Ollama
(local, default), OpenAI, and Azure OpenAI — selected by
`MEMORY_EMBEDDING_PROVIDER`. `createEmbedder()` is the factory: it probes the
backend once at startup to learn the vector dimensionality and **returns
`null` (never throws)** when disabled, misconfigured, or offline. The
providers are dependency-free (global `fetch`).

**Index.** When an embedder is present the store issues a `createIndexes`
command with `key: { embedding: "cosmosSearch" }` and `cosmosSearchOptions`
(`kind`, `similarity`, `dimensions`, plus `numLists` for IVF or
`m`/`efConstruction` for HNSW). This is DocumentDB's native vector index; the
`cosmosSearch` token is a wire-level name only — the feature is called
DocumentDB Search everywhere else.

**Query.** `searchNodes` runs the keyword search (`$text`) and the vector kNN
search (`$search: { cosmosSearch: { vector, path, k } }`) in parallel, then
fuses the two ranked ID lists with **reciprocal-rank fusion** (`fuseRankings`,
`RRF_K = 60`). A vector-query failure is caught and treated as an empty vector
list, so hybrid search never regresses below keyword-only.

**Backfill.** `documentdb-memory graph reembed [--all]` re-embeds existing
entities (only stale/missing vectors, or all) after enabling the feature or
switching models.

## Track 2: session-history (`history_*`)

Mirror of the SQLite database that Copilot CLI writes at
Expand Down Expand Up @@ -166,7 +210,9 @@ start it:

1. Loads `.env` via `dotenv` (best-effort).
2. Opens one shared `MongoClient` (`src/shared/mongo.ts`).
3. Runs the index bootstrap for both stores (idempotent).
3. Runs the index bootstrap for both stores (idempotent). When an embedding
provider is configured and reachable, this also builds the DocumentDB
Search vector index.
4. Registers all 17 tools (9 graph + 8 history) via the MCP SDK.
5. Installs SIGINT/SIGTERM handlers for clean shutdown.

Expand Down Expand Up @@ -197,7 +243,7 @@ appetite for containerization:
docker compose -f compose.full.yml up -d
```

Brings up four services:
Brings up all services on the `copilot-memory-net` bridge:

- `documentdb` — `ghcr.io/documentdb/documentdb/documentdb-local:latest`
with `--username localadmin --password Admin100` passed as CLI args
Expand All @@ -211,6 +257,12 @@ Brings up four services:
- `documentdb-memory-sync` — same image, runs
`documentdb-memory sessions sync --watch`; mounts your `~/.copilot`
directory read-only at `/copilot`.
- `ollama` — local embedding backend for DocumentDB Search, reached at
`http://ollama:11434` inside the network. Models persist in the
`ollama-models` volume.
- `ollama-pull` — one-shot init container that pulls the embedding model
(`MEMORY_EMBEDDING_MODEL`, default `nomic-embed-text`) then exits; the
MCP server waits on its `service_completed_successfully`.

Inside the compose network, DocumentDB requires TLS (the image enforces
it with a self-signed cert), so the internal URI is
Expand Down Expand Up @@ -256,6 +308,7 @@ load-bearing:
| `COPILOT_DIR` | `$HOME/.copilot` | `compose.full.yml` (mounts read-only) |
| `MEMORY_LOG_LEVEL` | `info` | pino (server + sync) |
| `SYNC_INTERVAL` | `30s` | sync daemon `--watch` |
| `MEMORY_EMBEDDING_PROVIDER` | `local` | DocumentDB Search (server + CLI) |
| `FUSE_HOST_BIND` | (off) | `compose.fuse-host-bind.yml` |
| `FUSE_MOUNT_PATH` | (off) | `doctor` only — manual opt-in |

Expand Down
19 changes: 19 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,25 @@ documentdb-memory graph import <file> [--merge | --replace]
observations appended.
- `--replace` — wipe the graph first, then import. Use with care.

### `graph reembed`

Recompute DocumentDB Search embeddings for existing entities. Useful after
enabling the feature for the first time, or after switching embedding models
(existing vectors from a different model are stale).

```text
documentdb-memory graph reembed [--all]
```

- default — embed only entities whose vector is missing or was produced by a
different model.
- `--all` — force re-embed every entity.

Requires an embedding provider to be configured and reachable
(`MEMORY_EMBEDDING_PROVIDER`, default `local`). If none is available the
command warns and makes no changes. See the
[DocumentDB Search section of the README](../README.md#documentdb-search--semantic-recall-for-the-knowledge-graph).

---

## `documentdb-memory sessions`
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/graph/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function registerAdd(graph: Command): void {
} else {
info(`entity "${name}" already exists; no changes`);
}
});
}, { withEmbedder: true });
});

add
Expand Down Expand Up @@ -71,7 +71,7 @@ export function registerAdd(graph: Command): void {
}
throw err;
}
});
}, { withEmbedder: true });
});
}

Expand Down
Loading
Loading