Skip to content

Add Milvus vector store backend#876

Open
zc277584121 wants to merge 3 commits into
plastic-labs:mainfrom
zc277584121:feat/milvus-vector-store
Open

Add Milvus vector store backend#876
zc277584121 wants to merge 3 commits into
plastic-labs:mainfrom
zc277584121:feat/milvus-vector-store

Conversation

@zc277584121

@zc277584121 zc277584121 commented Jul 6, 2026

Copy link
Copy Markdown

Summary

  • Add a Milvus vector-store implementation using MilvusClient, explicit schema, vector AUTOINDEX, and a Milvus Lite-friendly default URI.
  • Wire Milvus into vector-store settings, factory creation, startup dimension validation, configuration examples, and docs.
  • Add pymilvus[milvus-lite] dependency coverage plus focused tests for namespace mapping, filters, projection behavior, deletes, and dimension probing.

Testing

  • uv run --exclude-newer 2026-06-27T20:44:32.746059Z --locked ruff check src/vector_store/milvus.py src/vector_store/init.py src/config.py src/startup/embedding_validator.py tests/vector_store/test_milvus.py tests/conftest.py tests/llm/test_model_config.py
  • uv run --exclude-newer 2026-06-27T20:44:32.746059Z --locked basedpyright src/vector_store/milvus.py src/vector_store/init.py src/config.py src/startup/embedding_validator.py tests/vector_store/test_milvus.py tests/conftest.py tests/llm/test_model_config.py
  • uv run --exclude-newer 2026-06-27T20:44:32.746059Z --locked pytest tests/vector_store/test_milvus.py tests/vector_store/test_namespace_dim_probes.py tests/llm/test_model_config.py -q

Notes

  • Also attempted uv run pytest tests/startup/test_embedding_validator.py -q; it is blocked locally because Postgres at localhost:5432 is not running.

Summary by CodeRabbit

  • New Features

    • Added Milvus as a supported vector store backend.
    • Added Milvus Lite local storage by default, with configurable Milvus server and Zilliz Cloud connectivity.
    • Added configurable tokens, database names, consistency levels, filtering, metadata projection, and namespace management.
    • Added startup validation for Milvus embedding dimensions.
  • Documentation

    • Updated configuration references, setup guidance, troubleshooting documentation, and changelogs with Milvus options and usage details.
  • Tests

    • Added coverage for Milvus configuration, filtering, querying, metadata, distance handling, and end-to-end local operation.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8aec3de1-c97c-46d4-9fde-055ed086e78f

📥 Commits

Reviewing files that changed from the base of the PR and between fecb346 and 21a3076.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • .env.template
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • config.toml.example
  • docs/changelog/introduction.mdx
  • docs/v3/contributing/changing-embeddings.mdx
  • docs/v3/contributing/configuration.mdx
  • pyproject.toml
  • scripts/configure_embeddings.py
  • src/config.py
  • src/startup/embedding_validator.py
  • src/vector_store/__init__.py
  • src/vector_store/milvus.py
  • tests/llm/test_model_config.py
  • tests/vector_store/test_milvus.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • README.md
  • src/vector_store/init.py
  • .env.template
  • pyproject.toml
  • src/config.py
  • src/startup/embedding_validator.py
  • src/vector_store/milvus.py

Walkthrough

Adds Milvus Lite and remote Milvus support as a vector-store backend, including configuration fields, collection operations, startup dimension validation, factory integration, documentation, dependency updates, and unit/integration tests.

Changes

Milvus vector store support

Layer / File(s) Summary
Configuration and adoption documentation
.env.template, config.toml.example, src/config.py, pyproject.toml, README.md, docs/..., CHANGELOG.md, CLAUDE.md, scripts/configure_embeddings.py
Adds Milvus settings, dependency support, configuration examples, backend migration guidance, troubleshooting details, and release documentation.
MilvusVectorStore implementation
src/vector_store/milvus.py
Adds collection creation and schema validation, upsert, cosine search, filtering, metadata projection, deletion, lifecycle handling, and dimension probing.
Factory wiring and startup validation
src/vector_store/__init__.py, src/startup/embedding_validator.py
Creates Milvus stores from configuration and includes Milvus namespaces in external embedding-schema validation.
Milvus integration and configuration tests
tests/vector_store/test_milvus.py, tests/llm/test_model_config.py
Covers helper behavior, filtering, projection, distance normalization, Milvus Lite round trips, deletion, and configuration combinations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant MilvusVectorStore
  participant MilvusClient
  participant EmbeddingValidator
  Application->>MilvusVectorStore: upsert_many(namespace, vectors)
  MilvusVectorStore->>MilvusClient: create or validate collection
  MilvusVectorStore->>MilvusClient: upsert records
  Application->>MilvusVectorStore: query(namespace, embedding, filters)
  MilvusVectorStore->>MilvusClient: search with COSINE parameters
  MilvusClient-->>MilvusVectorStore: ordered hits and metadata
  MilvusVectorStore-->>Application: VectorQueryResult list
  EmbeddingValidator->>MilvusVectorStore: probe_namespace_dim(namespace)
  MilvusVectorStore->>MilvusClient: describe collection
  MilvusClient-->>EmbeddingValidator: declared vector dimension
