Skip to content

Commit 8977b48

Browse files
committed
feat: add Milvus vector index provider
Signed-off-by: Cheney Zhang <chen.zhang@zilliz.com>
1 parent 6426ff0 commit 8977b48

17 files changed

Lines changed: 1186 additions & 15 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,15 @@ cd tests
269269
uv run python test_postgres.py
270270
```
271271

272+
**Run with Milvus Lite as an external vector index:**
273+
```bash
274+
uv sync --extra milvus
275+
export OPENAI_API_KEY=your_key
276+
uv run python examples/milvus_vector_index.py
277+
```
278+
279+
The Milvus integration currently wires `vector_index.provider="milvus"` to the `inmemory` metadata store. Use `uri` to point the same configuration at Milvus Lite, a Milvus server, or Zilliz Cloud.
280+
272281
---
273282

274283
### Custom LLM and Embedding Providers
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# ADR 0008: External Vector Index Alongside Metadata Store
2+
3+
- Status: Accepted
4+
- Date: 2026-06-30
5+
6+
## Context
7+
8+
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:
9+
10+
- Multi-million-vector workloads, where brute-force is too slow and pgvector requires keeping Postgres hot enough to hold the index.
11+
- Managed deployments that already standardise on a dedicated vector service (Milvus, Zilliz Cloud) and want memU to reuse it.
12+
13+
`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.
14+
15+
## Decision
16+
17+
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.
18+
19+
- `VectorIndex` lives in `memu/database/vector_index/` and exposes `upsert`, `delete`, `delete_many`, `search`, `close`.
20+
- `build_vector_index` in the factory constructs the provider based on `VectorIndexConfig.provider`.
21+
- The in-memory metadata backend receives the vector index through its builder and mirrors mutations to it on create / update / delete / clear.
22+
- 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.
23+
24+
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. Until those follow-ups land, memU rejects `vector_index.provider="milvus"` with non-`inmemory` metadata stores to avoid silently ignoring the configured external index.
25+
26+
## Consequences
27+
28+
Positive:
29+
30+
- memU can scale vector similarity independently of the metadata store.
31+
- Users on Zilliz Cloud or a shared Milvus cluster can reuse that infrastructure.
32+
- Milvus Lite keeps the zero-setup local story (single file, no service) that `inmemory` already promises.
33+
34+
Negative:
35+
36+
- Two systems of record mean write amplification and a consistency window between the metadata store and the vector index.
37+
- Scope fields are replicated into Milvus as dynamic fields, increasing storage vs. a single SQL column.
38+
- `pymilvus` / `milvus-lite` are optional and gated behind the `milvus` extra; users picking this path accept that dependency.
39+
- Milvus Lite 3.x uses a newer local storage format than the earlier 2.x line, so users upgrading existing local `.db` files should create a new Milvus Lite database and re-ingest their vectors.
40+
- Milvus Lite 3.0 still imports `pkg_resources` on Python 3.13, so the `milvus` extra pins `setuptools<81` until that dependency is removed upstream.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
- [0005: Extract Embedding into a Dedicated Package, Fully Decoupled from Chat Clients](0005-dedicated-embedding-package.md)
88
- [0006: Promote Skills to a First-Class `RecallFile` Track, Generated Inside Memorize](0006-from-memory-item-category-to-tracked-workspace-memorization.md)
99
- [0007: Split Memorize/Retrieve into Three Independent Lines on a Layered Wiki-Graph Kernel](0007-three-independent-memory-lines-wiki-graph.md)
10+
- [0008: External Vector Index Alongside Metadata Store](0008-external-vector-index.md)

docs/architecture.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,15 @@ stored vectors.)
219219
- `postgres`: SQLModel persistence with pgvector support (when enabled), local fallback ranking when needed
220220

221221
`DatabaseConfig` auto-derives `vector_index`: `pgvector` for postgres, `bruteforce`
222-
otherwise. For Postgres, startup runs migration bootstrap and attempts
223-
`CREATE EXTENSION IF NOT EXISTS vector` in `ddl_mode="create"`.
222+
otherwise. Users can also set `database_config.vector_index` explicitly to select
223+
vector-search behavior:
224+
225+
- `bruteforce`: local cosine search in the metadata backend
226+
- `pgvector`: Postgres-native vector search when the metadata store is `postgres`
227+
- `milvus`: external Milvus / Milvus Lite / Zilliz Cloud vector index for the `inmemory` metadata store
228+
- `none`: reserved for disabling external vector-index construction
229+
230+
For Postgres, startup runs migration bootstrap and attempts `CREATE EXTENSION IF NOT EXISTS vector` in `ddl_mode="create"`.
224231

225232
### Track column and scope model
226233

docs/milvus.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Milvus Vector Index
2+
3+
memU can route in-memory metadata-store similarity search to [Milvus](https://milvus.io/) as an external vector index. This is useful when:
4+
5+
- **Scale**: you have more vectors than the brute-force fallback can handle at latency budget.
6+
- **Managed deployments**: you want to offload vector infrastructure to Zilliz Cloud or a self-hosted Milvus cluster.
7+
- **Zero-setup local dev**: Milvus Lite runs as a local file (`./milvus.db`) with no external service.
8+
9+
## Install
10+
11+
```bash
12+
uv sync --extra milvus
13+
```
14+
15+
> The `milvus` extra pulls `pymilvus`, `milvus-lite` and — on Python 3.13 — a `setuptools<81` compatibility pin because Milvus Lite 3.0 still imports `pkg_resources`. New local Milvus Lite files should use the 3.x storage format; older 2.x `.db` files are not expected to be reusable after upgrading to Milvus Lite 3.x.
16+
17+
## Quick Start (Milvus Lite)
18+
19+
```python
20+
from memu.app import MemoryService
21+
22+
service = MemoryService(
23+
llm_profiles={"default": {"api_key": "your-api-key"}},
24+
database_config={
25+
"metadata_store": {"provider": "inmemory"},
26+
"vector_index": {"provider": "milvus"},
27+
},
28+
)
29+
```
30+
31+
With the default configuration the index is persisted to `./milvus.db` using Milvus Lite — no Docker, no separate process.
32+
33+
## Targeting a Milvus Server
34+
35+
```python
36+
database_config = {
37+
"metadata_store": {"provider": "inmemory"},
38+
"vector_index": {
39+
"provider": "milvus",
40+
"uri": "http://localhost:19530",
41+
"collection_name": "memu_prod",
42+
},
43+
}
44+
```
45+
46+
## Targeting Zilliz Cloud
47+
48+
```python
49+
import os
50+
51+
database_config = {
52+
"metadata_store": {"provider": "inmemory"},
53+
"vector_index": {
54+
"provider": "milvus",
55+
"uri": os.environ["ZILLIZ_URI"],
56+
"token": os.environ["ZILLIZ_TOKEN"],
57+
"collection_name": "memu_prod",
58+
},
59+
}
60+
```
61+
62+
## Configuration
63+
64+
| Field | Default | Notes |
65+
| --- | --- | --- |
66+
| `provider` || Must be `"milvus"` to enable this index. |
67+
| `uri` | `"./milvus.db"` | File path runs Milvus Lite; `http(s)://host:port` targets a Milvus server; a Zilliz Cloud endpoint targets the managed service. |
68+
| `token` | `None` | Auth token for Zilliz Cloud or a secured Milvus server. |
69+
| `db_name` | `None` | Optional Milvus database name. |
70+
| `collection_name` | `"memu_memory_items"` | Name of the Milvus collection that holds memory vectors. |
71+
| `dim` | `None` | Embedding dimension. Inferred from the first upsert when omitted. |
72+
| `consistency_level` | `None` | Optional Milvus collection consistency level (`"Strong"`, `"Session"`, `"Bounded"`, or `"Eventually"`). Uses the server default when omitted. |
73+
74+
## Supported Combinations
75+
76+
memU keeps metadata records (summary, categories, scope, reinforcement stats, ...) in the metadata store and mirrors embeddings into Milvus on create / update / delete.
77+
78+
- **Available now**: `inmemory` metadata store + Milvus vector index (feature-complete).
79+
- **Not wired yet**: `sqlite` and `postgres` metadata stores reject `vector_index.provider="milvus"` rather than silently ignoring it. See `docs/adr/0008-external-vector-index.md` for the rollout plan.
80+
81+
## How Search Works
82+
83+
1. `create_item` / `update_item` mirror the embedding (and scope fields such as `user_id`, `agent_id`) into the configured Milvus collection.
84+
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.
85+
3. Milvus returns `(id, score)` pairs; the metadata store resolves them back to full memory records.
86+
4. Salience ranking (`ranking="salience"`) still runs locally because it needs per-item reinforcement and recency factors that the vector index does not store.

examples/milvus_vector_index.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Minimal end-to-end example: memU backed by a Milvus vector index.
2+
3+
Uses the ``inmemory`` metadata store and Milvus Lite (a single ``milvus.db``
4+
file) so it runs with zero external services. Point ``uri`` at a Milvus server
5+
URL or a Zilliz Cloud endpoint to scale to production.
6+
7+
Run:
8+
9+
uv sync --extra milvus
10+
export OPENAI_API_KEY=sk-...
11+
uv run python examples/milvus_vector_index.py
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import asyncio
17+
import os
18+
from pathlib import Path
19+
20+
from memu.app import MemoryService
21+
22+
23+
async def main() -> None:
24+
api_key = os.environ.get("OPENAI_API_KEY")
25+
if not api_key:
26+
msg = "Set OPENAI_API_KEY before running this example."
27+
raise SystemExit(msg)
28+
29+
file_path = os.path.abspath("examples/resources/conversations/conv1.json")
30+
if not Path(file_path).exists():
31+
msg = (
32+
f"Example conversation not found at {file_path}. "
33+
"Run from the repository root so examples/resources/conversations/conv1.json is available."
34+
)
35+
raise SystemExit(msg)
36+
37+
service = MemoryService(
38+
llm_profiles={"default": {"api_key": api_key}},
39+
database_config={
40+
"metadata_store": {"provider": "inmemory"},
41+
"vector_index": {
42+
"provider": "milvus",
43+
# Local Milvus Lite file. Swap for "http://host:19530" or a
44+
# Zilliz Cloud endpoint to target a real deployment.
45+
"uri": "./milvus.db",
46+
"collection_name": "memu_example",
47+
},
48+
},
49+
retrieve_config={"method": "rag"},
50+
)
51+
52+
print("[memU + Milvus] Memorizing example conversation...")
53+
memory = await service.memorize(
54+
resource_url=file_path,
55+
modality="conversation",
56+
user={"user_id": "demo-user"},
57+
)
58+
for cat in memory.get("categories", []):
59+
print(f" - {cat.get('name')}: {(cat.get('summary') or '')[:80]}...")
60+
61+
queries = [
62+
{"role": "user", "content": {"text": "What do you know about my preferences?"}},
63+
]
64+
result = await service.retrieve(queries=queries, where={"user_id": "demo-user"})
65+
66+
print("\n[memU + Milvus] Retrieved items:")
67+
for item in result.get("items", [])[:5]:
68+
print(f" - [{item.get('memory_type')}] {(item.get('summary') or '')[:100]}")
69+
70+
71+
if __name__ == "__main__":
72+
asyncio.run(main())

pyproject.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ test = [
6868

6969
[project.optional-dependencies]
7070
postgres = ["pgvector>=0.3.4", "sqlalchemy[postgresql-psycopgbinary]>=2.0.36"]
71+
milvus = [
72+
"pymilvus>=3.0.0,<4.0.0",
73+
"milvus-lite>=3.0,<4.0",
74+
# milvus-lite 3.0 still imports pkg_resources on Python 3.13.
75+
"setuptools<81; python_version >= '3.13'",
76+
]
7177
langgraph = ["langgraph>=0.0.10", "langchain-core>=0.1.0"]
7278
claude = ["claude-agent-sdk>=0.1.24"]
7379
lazyllm = ["lazyllm>=0.7.3"]
@@ -84,7 +90,7 @@ memu = "memu.cli:main"
8490

8591
[tool.deptry.per_rule_ignores]
8692
# Optional dependencies used in examples/ or imported lazily behind extras.
87-
DEP002 = ["claude-agent-sdk", "markitdown"]
93+
DEP002 = ["claude-agent-sdk", "markitdown", "milvus-lite", "setuptools"]
8894

8995
[tool.mypy]
9096
files = ["src", "tests"]
@@ -116,6 +122,10 @@ ignore_missing_imports = true
116122
module = ["markitdown", "markitdown.*"]
117123
ignore_missing_imports = true
118124

125+
[[tool.mypy.overrides]]
126+
module = ["pymilvus.*"]
127+
ignore_missing_imports = true
128+
119129
[tool.ruff]
120130
target-version = "py313"
121131
line-length = 120

src/memu/app/settings.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,23 @@ class MetadataStoreConfig(BaseModel):
576576

577577

578578
class VectorIndexConfig(BaseModel):
579-
provider: Annotated[Literal["bruteforce", "pgvector", "none"], Normalize] = "bruteforce"
579+
provider: Annotated[Literal["bruteforce", "pgvector", "milvus", "none"], Normalize] = "bruteforce"
580580
dsn: str | None = Field(default=None, description="Postgres connection string when provider=pgvector.")
581+
uri: str | None = Field(
582+
default=None,
583+
description=(
584+
"Milvus URI when provider=milvus. A file path such as './milvus.db' uses Milvus Lite; "
585+
"http(s)://host:port targets Milvus server or Zilliz Cloud."
586+
),
587+
)
588+
token: str | None = Field(default=None, description="Milvus/Zilliz auth token, if required.")
589+
db_name: str | None = Field(default=None, description="Milvus database name, if required.")
590+
collection_name: str = Field(default="memu_memory_items", description="Milvus collection name.")
591+
dim: int | None = Field(default=None, description="Embedding dimension for Milvus collection creation.")
592+
consistency_level: Literal["Strong", "Session", "Bounded", "Eventually"] | None = Field(
593+
default=None,
594+
description="Milvus collection consistency level. Uses the server default when omitted.",
595+
)
581596

582597

583598
class DatabaseConfig(BaseModel):
@@ -592,3 +607,5 @@ def model_post_init(self, __context: Any) -> None:
592607
self.vector_index = VectorIndexConfig(provider="bruteforce")
593608
elif self.vector_index.provider == "pgvector" and self.vector_index.dsn is None:
594609
self.vector_index = self.vector_index.model_copy(update={"dsn": self.metadata_store.dsn})
610+
elif self.vector_index.provider == "milvus" and self.vector_index.uri is None:
611+
self.vector_index = self.vector_index.model_copy(update={"uri": "./milvus.db"})

src/memu/database/factory.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from memu.app.settings import DatabaseConfig
88
from memu.database.inmemory import build_inmemory_database
99
from memu.database.interfaces import Database
10+
from memu.database.vector_index import build_vector_index
1011

1112
if TYPE_CHECKING:
1213
pass
@@ -26,8 +27,14 @@ def build_database(
2627
- "sqlite": SQLite file-based storage (lightweight, portable)
2728
"""
2829
provider = config.metadata_store.provider
30+
vector_provider = config.vector_index.provider if config.vector_index else None
31+
if vector_provider == "milvus" and provider != "inmemory":
32+
msg = "vector_index provider 'milvus' currently supports metadata_store provider 'inmemory' only"
33+
raise ValueError(msg)
34+
2935
if provider == "inmemory":
30-
return build_inmemory_database(config=config, user_model=user_model)
36+
vector_index = build_vector_index(config.vector_index)
37+
return build_inmemory_database(config=config, user_model=user_model, vector_index=vector_index)
3138
elif provider == "postgres":
3239
# Lazy import to avoid requiring pgvector when not using postgres
3340
from memu.database.postgres import build_postgres_database

src/memu/database/inmemory/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
from memu.app.settings import DatabaseConfig
66
from memu.database.inmemory.models import build_inmemory_models
77
from memu.database.inmemory.repo import InMemoryStore
8+
from memu.database.vector_index.interfaces import VectorIndex
89

910

1011
def build_inmemory_database(
1112
*,
1213
config: DatabaseConfig,
1314
user_model: type[BaseModel],
15+
vector_index: VectorIndex | None = None,
1416
) -> InMemoryStore:
1517
(
1618
resource_model,
@@ -28,6 +30,7 @@ def build_inmemory_database(
2830
recall_file_entry_model=recall_file_entry_model,
2931
recall_file_resource_model=recall_file_resource_model,
3032
recall_file_segment_model=recall_file_segment_model,
33+
vector_index=vector_index,
3134
)
3235

3336

0 commit comments

Comments
 (0)