Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Features

* Add Milvus / Milvus Lite / Zilliz Cloud as an optional external vector index (`vector_index.provider = "milvus"`), wired to the in-memory metadata store. See `docs/milvus.md` and `docs/adr/0003-external-vector-index.md`.

## [1.5.1](https://github.com/NevaMind-AI/memU/compare/v1.5.0...v1.5.1) (2026-03-23)


Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,14 @@ Both examples demonstrate **proactive memory workflows**:

See [`tests/test_inmemory.py`](tests/test_inmemory.py) and [`tests/test_postgres.py`](tests/test_postgres.py) for implementation details.

**Use Milvus / Milvus Lite / Zilliz Cloud as an external vector index:**
```bash
uv sync --extra milvus
export OPENAI_API_KEY=your_api_key
python examples/milvus_vector_index.py
```
The `inmemory` metadata store is wired to Milvus today; the `sqlite` and `postgres` backends follow the same pattern and will be wired in a follow-up. See [`docs/milvus.md`](docs/milvus.md) for configuration details.

---

### Custom LLM and Embedding Providers
Expand Down
39 changes: 39 additions & 0 deletions docs/adr/0003-external-vector-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ADR 0003: External Vector Index Alongside Metadata Store

- Status: Accepted
- Date: 2026-04-15

## Context

ADR 0002 locked in repository-based storage with backend-aware vector search: the `inmemory` and `sqlite` backends do brute-force cosine, and `postgres` can use pgvector. Two footprints are not well served by that design:

- Multi-million-vector workloads, where brute-force is too slow and pgvector requires keeping Postgres hot enough to hold the index.
- Managed deployments that already standardise on a dedicated vector service (Milvus, Zilliz Cloud) and want memU to reuse it.

`settings.py` already separates `metadata_store` from `vector_index` in configuration, but every shipped backend kept vectors inside its own tables. The separation was not realised in code.

## Decision

Introduce a `VectorIndex` protocol that memory repositories can delegate to when an external index is configured. The first implementation is `MilvusVectorIndex`, targeting Milvus Lite (zero-dep local), self-hosted Milvus, and Zilliz Cloud behind a single `uri`/`token` config.

- `VectorIndex` lives in `memu/database/vector_index/` and exposes `upsert`, `delete`, `delete_many`, `search`, `close`.
- `build_vector_index` in the factory constructs the provider based on `VectorIndexConfig.provider`.
- Metadata backends receive the vector index through their builder and mirror mutations to it on create / update / delete / clear.
- Search routes through the vector index when present; salience ranking stays local because it needs per-item reinforcement and recency factors the index does not persist.

Initial scope: the `inmemory` metadata backend is wired to `MilvusVectorIndex`. `sqlite` and `postgres` are planned follow-ups; their existing (brute-force / pgvector) paths remain the default so the change is additive.

## Consequences

Positive:

- memU can scale vector similarity independently of the metadata store.
- Users on Zilliz Cloud or a shared Milvus cluster can reuse that infrastructure.
- Milvus Lite keeps the zero-setup local story (single file, no service) that `inmemory` already promises.

Negative:

- Two systems of record mean write amplification and a consistency window between the metadata store and the vector index.
- Scope fields are replicated into Milvus as dynamic fields, increasing storage vs. a single SQL column.
- `pymilvus` / `milvus-lite` are optional and gated behind the `milvus` extra; users picking this path accept that dependency.
- `milvus-lite <=2.5.1` still imports `pkg_resources`, so Python 3.13 users need `setuptools<81` until upstream switches to `importlib.metadata`.
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ Storage is abstracted through a `Database` protocol with four repositories:

For Postgres, startup runs migration bootstrap and attempts `CREATE EXTENSION IF NOT EXISTS vector` in `ddl_mode="create"`.

### External vector index (optional)

`database_config.vector_index` can point at an external index that lives outside the metadata store. Providers:

- `bruteforce` / `pgvector`: default, handled inside the metadata backend (see above).
- `milvus`: delegates similarity search to Milvus / Milvus Lite / Zilliz Cloud via the `VectorIndex` protocol in `memu/database/vector_index/`. Metadata backends mirror embedding writes through this interface on create/update/delete; searches route through it except for `ranking="salience"`, which still runs locally. See `docs/milvus.md` and `docs/adr/0003-external-vector-index.md`.
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The milvus bullet implies all metadata backends mirror writes and route searches through VectorIndex, but in code only the inmemory backend is currently wired to receive a VectorIndex instance; sqlite/postgres ignore it today. Consider tightening this wording (e.g., β€œcurrently only inmemory mirrors…”) to avoid suggesting functionality that isn’t implemented yet.

Suggested change
- `milvus`: delegates similarity search to Milvus / Milvus Lite / Zilliz Cloud via the `VectorIndex` protocol in `memu/database/vector_index/`. Metadata backends mirror embedding writes through this interface on create/update/delete; searches route through it except for `ranking="salience"`, which still runs locally. See `docs/milvus.md` and `docs/adr/0003-external-vector-index.md`.
- `milvus`: delegates similarity search to Milvus / Milvus Lite / Zilliz Cloud via the `VectorIndex` protocol in `memu/database/vector_index/`. Currently, only the `inmemory` metadata backend mirrors embedding writes through this interface on create/update/delete and routes searches through it; `ranking="salience"` still runs locally. See `docs/milvus.md` and `docs/adr/0003-external-vector-index.md`.

Copilot uses AI. Check for mistakes.

### Scope model propagation

`UserConfig.model` is merged into record/table models so scope fields (for example `user_id`) become first-class columns/attributes across resources, items, categories, and relations.
Expand Down
83 changes: 83 additions & 0 deletions docs/milvus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Milvus Vector Index

memU can route similarity search to [Milvus](https://milvus.io/) as an external vector index, decoupled from the metadata store. This is useful when:

- **Scale**: you have more vectors than the brute-force fallback can handle at latency budget.
- **Managed deployments**: you want to offload vector infrastructure to Zilliz Cloud or a self-hosted Milvus cluster.
- **Zero-setup local dev**: Milvus Lite runs as a local file (`./milvus.db`) with no external service.

## Install

```bash
uv sync --extra milvus
# or: uv pip install "memu-py[milvus]"
```

> The `milvus` extra pulls `pymilvus`, `milvus-lite` and β€” on Python 3.13 β€” a `setuptools<81` pin to work around [milvus-lite's use of the removed `pkg_resources` API](https://github.com/milvus-io/milvus-lite/issues).

## Quick Start (Milvus Lite)

```python
from memu.app import MemoryService

service = MemoryService(
llm_profiles={"default": {"api_key": "your-api-key"}},
database_config={
"metadata_store": {"provider": "inmemory"},
"vector_index": {"provider": "milvus"},
},
)
```

With the default configuration the index is persisted to `./milvus.db` using Milvus Lite β€” no Docker, no separate process.

## Targeting a Milvus Server

```python
database_config = {
"metadata_store": {"provider": "sqlite", "dsn": "sqlite:///memu.db"},
"vector_index": {
"provider": "milvus",
"uri": "http://localhost:19530",
"collection_name": "memu_prod",
},
}
```

## Targeting Zilliz Cloud

```python
database_config = {
"metadata_store": {"provider": "postgres", "dsn": os.environ["POSTGRES_DSN"]},
"vector_index": {
"provider": "milvus",
"uri": os.environ["ZILLIZ_URI"],
"token": os.environ["ZILLIZ_TOKEN"],
"collection_name": "memu_prod",
},
}
```

## Configuration

| Field | Default | Notes |
| --- | --- | --- |
| `provider` | β€” | Must be `"milvus"` to enable this index. |
| `uri` | `"./milvus.db"` | File path runs Milvus Lite; `http(s)://host:port` targets a Milvus server; a Zilliz Cloud endpoint targets the managed service. |
| `token` | `None` | Auth token for Zilliz Cloud or a secured Milvus server. |
| `collection_name` | `"memu_memory_items"` | Name of the Milvus collection that holds memory vectors. |
| `dim` | `None` | Embedding dimension. Inferred from the first upsert when omitted. |

## Supported Combinations

The Milvus vector index can be paired with any metadata store. memU keeps the metadata record (summary, categories, scope, reinforcement stats, ...) in the metadata store and mirrors embeddings into Milvus on create / update / delete.

- **Available now**: `inmemory` metadata store + Milvus vector index (feature-complete).
- **Planned**: `sqlite` and `postgres` metadata stores wired to Milvus. See `docs/adr/0003-external-vector-index.md` for the rollout plan.
Comment on lines +71 to +76
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section says the Milvus vector index β€œcan be paired with any metadata store”, but in code only the inmemory backend is currently wired to pass a VectorIndex instance; sqlite/postgres ignore provider="milvus" today. Consider adjusting the wording to match the actual supported combinations (or documenting the current limitation more explicitly).

Copilot uses AI. Check for mistakes.

## How Search Works

1. `create_item` / `update_item` mirror the embedding (and scope fields such as `user_id`, `agent_id`) into the configured Milvus collection.
2. `vector_search_items` forwards the query vector to Milvus using a COSINE AUTOINDEX. Scope filters (e.g. `where={"user_id": "u1"}`) are translated into Milvus boolean expressions on dynamic fields.
3. Milvus returns `(id, score)` pairs; the metadata store resolves them back to full memory records.
4. Salience ranking (`ranking="salience"`) still runs locally because it needs per-item reinforcement and recency factors that the vector index does not store.
72 changes: 72 additions & 0 deletions examples/milvus_vector_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Minimal end-to-end example: memU backed by a Milvus vector index.

Uses the ``inmemory`` metadata store and Milvus Lite (a single ``milvus.db``
file) so it runs with zero external services. Point ``uri`` at a Milvus server
URL or a Zilliz Cloud endpoint to scale to production.

Run:

uv sync --extra milvus
export OPENAI_API_KEY=sk-...
uv run python examples/milvus_vector_index.py
"""

from __future__ import annotations

import asyncio
import os
from pathlib import Path

from memu.app import MemoryService


async def main() -> None:
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
msg = "Set OPENAI_API_KEY before running this example."
raise SystemExit(msg)

file_path = os.path.abspath("example/example_conversation.json")
if not Path(file_path).exists():
msg = (
f"Example conversation not found at {file_path}. "
"Run from the repository root so the 'example/' folder is visible."
Comment on lines +29 to +33
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example looks for example/example_conversation.json, but this repository doesn’t have an example/ directory; the sample conversation JSON lives under tests/example/ and there are also sample conversations under examples/resources/. As written, the script will exit immediately; update the path/message to point at an existing file location.

Suggested change
file_path = os.path.abspath("example/example_conversation.json")
if not Path(file_path).exists():
msg = (
f"Example conversation not found at {file_path}. "
"Run from the repository root so the 'example/' folder is visible."
file_path = os.path.abspath("tests/example/example_conversation.json")
if not Path(file_path).exists():
msg = (
f"Example conversation not found at {file_path}. "
"Run from the repository root so 'tests/example/example_conversation.json' is available."

Copilot uses AI. Check for mistakes.
)
raise SystemExit(msg)

service = MemoryService(
llm_profiles={"default": {"api_key": api_key}},
database_config={
"metadata_store": {"provider": "inmemory"},
"vector_index": {
"provider": "milvus",
# Local Milvus Lite file. Swap for "http://host:19530" or a
# Zilliz Cloud endpoint to target a real deployment.
"uri": "./milvus.db",
"collection_name": "memu_example",
},
},
retrieve_config={"method": "rag"},
)

print("[memU + Milvus] Memorizing example conversation...")
memory = await service.memorize(
resource_url=file_path,
modality="conversation",
user={"user_id": "demo-user"},
)
for cat in memory.get("categories", []):
print(f" - {cat.get('name')}: {(cat.get('summary') or '')[:80]}...")

queries = [
{"role": "user", "content": {"text": "What do you know about my preferences?"}},
]
result = await service.retrieve(queries=queries, where={"user_id": "demo-user"})

print("\n[memU + Milvus] Retrieved items:")
for item in result.get("items", [])[:5]:
print(f" - [{item.get('memory_type')}] {(item.get('summary') or '')[:100]}")


if __name__ == "__main__":
asyncio.run(main())
15 changes: 14 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ test = [

[project.optional-dependencies]
postgres = ["pgvector>=0.3.4", "sqlalchemy[postgresql-psycopgbinary]>=2.0.36"]
milvus = [
"milvus-lite>=2.4.10",
"pymilvus>=2.5.0",
# milvus-lite <=2.5.1 still imports pkg_resources, which setuptools >=81
# removed. Pin on Python 3.13 until a milvus-lite release drops the import.
"setuptools<81; python_version >= '3.13'",
]
langgraph = ["langgraph>=0.0.10", "langchain-core>=0.1.0"]
claude = ["claude-agent-sdk>=0.1.24"]

Expand All @@ -81,7 +88,9 @@ memu-server = "memu.server.cli:main"

[tool.deptry.per_rule_ignores]
# Optional dependencies used in examples/
DEP002 = ["claude-agent-sdk"]
# milvus-lite is imported indirectly by pymilvus when uri ends in .db;
# setuptools is a conditional compatibility pin for milvus-lite on Python 3.13.
DEP002 = ["claude-agent-sdk", "milvus-lite", "setuptools"]

[tool.mypy]
files = ["src", "tests"]
Expand Down Expand Up @@ -109,6 +118,10 @@ ignore_missing_imports = true
module = ["pgvector.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = ["pymilvus.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = ["memu.client.openai_wrapper"]
disallow_untyped_defs = false
Expand Down
18 changes: 17 additions & 1 deletion src/memu/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,22 @@ class MetadataStoreConfig(BaseModel):


class VectorIndexConfig(BaseModel):
provider: Annotated[Literal["bruteforce", "pgvector", "none"], Normalize] = "bruteforce"
provider: Annotated[Literal["bruteforce", "pgvector", "milvus", "none"], Normalize] = "bruteforce"
dsn: str | None = Field(default=None, description="Postgres connection string when provider=pgvector.")
uri: str | None = Field(
default=None,
description=(
"Milvus URI. File path (e.g. './milvus.db') runs Milvus Lite, "
"'http://host:19530' targets a Milvus server, and a Zilliz Cloud "
"endpoint targets the managed service."
),
)
token: str | None = Field(default=None, description="Milvus/Zilliz auth token, if required.")
collection_name: str = Field(default="memu_memory_items", description="Milvus collection name.")
dim: int | None = Field(
default=None,
description="Embedding dimension. If None, inferred from the first upsert.",
)


class DatabaseConfig(BaseModel):
Expand All @@ -319,3 +333,5 @@ def model_post_init(self, __context: Any) -> None:
self.vector_index = VectorIndexConfig(provider="bruteforce")
elif self.vector_index.provider == "pgvector" and self.vector_index.dsn is None:
self.vector_index = self.vector_index.model_copy(update={"dsn": self.metadata_store.dsn})
elif self.vector_index.provider == "milvus" and self.vector_index.uri is None:
self.vector_index = self.vector_index.model_copy(update={"uri": "./milvus.db"})
17 changes: 15 additions & 2 deletions src/memu/database/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from memu.app.settings import DatabaseConfig
from memu.database.inmemory import build_inmemory_database
from memu.database.interfaces import Database
from memu.database.vector_index import build_vector_index

if TYPE_CHECKING:
pass
Expand All @@ -20,14 +21,26 @@ def build_database(
"""
Initialize a database backend for the configured provider.

Supported providers:
Supported metadata providers:
- "inmemory": In-memory storage (default, no persistence)
- "postgres": PostgreSQL with optional pgvector support
- "sqlite": SQLite file-based storage (lightweight, portable)

Supported vector index providers (used together with a metadata provider):
- "bruteforce": In-memory cosine top-k (default)
- "pgvector": PostgreSQL pgvector column in the metadata backend
- "milvus": External Milvus / Milvus Lite / Zilliz Cloud index
- "none": Disable vector search entirely
"""
vector_index = build_vector_index(config.vector_index)

provider = config.metadata_store.provider
if provider == "inmemory":
return build_inmemory_database(config=config, user_model=user_model)
return build_inmemory_database(
config=config,
user_model=user_model,
vector_index=vector_index,
)
elif provider == "postgres":
# Lazy import to avoid requiring pgvector when not using postgres
from memu.database.postgres import build_postgres_database
Expand Down
3 changes: 3 additions & 0 deletions src/memu/database/inmemory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from memu.app.settings import DatabaseConfig
from memu.database.inmemory.models import build_inmemory_models
from memu.database.inmemory.repo import InMemoryStore
from memu.database.vector_index.interfaces import VectorIndex


def build_inmemory_database(
*,
config: DatabaseConfig,
user_model: type[BaseModel],
vector_index: VectorIndex | None = None,
) -> InMemoryStore:
resource_model, memory_category_model, memory_item_model, category_item_model = build_inmemory_models(user_model)
return InMemoryStore(
Expand All @@ -19,6 +21,7 @@ def build_inmemory_database(
memory_item_model=memory_item_model,
memory_category_model=memory_category_model,
category_item_model=category_item_model,
vector_index=vector_index,
)


Expand Down
Loading
Loading