Loading

Possibly related PRs

Suggested reviewers: vvoruganti

Poem

A bunny hops where Milvus grew,
With vectors tucked in databases blue.
Collections bloom, searches spring,
Metadata dances on every thing.
“Session” reads, then clouds drift by—
Carrots for tests beneath the sky!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a Milvus vector store backend.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vector_store/milvus.py`:
- Around line 320-398: The query logic in Milvus uses raw search scores as if
they were cosine distance, which makes the max_distance filter and
VectorQueryResult.score inconsistent. Update the async query path in query() to
convert the Milvus score returned from self.client.search into cosine distance
before comparing against max_distance and before assigning score, or adjust the
contract consistently throughout this method. Use the existing query(),
search_kwargs, and VectorQueryResult construction points to apply the conversion
in one place.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 77bb082b-68a7-48d7-aa40-5d891ac05abe

📥 Commits

Reviewing files that changed from the base of the PR and between 602347d and 1b538c9.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • .env.template
  • README.md
  • config.toml.example
  • docs/v3/contributing/configuration.mdx
  • pyproject.toml
  • src/config.py
  • src/startup/embedding_validator.py
  • src/vector_store/__init__.py
  • src/vector_store/milvus.py
  • tests/conftest.py
  • tests/llm/test_model_config.py
  • tests/vector_store/test_milvus.py

Comment thread src/vector_store/milvus.py
@zc277584121

Copy link
Copy Markdown
Author

Hi @VVoruganti @Rajat-Ahuja1997, could you take a look when you have a chance?

This PR adds a Milvus vector store backend following the existing VectorStore interface. I also pushed a follow-up for the score/distance handling and added focused coverage; the current review check is green.

@zc277584121
zc277584121 force-pushed the feat/milvus-vector-store branch from cfae284 to fecb346 Compare July 14, 2026 00:31

@VVoruganti VVoruganti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this — the backend itself is solid work. I verified parity rather than taking the tests at face value, and it holds up:

  • Score convention is correct. I probed MilvusClient.search with metric_type="COSINE" directly: hits come back as {id, distance, entity} and distance is true cosine distance (0.0 / 1.0 / 2.0 for identical / orthogonal / opposite). That matches lancedb's _distance and turbopuffer's $dist, so ordering and max_distance thresholding behave identically to the existing backends.
  • Filters cover every caller shape. Callers only ever pass level eq, session_name eq or {"in": [...]}, and peer_name eq (crud/document.py:242, crud/message.py:582, utils/search.py:104, dreamer/surprisal.py:264). All supported, and this backend is stricter than lancedb on malformed operators (clear ValueError vs. emitting broken SQL) — an improvement.
  • include_attributes True / False / ["message_id"] all behave like the other backends, the last via the dynamic field.
  • Clean on ruff, ruff format, and basedpyright (0 errors); 21/21 tests in tests/vector_store/ pass.

Two things I'd want fixed before merge, plus some cleanup. Details inline.

Blocking

  1. MILVUS_CONSISTENCY_LEVEL default is unsafe on a real Milvus server — see inline on src/config.py. This is the one genuine capability gap versus the other backends.
  2. The "score" branch in _hit_cosine_distance is unreachable, and the test that covers it asserts behavior no real backend produces — see inline on src/vector_store/milvus.py and tests/vector_store/test_milvus.py.

Cleanup

  1. Two changes unrelated to Milvus (tests/conftest.py, tests/llm/test_model_config.py) — inline.
  2. Docs and changelog are half-done. There are zero Milvus mentions in CHANGELOG.md, docs/changelog/introduction.mdx, CLAUDE.md (the project tree around lines 246-247 and architectural decision #8 at line 289 still say "turbopuffer or lancedb"), docs/v3/contributing/changing-embeddings.mdx:60, scripts/configure_embeddings.py:19-20, and docs/v3/contributing/configuration.mdx:285 and :295. The repo has a clear convention here — "External Vector Store support for turbopuffer and lancedb" got its own CHANGELOG entry, so a third backend should too. The .env.template / config.toml.example / configuration.mdx env-var updates you did make are all correct; it's just these prose surfaces that were missed.

Everything else in the non-Milvus part of the diff is exactly the mechanical widening you'd expect, and declaring pymilvus as a non-optional core dependency is consistent with how turbopuffer and lancedb are already declared.

The remaining inline notes are non-blocking design observations, not merge conditions.

Comment thread src/config.py
MILVUS_URI: str = "./milvus.db"
MILVUS_TOKEN: str | None = None
MILVUS_DB_NAME: str | None = None
MILVUS_CONSISTENCY_LEVEL: (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: defaulting this to None means a Milvus server or Zilliz Cloud collection is created with Milvus's own default consistency level, Bounded — a just-upserted vector can be invisible to search for several seconds.

That's a real behavioral divergence from the other three backends: pgvector, lancedb, and turbopuffer are all read-your-writes. Honcho's reconciler writes embeddings and search reads them back, so this shows up as silently missing recall rather than an error.

It's double-masked right now:

  • the default MILVUS_URI is Milvus Lite, which is effectively strong, so local dev never sees it;
  • test_milvus_lite_round_trip pins MILVUS_CONSISTENCY_LEVEL to "Strong", so the tested config isn't the default config.

There's also a second-order problem: the value is only passed to create_collection, so it's baked in at collection-creation time. An operator who hits stale reads in production can't fix it by setting the env var — existing collections keep whatever default they were born with, and there's no migration path short of dropping them.

Suggest defaulting to "Session" (read-your-writes per client, which is the semantics the other backends provide) rather than None.

Comment thread src/vector_store/milvus.py Outdated
return float(hit["distance"])
if "score" in hit:
return 1.0 - float(hit["score"])
return 0.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking (small): the "score" branch is unreachable. MilvusClient.search returns hits shaped {id, distance, entity} — there is no score key. I confirmed against pymilvus directly:

[{'id': 'same', 'distance': 0.0,  ...},
 {'id': 'orth', 'distance': 1.0,  ...},
 {'id': 'opp',  'distance': 2.0,  ...}]

So distance is already true cosine distance and the first branch is the only one that ever runs — which is correct and matches lancedb/turbopuffer. The 1.0 - score line is dead code guarding against a response shape this SDK doesn't produce.

Worth deleting along with _ScoreOnlySearchClient: as written, the two tests assert contradictory scoring conventions for an identical-vector hit (0.0 in the round-trip test, 0.1 from the fake) and both pass, so a future reader has no way to tell which one describes reality. The round-trip test against real Milvus Lite already covers scoring properly.

The return 0.0 fallback is fine to keep — lancedb and turbopuffer both default a missing distance to 0.0 the same way.

Comment thread tests/vector_store/test_milvus.py Outdated
"entity": {"message_id": "msg_2"},
},
]
]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This fake emits a score key that MilvusClient.search never returns (real hits are {id, distance, entity}, with distance already being cosine distance). So test_query_converts_milvus_score_to_cosine_distance certifies a code path that can't execute against pymilvus.

Suggest removing this class and that test together with the dead branch in _hit_cosine_distancetest_milvus_lite_round_trip already asserts the real scoring behavior (all_results[0].score == 0.0 for the identical vector) against actual Milvus Lite, which is the stronger check.

Comment thread tests/conftest.py Outdated
"tests/unified/",
"tests/live_llm/",
# Vector-store provider tests exercise SDK behavior directly and do not need DB setup.
"tests/vector_store/",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This change isn't needed for the Milvus tests, and it affects pre-existing tests.

I verified by reverting just these two lines and re-running: tests/vector_store/test_milvus.py passes 6/6 without it. Since tests/vector_store/ also holds test_lancedb.py, test_turbopuffer.py, and test_namespace_dim_probes.py, adding the directory to the blocklist silently changes fixture behavior for those existing tests as a side effect of a PR about Milvus.

Suggest dropping it from this PR. If skipping DB setup for the whole tests/vector_store/ directory is worth doing on its own merits, it'd be easier to review as a separate change.

Comment thread tests/llm/test_model_config.py Outdated
assert any(
"VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages
), f"expected deprecation warning, got {messages!r}"
assert any("VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages), (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This reformat is unrelated to Milvus — it's ruff format drift that's already latent on main. Running ruff format (0.15.12) against main's copy of this file produces exactly this hunk, so it isn't something your change introduced; it just got swept in.

Harmless, but it's noise in a backend PR and it'll reappear for the next contributor. Worth pulling out into its own formatting commit.

(The Literal widening further down in this same file is correctly part of this PR — no issue there.)

for key in STANDARD_METADATA_FIELDS:
row[key] = self._metadata_varchar_value(metadata.get(key))

return row

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking design note: every metadata value gets stored twice — once inside the JSON METADATA_FIELD, and again as a top-level field (explicit column for the six STANDARD_METADATA_FIELDS, dynamic field for everything else).

Since the collection is created with enable_dynamic_field=True, the dynamic field already captures arbitrary keys, which makes the JSON metadata column redundant — roughly 2x metadata storage. It also creates a precedence rule in _entity_metadata that's invisible until the two copies disagree: JSON is merged first, then entity fields overwrite it, so the str()-coerced copy always wins for those six keys.

Related, two smaller things in the same area:

  • The str() coercion in _metadata_varchar_value silently changes types round-trip for those six fields. Harmless today (level, session_name, and peer_name are all strings), but it's a latent trap for any future non-string value.
  • message_id — the only field actually projected on the hot message-search path (crud/message.py and utils/search.py both pass include_attributes=["message_id"]) — isn't among the declared fields, so it lands in the dynamic field, while six mostly-unused VARCHAR columns are declared explicitly. It works, but it's backwards from an indexing standpoint. lancedb takes the other approach in _metadata_fields_for_namespace, declaring message_id for msg namespaces.

Also minor: MAX_VARCHAR_LENGTH = 65535 on the primary key is generous for Honcho's ~21-char nanoid IDs.

if settings.VECTOR_STORE.MILVUS_DB_NAME:
client_kwargs["db_name"] = settings.VECTOR_STORE.MILVUS_DB_NAME

self.client = MilvusClient(**client_kwargs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: constructing the client eagerly in __init__ means that with a Milvus server / Zilliz URI, a transient outage at first use surfaces as a raw MilvusException propagating out of get_external_vector_store() — not the VectorStoreError that callers of this module are written to expect.

For comparison, LanceDBVectorStore defers connecting until _get_db(), and turbopuffer's httpx client connects lazily. Since @cache doesn't memoize exceptions this does self-heal on the next call, so it's an error-shape wart rather than a durable failure. Fine to leave; just noting it since Milvus Lite (the default) never exercises this path.

@VVoruganti VVoruganti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Converting my previous review to a formal request for changes — the inline comments there still hold and I haven't duplicated them here.

To restate the assessment: the backend is solid and has real parity with the existing stores. I verified the score convention against pymilvus directly (distance is already true cosine distance, matching lancedb's _distance and turbopuffer's $dist), confirmed every caller filter shape is supported, and it's clean on ruff / ruff format / basedpyright with 21/21 vector-store tests passing. Happy to approve once the two blocking items are addressed.

Blocking:

  1. MILVUS_CONSISTENCY_LEVEL defaults to None (inline) → real Milvus/Zilliz collections get Milvus's Bounded default, so a just-upserted vector can be invisible to search for seconds. pgvector, lancedb, and turbopuffer are all read-your-writes, so this is a genuine capability gap rather than a preference. It's invisible in CI and local dev because the default MILVUS_URI is Milvus Lite (effectively strong), and test_milvus_lite_round_trip pins "Strong" — so it would surface only in production, as missing recall rather than an error. Compounding it, the value is only passed to create_collection, so it's fixed at collection-creation time and can't be corrected later via config. Suggest defaulting to "Session".

  2. Dead "score" branch in _hit_cosine_distance, plus a test that certifies it. MilvusClient.search never returns a score key, so that branch can't execute; _ScoreOnlySearchClient asserts a response shape the SDK doesn't produce. The two tests currently assert contradictory scoring for an identical-vector hit (0.0 real vs. 0.1 from the fake) and both pass, which leaves no way to tell which describes reality. Deleting the branch and the fake is enough — test_milvus_lite_round_trip already covers scoring against real Milvus Lite.

Non-blocking, and I don't want these to hold up the merge: the docs/changelog gaps (item 4 in the previous review) are worth picking up since the repo has an established convention for new backends, and the remaining inline notes are design observations only.

@zc277584121
zc277584121 force-pushed the feat/milvus-vector-store branch from fecb346 to 21a3076 Compare July 25, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants