Skip to content

Add Milvus as a first-class Vektori storage backend#49

Merged
laxmanclo merged 4 commits into
vektori-ai:mainfrom
snigenigmatic:feat/milvus-backend
Apr 13, 2026
Merged

Add Milvus as a first-class Vektori storage backend#49
laxmanclo merged 4 commits into
vektori-ai:mainfrom
snigenigmatic:feat/milvus-backend

Conversation

@snigenigmatic

@snigenigmatic snigenigmatic commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

This PR introduces Milvus support as a production-ready storage backend in Vektori, with full backend implementation, factory/config/CLI wiring, documentation updates, Docker local runtime support, and dedicated test coverage.
The Ollama example run is included only as a sanity validation path.

Problem / Motivation

Vektori needs a high-scale vector database option beyond existing backends. Milvus provides scalable ANN search and operational patterns suitable for larger deployments.

Scope

  • Milvus backend implementation (milvus.py)
  • Storage factory routing + URL heuristic updates
  • Config/CLI/README updates for Milvus usage
  • Compose support for local Milvus stack
  • Milvus-focused unit/integration/e2e tests
  • Example sanity script using local Ollama + Milvus

Out of Scope

  • Changing default backend behavior (SQLite remains default)
  • Making Ollama part of core backend requirements
  • Broader model-provider changes unrelated to Milvus

###Key Design Decisions

  • Three Milvus collections: facts, sentences, episodes
  • Session metadata stored as typed records in sentence collection
  • User-level partition key isolation
  • Best-effort compatibility for async/sync Milvus clients
  • Write flush added for stable read-after-write behavior in immediate query flows/tests

Validation

  • Milvus unit tests passed
  • Milvus e2e tests passed
  • Local sanity run with Ollama + Milvus passed (validation-only)

Risks & Mitigations

  • Async loop mismatch in certain test/runtime contexts → fallback to sync client
  • Milvus eventual consistency after writes → explicit flush after writes
  • Milvus VARCHAR null constraints → normalize optional string fields to empty string

Backward Compatibility

  • No breaking API change
  • Existing backends and defaults remain intact
  • Milvus dependency is optional via extras

Summary by CodeRabbit

  • New Features

    • Milvus added as a supported storage backend for vector storage, search, sessions, facts, episodes, and user deletion.
  • Documentation

    • README, CLI help/examples, and a runnable demo updated to show Milvus usage and configuration.
  • Chores

    • Local development Docker compose now includes Milvus and supporting services; packaging extras updated to offer an optional Milvus install.
  • Tests

    • New unit and integration tests covering Milvus-backed flows.

add MilvusBackend with facts/sentences/episodes collections and user partition-key isolation

wire Milvus into storage resolution (storage_backend='milvus' and :19530 URL heuristic)

add Milvus support to config/CLI/docs and Docker Compose local stack (etcd, minio, milvus)

add optional dependency extras for Milvus (pymilvus)

add unit + integration + e2e tests for Milvus backend behavior

harden Milvus operations for reliability (nullable string normalization, async loop fallback, read-after-write flush)

include Ollama+Milvus example as sanity-check demo (non-core to backend feature)
Copilot AI review requested due to automatic review settings April 11, 2026 13:16
@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds Milvus as a new async storage backend: implementation of MilvusBackend, factory wiring and client registration, Docker Compose services for Milvus/etcd/MinIO, README/CLI/docs updates, example script, pyproject extras, and extensive unit and integration tests exercising Milvus flows.

Changes

Cohort / File(s) Summary
Docs & CLI
README.md, vektori/cli.py, vektori/config.py
Documented Milvus support; added README examples, CLI help/config examples and config docstring showing --storage-backend milvus and default connection hint.
Project Metadata
pyproject.toml
Added optional extra milvus = ["pymilvus>=2.5.3"] and included pymilvus in the all extra; minor authors formatting change.
Local Dev Infra
docker-compose.yml
Added etcd, minio, and milvus services with ports, volumes, env, healthchecks, and inter-service dependencies for local Milvus testing.
Storage Factory
vektori/storage/factory.py
Factory extended to detect milvus backend (URL heuristic including :19530) and instantiate MilvusBackend with passed embedding_dim.
Milvus Backend
vektori/storage/milvus.py
New async-first MilvusBackend implementing _facts, _sentences, _episodes collections, lifecycle, async/sync client fallbacks, normalized query/search, upsert/search/delete, sessions/episodes/facts APIs, CSV-backed source linking with CAS retries, and converters.
Client Integration
vektori/client.py
Register created embedder with storage backend when backend exposes set_sentence_embedder.
Examples
examples/ollama_milvus_session_demo.py
Added async demo script showing Vektori + Milvus usage with Ollama models: create client, add messages, and run a search.
Unit Tests
tests/unit/test_milvus_backend.py, tests/unit/test_new_backends_factory.py
Added extensive unit tests for MilvusBackend behavior, filter/query construction, edge cases, factory routing, and embedding/ID behaviors.
Integration & E2E Tests
tests/integration/test_milvus_backend.py, tests/integration/test_milvus_e2e.py
Added integration/e2e tests gated on Milvus availability; exercise ingestion, search depths, sessions, episodes, sources, and delete_user flows.

Sequence Diagram(s)

sequenceDiagram
    actor User as Client/User
    participant Vektori as Vektori API
    participant Embedder as Embedder
    participant MilvusBackend as MilvusBackend
    participant Milvus as Milvus Server

    rect rgba(100, 200, 100, 0.5)
    Note over User,Milvus: Initialization
    User->>Vektori: create(storage_backend="milvus")
    Vektori->>MilvusBackend: instantiate(url, prefix, embedding_dim)
    Vektori->>Embedder: create()
    Vektori->>MilvusBackend: set_sentence_embedder(embedder)
    Vektori->>MilvusBackend: await initialize()
    MilvusBackend->>Milvus: connect & ensure_collections()
    end

    rect rgba(100, 150, 200, 0.5)
    Note over User,Milvus: Ingest (add)
    User->>Vektori: await add(messages...)
    Vektori->>Embedder: embed(messages) -> vectors
    Vektori->>MilvusBackend: upsert_sentences(messages, vectors)
    MilvusBackend->>Milvus: upsert into sentences collection
    Milvus-->>MilvusBackend: ack
    MilvusBackend-->>Vektori: inserted_count
    Vektori-->>User: add result
    end

    rect rgba(200, 150, 100, 0.5)
    Note over User,Milvus: Search
    User->>Vektori: await search(query, depth="l1")
    Vektori->>Embedder: embed(query) -> vector
    Vektori->>MilvusBackend: search_sentences(vector, user_id)
    MilvusBackend->>Milvus: search with filter_expr
    Milvus-->>MilvusBackend: matched rows
    MilvusBackend->>Vektori: normalized {sentences,facts,episodes}
    Vektori-->>User: search result
    end

    rect rgba(200, 100, 100, 0.5)
    Note over User,Milvus: Teardown
    User->>Vektori: await close()
    Vektori->>MilvusBackend: await close()
    MilvusBackend->>Milvus: close client
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 In a burrow of vectors, I hop and hum,
Milvus keeps my memories, every bit and crumb.
Three cozy collections, tidy and bright,
Facts, sentences, episodes snug through the night.
Hooray — new backend carrots! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding Milvus as a first-class storage backend for Vektori, which is the central focus of the entire pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds Milvus as an optional, production-oriented storage backend for Vektori, including backend implementation, configuration/factory/CLI wiring, local Docker runtime support, documentation updates, and Milvus-specific unit + integration/e2e tests.

Changes:

  • Implement MilvusBackend with three collections (facts/sentences/episodes) and session markers stored as typed records.
  • Wire Milvus into backend factory selection, config defaults/help text, and README usage guidance.
  • Add Docker Compose services for a local Milvus stack (etcd + minio + milvus) and introduce Milvus-focused tests and an Ollama sanity example.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
vektori/storage/milvus.py New Milvus backend implementation (schema, CRUD/search, session markers, flush strategy).
vektori/storage/factory.py Routes storage_backend="milvus" and adds URL heuristic for port 19530.
vektori/config.py Documents "milvus" as a valid storage_backend.
vektori/cli.py Updates CLI help + adds Milvus URL example and config example.
tests/unit/test_new_backends_factory.py Adds factory routing + constructor default tests for Milvus.
tests/unit/test_milvus_backend.py Adds mocked unit tests for key MilvusBackend behaviors.
tests/integration/test_milvus_backend.py Adds live-backend integration tests (auto-skip if Milvus unavailable).
tests/integration/test_milvus_e2e.py Adds end-to-end pipeline tests against live Milvus with mocked providers.
README.md Adds Milvus install + usage examples and updates backend list.
pyproject.toml Adds milvus extra (pymilvus) and includes it in all.
examples/ollama_milvus_session_demo.py Adds local Ollama + Milvus demo script.
docker-compose.yml Adds etcd/minio/milvus services and volumes for local Milvus runtime.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread vektori/storage/milvus.py
Comment on lines +288 to +297
if filter_expr:
for key in ("filter", "expr"):
try:
rows = await self._call("query", **{**kwargs, key: filter_expr})
return self._normalize_query_rows(rows)
except TypeError:
continue
rows = await self._call("query", **kwargs)
return self._normalize_query_rows(rows)

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

In _query(), if filter_expr is provided but neither filter nor expr keyword is accepted, the fallback call executes query without any filter, which can silently return unfiltered rows (cross-user/record_type leakage and incorrect results). Instead of querying without a filter, fail fast (raise) or add an explicit compatibility path that still applies the filter (e.g., detect the correct parameter name once and cache it).

Copilot uses AI. Check for mistakes.
Comment thread vektori/storage/milvus.py Outdated
Comment on lines +457 to +463
for key in ("filter", "expr"):
try:
await self._call("delete", collection_name=collection_name, **{key: filter_expr})
return
except TypeError:
continue
await self._call("delete", collection_name=collection_name)

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

_delete() falls back to calling delete without passing filter_expr if neither filter nor expr keyword is accepted. That risks deleting the entire collection when the client API signature differs. Safer behavior is to raise an error (or detect/correct the parameter name) rather than performing an unfiltered delete.

Suggested change
for key in ("filter", "expr"):
try:
await self._call("delete", collection_name=collection_name, **{key: filter_expr})
return
except TypeError:
continue
await self._call("delete", collection_name=collection_name)
last_error: TypeError | None = None
for key in ("filter", "expr"):
try:
await self._call("delete", collection_name=collection_name, **{key: filter_expr})
return
except TypeError as e:
last_error = e
continue
raise TypeError(
"Milvus client delete() does not accept a supported filter argument "
f"for filtered delete on collection {collection_name!r}; "
"refusing to perform an unfiltered delete."
) from last_error

Copilot uses AI. Check for mistakes.
Comment thread vektori/storage/milvus.py
Comment on lines +726 to +733
if not sentences:
return 0

rows: list[dict[str, Any]] = []

for sent, emb in zip(sentences, embeddings):
if self.embedding_dim and len(emb) != self.embedding_dim:
raise ValueError(

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

upsert_sentences() iterates with zip(sentences, embeddings), which silently truncates if the lengths differ. Consider validating len(sentences) == len(embeddings) up front and raising a clear error to avoid dropping sentences or embeddings without noticing.

Copilot uses AI. Check for mistakes.
Comment thread vektori/storage/milvus.py
Comment on lines +1152 to +1164

for fid in fact_ids:
row = await self._get_one(
self._facts_col,
f'id == "{_escape(fid)}" and record_type == "{_FACT_RECORD}"',
["source_sentence_ids", "id"],
)
if not row:
continue
for sid in _csv_to_ids(row.get("source_sentence_ids")):
if sid not in seen:
seen.add(sid)
result.append(sid)

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

get_source_sentences() issues one _get_one() query per fact ID. For larger retrievals this becomes N+1 round trips and can dominate latency. Consider batching (e.g., a single query using an id in [...] / IN-style expression, or a client method that retrieves by primary keys) and then merging/deduping in memory.

Suggested change
for fid in fact_ids:
row = await self._get_one(
self._facts_col,
f'id == "{_escape(fid)}" and record_type == "{_FACT_RECORD}"',
["source_sentence_ids", "id"],
)
if not row:
continue
for sid in _csv_to_ids(row.get("source_sentence_ids")):
if sid not in seen:
seen.add(sid)
result.append(sid)
batch_size = 200
for start in range(0, len(fact_ids), batch_size):
batch_ids = fact_ids[start : start + batch_size]
id_expr = " or ".join([f'id == "{_escape(fid)}"' for fid in batch_ids])
rows = await self._query_all(
collection_name=self._facts_col,
filter_expr=f'({id_expr}) and record_type == "{_FACT_RECORD}"',
output_fields=["source_sentence_ids", "id"],
batch_size=batch_size,
)
for row in rows:
for sid in _csv_to_ids(row.get("source_sentence_ids")):
if sid not in seen:
seen.add(sid)
result.append(sid)

Copilot uses AI. Check for mistakes.
Comment thread vektori/storage/milvus.py
Comment on lines +1168 to +1185
async def get_sentences_by_ids(self, sentence_ids: list[str]) -> list[dict[str, Any]]:
if not sentence_ids:
return []

rows: list[dict[str, Any]] = []
for sid in sentence_ids:
row = await self._get_one(
self._sentences_col,
(
f'id == "{_escape(sid)}" and '
f'record_type == "{_SENTENCE_RECORD}" and '
"is_active == true"
),
self._sentence_output_fields(),
)
if row:
rows.append(self._to_sentence(row))

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

get_sentences_by_ids() queries Milvus one ID at a time via _get_one(), which is an N+1 pattern. If Milvus supports an id in [...] filter or bulk get by primary key, batching this into a single call would significantly reduce latency for L1/L2 retrieval paths.

Suggested change
async def get_sentences_by_ids(self, sentence_ids: list[str]) -> list[dict[str, Any]]:
if not sentence_ids:
return []
rows: list[dict[str, Any]] = []
for sid in sentence_ids:
row = await self._get_one(
self._sentences_col,
(
f'id == "{_escape(sid)}" and '
f'record_type == "{_SENTENCE_RECORD}" and '
"is_active == true"
),
self._sentence_output_fields(),
)
if row:
rows.append(self._to_sentence(row))
async def _get_sentences_rows_by_ids(
self, sentence_ids: list[str], batch_size: int = 100
) -> list[dict[str, Any]]:
if not sentence_ids:
return []
seen_ids: set[str] = set()
ordered_ids: list[str] = []
for sid in sentence_ids:
if sid and sid not in seen_ids:
seen_ids.add(sid)
ordered_ids.append(sid)
rows: list[dict[str, Any]] = []
for start in range(0, len(ordered_ids), batch_size):
batch_ids = ordered_ids[start : start + batch_size]
id_filter = " or ".join([f'id == "{_escape(sid)}"' for sid in batch_ids])
filter_expr = (
f"({id_filter}) and "
f'record_type == "{_SENTENCE_RECORD}" and '
"is_active == true"
)
rows.extend(
await self._query_all(
collection_name=self._sentences_col,
filter_expr=filter_expr,
output_fields=self._sentence_output_fields(),
batch_size=batch_size,
)
)
return rows
async def get_sentences_by_ids(self, sentence_ids: list[str]) -> list[dict[str, Any]]:
if not sentence_ids:
return []
rows = [
self._to_sentence(row)
for row in await self._get_sentences_rows_by_ids(sentence_ids)
]

Copilot uses AI. Check for mistakes.
Comment thread docker-compose.yml
ETCD_SNAPSHOT_COUNT: "50000"
command:
- /usr/local/bin/etcd
- -advertise-client-urls=http://127.0.0.1:2379

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

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

etcd is configured with -advertise-client-urls=http://127.0.0.1:2379. In a Compose network this typically causes other containers (like milvus) to receive/learn a loopback endpoint that isn’t reachable from outside the etcd container. Prefer advertising the service DNS name (e.g., http://etcd:2379) so Milvus can reliably connect.

Suggested change
- -advertise-client-urls=http://127.0.0.1:2379
- -advertise-client-urls=http://etcd:2379

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
docker-compose.yml (1)

50-96: Good addition for local Milvus development stack.

The etcd, minio, and milvus services are correctly configured with:

  • Proper environment variables for service discovery (ETCD_ENDPOINTS, MINIO_ADDRESS).
  • Persistent named volumes for data retention.
  • Healthcheck on the Milvus service with generous retries (20) to accommodate startup time.

Consider adding healthchecks to etcd and minio services and using depends_on with condition: service_healthy for more robust startup ordering in CI environments. This is optional for local development.

♻️ Optional: Add healthchecks for more robust startup
   etcd:
     image: quay.io/coreos/etcd:v3.5.5
     environment:
       ETCD_AUTO_COMPACTION_MODE: revision
       ETCD_AUTO_COMPACTION_RETENTION: "1000"
       ETCD_QUOTA_BACKEND_BYTES: "4294967296"
       ETCD_SNAPSHOT_COUNT: "50000"
     command:
       - /usr/local/bin/etcd
       - -advertise-client-urls=http://127.0.0.1:2379
       - -listen-client-urls=http://0.0.0.0:2379
       - --data-dir=/etcd
     ports:
       - "2379:2379"
     volumes:
       - etcddata:/etcd
+    healthcheck:
+      test: ["CMD-SHELL", "etcdctl endpoint health || exit 1"]
+      interval: 5s
+      timeout: 5s
+      retries: 10

   minio:
     image: minio/minio:RELEASE.2024-02-17T01-15-57Z
     environment:
       MINIO_ROOT_USER: minioadmin
       MINIO_ROOT_PASSWORD: minioadmin
     command: minio server /minio_data
     ports:
       - "9000:9000"
     volumes:
       - miniodata:/minio_data
+    healthcheck:
+      test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
+      interval: 5s
+      timeout: 5s
+      retries: 10

   milvus:
     image: milvusdb/milvus:v2.5.3
     command: ["milvus", "run", "standalone"]
     environment:
       ETCD_ENDPOINTS: etcd:2379
       MINIO_ADDRESS: minio:9000
     ports:
       - "19530:19530"
       - "9091:9091"
     volumes:
       - milvusdata:/var/lib/milvus
     depends_on:
-      - etcd
-      - minio
+      etcd:
+        condition: service_healthy
+      minio:
+        condition: service_healthy
     healthcheck:
       test: ["CMD-SHELL", "curl -sf http://localhost:9091/healthz || exit 1"]
       interval: 5s
       timeout: 5s
       retries: 20
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 50 - 96, Add healthchecks for the etcd and
minio services and update Milvus's startup dependency to wait for those
healthchecks; specifically, for the etcd service (identifier: etcd) add a
healthcheck that probes its client port (e.g., curl http://127.0.0.1:2379/health
or etcdctl endpoint status) with reasonable interval/timeout/retries, and for
the minio service (identifier: minio) add a healthcheck that hits the MinIO
health endpoint (e.g., curl -sf http://127.0.0.1:9000/minio/health/live or mc
admin info) with interval/timeout/retries; then modify the milvus service's
depends_on block (identifier: milvus) to use condition: service_healthy for etcd
and minio so Milvus waits until both are healthy before starting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@vektori/storage/milvus.py`:
- Around line 813-819: The stubbed find_sentences_by_similarity must be
implemented: compute embeddings for the input quotes using the same encoder used
elsewhere (e.g., the class's embedding helper), perform vector searches against
the Milvus collection with a filter for session_id if provided, convert returned
hits whose similarity score meets the threshold into their stored sentence/text
metadata (use the collection's metadata field name, e.g., "sentence" or "text"),
deduplicate and preserve order, and return that list; ensure you use the
existing client/collection access patterns in this class (the
find_sentences_by_similarity function, the class's embedding helper and Milvus
collection/search methods) and handle empty inputs and errors gracefully.
- Around line 1319-1342: The upsert path currently overwrites stored session
metadata with {} when metadata is passed as None; fix by computing a
metadata_value that preserves existing metadata when metadata is None (i.e., use
existing.get("metadata") if existing and metadata is None) otherwise use
self._json_value(metadata), then assign that metadata_value to the
row["metadata"] field instead of always calling self._json_value(metadata);
update the upsert logic around the row construction (reference symbols: row,
existing, metadata, self._json_value) to avoid erasing stored metadata on
metadata-less upserts.
- Around line 1210-1217: The dedup key currently omits agent_id causing
cross-agent collisions; update insert_episode() so episode_id generation and the
dedupe lookup include agent_id: include agent_id in the uuid5 input (e.g.
f"{user_id}::{agent_id}::{text}") when assigning episode_id and add an
additional condition to the _get_one query (e.g. `and agent_id ==
"{_escape(agent_id)}"`) against self._episodes_col using the same
_EPISODE_RECORD and _episode_output_fields() symbols so episodes are scoped per
agent.
- Around line 456-463: The _delete method currently tries calling
self._call("delete", collection_name=collection_name, **{key: filter_expr}) for
keys "filter" and "expr" and then falls back to an unfiltered delete; change
this so that you only perform the delete when one of those parameter forms
succeeds—on first successful call, call self._flush_collection(collection_name)
and return—and if both parameter attempts raise TypeError (i.e. the client
doesn't support either), raise a clear exception (e.g. RuntimeError or
TypeError) instead of calling an unfiltered delete via _call; ensure you
reference the _delete method, the _call invocation, the
filter_expr/collection_name params, and the _flush_collection call in the fix.
- Around line 288-296: The current code silently falls back to unfiltered
operations when both "filter" and "expr" are rejected; update the logic in the
Milvus helpers (the block that calls self._call("query", **{**kwargs, key:
filter_expr})) to fail closed: attempt with "filter" then "expr", and if both
attempts raise TypeError, do not re-run the operation without filters—instead
raise a clear exception (e.g., RuntimeError or ValueError) indicating the client
does not support filtering so the caller can handle it; apply the same pattern
to the analogous _search and _delete call sites and reference the same symbols
(filter_expr, kwargs, self._call("query") / self._call("search") /
self._call("delete")).

---

Nitpick comments:
In `@docker-compose.yml`:
- Around line 50-96: Add healthchecks for the etcd and minio services and update
Milvus's startup dependency to wait for those healthchecks; specifically, for
the etcd service (identifier: etcd) add a healthcheck that probes its client
port (e.g., curl http://127.0.0.1:2379/health or etcdctl endpoint status) with
reasonable interval/timeout/retries, and for the minio service (identifier:
minio) add a healthcheck that hits the MinIO health endpoint (e.g., curl -sf
http://127.0.0.1:9000/minio/health/live or mc admin info) with
interval/timeout/retries; then modify the milvus service's depends_on block
(identifier: milvus) to use condition: service_healthy for etcd and minio so
Milvus waits until both are healthy before starting.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: d7d8042d-a0e1-491d-9c0b-b149bca1b438

📥 Commits

Reviewing files that changed from the base of the PR and between 2500816 and ff4f5b2.

📒 Files selected for processing (12)
  • README.md
  • docker-compose.yml
  • examples/ollama_milvus_session_demo.py
  • pyproject.toml
  • tests/integration/test_milvus_backend.py
  • tests/integration/test_milvus_e2e.py
  • tests/unit/test_milvus_backend.py
  • tests/unit/test_new_backends_factory.py
  • vektori/cli.py
  • vektori/config.py
  • vektori/storage/factory.py
  • vektori/storage/milvus.py

Comment thread vektori/storage/milvus.py Outdated
Comment thread vektori/storage/milvus.py Outdated
Comment thread vektori/storage/milvus.py Outdated
Comment thread vektori/storage/milvus.py Outdated
Comment thread vektori/storage/milvus.py
@laxmanclo

laxmanclo commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

@snigenigmatic is the api integration also done? can developers provide api keys and use milvus thru cloud version as well?

@snigenigmatic

Copy link
Copy Markdown
Contributor Author

@laxmanclo need to test API based providers, low on credits.

@laxmanclo

Copy link
Copy Markdown
Contributor

@snigenigmatic no worries, i'll test the API on Milvus on my end.

@snigenigmatic

Copy link
Copy Markdown
Contributor Author

@laxmanclo thanks, in the meantime, i will look into the improvements mentioned above

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (4)
vektori/storage/milvus.py (4)

1359-1364: Use _EPISODE_RECORD constant for consistency.

Line 1361 uses the hardcoded string "episode" instead of the _EPISODE_RECORD constant used elsewhere in the codebase.

♻️ Use constant for record type
         rows = await self._query_all(
             collection_name=self._episodes_col,
-            filter_expr='record_type == "episode" and is_active == true',
+            filter_expr=f'record_type == "{_EPISODE_RECORD}" and is_active == true',
             output_fields=self._episode_output_fields(),
             batch_size=200,
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 1359 - 1364, Replace the hardcoded
filter string 'record_type == "episode"' with the module constant
_EPISODE_RECORD in the call to _query_all so the filter_expr uses the constant
(e.g., build the expression using _EPISODE_RECORD) for consistency with other
code; update the call in the block that invokes self._query_all with
collection_name=self._episodes_col and
output_fields=self._episode_output_fields() to construct filter_expr using
_EPISODE_RECORD instead of the literal "episode".

251-264: String-based loop mismatch detection is fragile but pragmatic.

The check for "attached to a different loop" (line 254) handles pytest-asyncio edge cases, but could break if gRPC changes error message format. Consider catching RuntimeError more broadly or documenting this as a known limitation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 251 - 264, The current string-based
detection for a gRPC loop-mismatch is fragile; update the exception handling so
it catches RuntimeError (instead of a broad Exception) and treats RuntimeError
as the signal to fall back to the sync client (keep the logger warning, import
MilvusClient, set self._client and call via asyncio.to_thread), while re-raising
any non-RuntimeError exceptions; locate the block that references self._client,
the string check "attached to a different loop", and MilvusClient to implement
this change.

38-39: Consider preserving timezone info in ISO timestamps.

_utcnow_iso() strips timezone info via replace(tzinfo=None). If downstream code or clients expect timezone-aware timestamps, this could cause subtle issues. Consider using .isoformat() directly or appending Z suffix for explicit UTC.

♻️ Optional: Preserve UTC indicator
 def _utcnow_iso() -> str:
-    return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
+    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 38 - 39, The helper _utcnow_iso
currently strips timezone info (via replace(tzinfo=None)), which can break
consumers expecting UTC-aware timestamps; update _utcnow_iso to return an ISO
8601 UTC-aware string by either calling datetime.now(timezone.utc).isoformat()
(preserving the +00:00) or by generating a naive UTC time and appending a "Z"
suffix so the returned value explicitly indicates UTC; adjust any callers if
they relied on naive strings.

473-483: Fallback upsert path swallows delete errors which may mask issues.

When the primary upsert fails and the code falls back to delete-then-insert, exceptions from _delete (including the new RuntimeError for unsupported filters) are silently swallowed (lines 478-481). This could lead to duplicate key errors on the subsequent insert if the old records weren't actually deleted.

Consider logging the delete failure or allowing it to propagate in the fallback path.

♻️ Log delete failures in fallback path
             if ids:
                 ors = " or ".join([f'id == "{_escape(i)}"' for i in ids])
                 try:
                     await self._delete(collection_name, ors)
-                except Exception:
-                    pass
+                except Exception as e:
+                    logger.warning("Fallback upsert: delete failed, may cause duplicates: %s", e)
             await self._call("insert", collection_name=collection_name, data=rows)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 473 - 483, The fallback
delete-then-insert path currently swallows all exceptions from await
self._delete(...), which can mask failures and cause duplicate-key insert
errors; change the bare except to capture the exception (except Exception as e)
and either log the failure with context (collection_name, ors or ids) using the
instance logger (e.g. self._logger or configured logger) and re-raise to stop
the insert, or handle RuntimeError from _delete (unsupported filter) separately
by logging a warning and continuing; ensure the changes are applied around the
_delete call in the upsert fallback so that failures are not silently ignored
before calling _call("insert", ...) and _flush_collection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@vektori/storage/milvus.py`:
- Around line 1359-1364: Replace the hardcoded filter string 'record_type ==
"episode"' with the module constant _EPISODE_RECORD in the call to _query_all so
the filter_expr uses the constant (e.g., build the expression using
_EPISODE_RECORD) for consistency with other code; update the call in the block
that invokes self._query_all with collection_name=self._episodes_col and
output_fields=self._episode_output_fields() to construct filter_expr using
_EPISODE_RECORD instead of the literal "episode".
- Around line 251-264: The current string-based detection for a gRPC
loop-mismatch is fragile; update the exception handling so it catches
RuntimeError (instead of a broad Exception) and treats RuntimeError as the
signal to fall back to the sync client (keep the logger warning, import
MilvusClient, set self._client and call via asyncio.to_thread), while re-raising
any non-RuntimeError exceptions; locate the block that references self._client,
the string check "attached to a different loop", and MilvusClient to implement
this change.
- Around line 38-39: The helper _utcnow_iso currently strips timezone info (via
replace(tzinfo=None)), which can break consumers expecting UTC-aware timestamps;
update _utcnow_iso to return an ISO 8601 UTC-aware string by either calling
datetime.now(timezone.utc).isoformat() (preserving the +00:00) or by generating
a naive UTC time and appending a "Z" suffix so the returned value explicitly
indicates UTC; adjust any callers if they relied on naive strings.
- Around line 473-483: The fallback delete-then-insert path currently swallows
all exceptions from await self._delete(...), which can mask failures and cause
duplicate-key insert errors; change the bare except to capture the exception
(except Exception as e) and either log the failure with context
(collection_name, ors or ids) using the instance logger (e.g. self._logger or
configured logger) and re-raise to stop the insert, or handle RuntimeError from
_delete (unsupported filter) separately by logging a warning and continuing;
ensure the changes are applied around the _delete call in the upsert fallback so
that failures are not silently ignored before calling _call("insert", ...) and
_flush_collection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66c37818-863f-40e7-8ce9-63dd50a9de13

📥 Commits

Reviewing files that changed from the base of the PR and between ff4f5b2 and fa15abf.

📒 Files selected for processing (4)
  • docker-compose.yml
  • tests/unit/test_milvus_backend.py
  • vektori/client.py
  • vektori/storage/milvus.py

…nstant

- make _utcnow_iso return UTC-aware ISO timestamps

- catch RuntimeError for async loop mismatch and fall back to sync MilvusClient

- stop swallowing _delete failures in upsert fallback (warn on unsupported delete, re-raise others)

- replace hardcoded episode filter with _EPISODE_RECORD

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@vektori/storage/milvus.py`:
- Around line 1420-1435: The session marker and sentence lookups must be
tenant-scoped: change _session_marker_id to include user_id (e.g., hash
f"session::{user_id}::{session_id}") and update all callers (e.g.,
upsert_session and get_session) so they pass user_id when generating the marker;
also modify the sentence fetch/filter queries against _sentences_col (the
_get_one and any sentence list queries around upsert_session and the block at
1491-1497) to add a user_id equality clause (e.g., and user_id == "<user_id>")
so marker rows and session sentences are isolated per user/tenant. Ensure every
place that builds or queries by the marker or session rows uses the new
user-scoped marker and filter.
- Around line 1248-1258: The current loop over by_fact reads a row with
_get_one, appends CSV ids and calls _upsert_rows, which causes last-writer-wins
races; change this to a compare-and-swap retry: read the row via _get_one,
compute the merged ids using _csv_to_ids/_ids_to_csv, then call _upsert_rows or
an update API with a conditional filter that the row's previous
source_sentence_ids equals the value you read (i.e. include the original
source_sentence_ids in the update predicate); on failure (no rows updated)
re-read and retry until success (or limit retries). Apply the same pattern to
the analogous block around lines 1355-1365 so updates on _facts_col are atomic
and do not drop concurrent links.
- Around line 1208-1217: The query in expand_session_context() incorrectly
restricts results to the same turn by using the equality filter turn_number ==
{turn_number}, which prevents fetching neighboring sentences across turn
boundaries; update the filter passed to _query_all (for collection
self._sentences_col, record type _SENTENCE_RECORD) to remove the turn_number
equality constraint and instead only filter by session_id (using
_escape(session_id)), the sentence_index range (sentence_index >=
{sentence_index - window} and sentence_index <= {sentence_index + window}), and
is_active == true so surrounding sentences from adjacent turns are included.
- Around line 466-495: The fallback delete+insert is currently triggered by a
broad except Exception around self._call("upsert", ...) which masks real write
errors; change this to only use the fallback when the error unmistakably
indicates that upsert is unsupported (e.g., catch a specific NotImplementedError
/ client-specific UnsupportedOperationError or inspect the exception message for
an "unsupported" / "not implemented" indicator), re-raise any other exceptions
so transient RPC/validation failures are not treated as capability gaps, and
keep the existing logic that calls _delete(...) and then _call("insert", ...)
only inside that narrowed except path; reference the _call("upsert",
collection_name=collection_name, data=rows) invocation and the fallback block
that calls _delete and _call("insert") when making the change.
- Around line 788-793: The code currently zips sentences and embeddings which
silently truncates when their lengths differ; before the loop that does for
sent, emb in zip(sentences, embeddings) (and before the early-return for empty
sentences), validate that both inputs are non-empty and have equal lengths
(len(sentences) == len(embeddings)); if not, raise a ValueError (or log and
return an error) with a clear message including the lengths so the caller fails
fast and the mismatch is obvious.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31451873-eddb-45d0-975b-09bf1eb888b7

📥 Commits

Reviewing files that changed from the base of the PR and between fa15abf and 8c53c57.

📒 Files selected for processing (1)
  • vektori/storage/milvus.py

Comment thread vektori/storage/milvus.py Outdated
Comment thread vektori/storage/milvus.py
Comment thread vektori/storage/milvus.py
"mentions": 1,
"is_active": True,
"superseded_by": superseded_by_target or "",
"event_time": event_time.isoformat() if event_time else "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Normalize event_time before doing string-based date filters.

These predicates compare raw ISO strings. That is only chronologically correct if every stored/query value uses the same offset, so mixed offsets like -05:00 and +00:00 will return wrong before_date / after_date results.

Also applies to: 1051-1054

Comment thread vektori/storage/milvus.py Outdated
Comment thread vektori/storage/milvus.py Outdated
  - Scope session markers by user_id to prevent cross-tenant collisions
  - Add user_id filter to session sentence queries in get_session
  - Use compare-and-swap retry loop in insert_fact_sources and
    insert_episode_fact to prevent last-writer-wins races
  - Remove turn_number equality constraint from expand_session_context
    so the window spans adjacent turns
  - Narrow _upsert_rows fallback to only trigger on unsupported-upsert
    errors instead of catching all exceptions
  - Validate sentences/embeddings length match before zip in
    upsert_sentences
  - Fix _call to catch pymilvus-wrapped RuntimeError on event loop
    mismatch

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
vektori/storage/milvus.py (2)

1035-1035: ⚠️ Potential issue | 🟠 Major

Normalize event_time before filtering on it.

event_time is persisted as raw isoformat() output and later compared lexicographically. Mixed offsets — plus the empty-string default for missing timestamps — make before_date / after_date results incorrect. Persist a canonical UTC form (or epoch) and exclude empty values when date filters are active.

Also applies to: 1068-1071

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` at line 1035, Persist event_time in a canonical
UTC form and avoid empty-string timestamps when date filters are used: replace
the current "event_time": event_time.isoformat() if event_time else "" logic
with a normalized UTC representation (e.g.,
event_time.astimezone(datetime.timezone.utc).isoformat() or
event_time.timestamp()) so stored values are comparable
lexicographically/chronologically, and when date filters
(before_date/after_date) are active omit the field or store a null/absent value
instead of "" so filtering logic in the code that uses "event_time" (the
variable and the "event_time" dict key found at the shown locations and the
similar block around lines 1068-1071) behaves correctly.

1277-1289: ⚠️ Potential issue | 🟠 Major

This is still read-check-write, not compare-and-swap.

The extra _get_one(... == previous_csv) narrows the race window, but two writers can still read the same old value, both pass the check, and the last _upsert_rows wins. If these links must survive concurrent updates, this needs serialization or a true conditional update instead of rewriting CSV blobs.

Also applies to: 1406-1417

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 1277 - 1289, The current logic around
_get_one + _upsert_rows is still a read-check-write race; replace it with an
atomic conditional update so the write only succeeds if source_sentence_ids
equals the previous CSV on the server. Instead of calling _get_one(...) then
_upsert_rows(...), use Milvus/Milvus-client's server-side conditional update
(update with an expression/filter like `id == "<fid>" and source_sentence_ids ==
"<previous_csv>"`) or your storage-layer helper that performs an atomic update,
failing the update when the expression doesn't match; update the code paths that
use this pattern (the block referencing _get_one, _facts_col,
source_sentence_ids, previous_csv and the subsequent _upsert_rows call, and the
duplicate block around lines 1406-1417) to use that conditional update and
handle the false/failed update case by retrying or aborting as appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@vektori/storage/milvus.py`:
- Around line 1224-1232: The current _query_all call on self._sentences_col uses
only session_id and a sentence_index range which is per-turn and can pull
unrelated sentences; fix by making the window turn-aware: first fetch the seed
sentence (querying for record_type == _SENTENCE_RECORD, session_id and the exact
sentence_index) to get its turn_number (or global ordering key), then either (A)
constrain the follow-up _query_all to include turn_number == seed_turn and the
sentence_index range within that turn, or (B) fetch the ordered sentence stream
for the session (order by turn_number, sentence_index) and slice client-side
around the seed position to build the window; update the code paths that call
_query_all with these new bounds (references: _query_all, self._sentences_col,
_SENTENCE_RECORD, session_id, sentence_index, window).
- Around line 822-829: The current dedupe query in the call to self._get_one
(using self._sentences_col and record type _SENTENCE_RECORD) only matches on
content_hash and can cross tenants/users; change the WHERE clause to also scope
by the tenant/user identifier stored with sentences (e.g., add a clause like and
user_id == "{_escape(user_id)}" or the appropriate tenant field) so the lookup
becomes content_hash + user/tenant-scoped. Update the query construction around
the existing f-string used for content_hash and ensure you use the same escaping
helper (_escape) and the correct variable name for the current user's id.
- Around line 249-265: The RuntimeError except block is too broad and causes any
RuntimeError to trigger the sync fallback; update the exception handling around
the await fn(**kwargs) call so you only fall back when the exception text
indicates the loop mismatch. Concretely, change the bare "except RuntimeError"
to capture the exception (e.g., "except RuntimeError as exc") and re-raise
unless "attached to a different loop" (or the same loop-mismatch substring)
appears in str(exc); keep the existing Milvus-wrapped Exception check but ensure
both branches only fall through to creating MilvusClient and calling fn via
asyncio.to_thread when the message confirms a loop mismatch, otherwise re-raise
the original exception to avoid replaying real failures for functions like the
mutating methods on self._client.

---

Duplicate comments:
In `@vektori/storage/milvus.py`:
- Line 1035: Persist event_time in a canonical UTC form and avoid empty-string
timestamps when date filters are used: replace the current "event_time":
event_time.isoformat() if event_time else "" logic with a normalized UTC
representation (e.g., event_time.astimezone(datetime.timezone.utc).isoformat()
or event_time.timestamp()) so stored values are comparable
lexicographically/chronologically, and when date filters
(before_date/after_date) are active omit the field or store a null/absent value
instead of "" so filtering logic in the code that uses "event_time" (the
variable and the "event_time" dict key found at the shown locations and the
similar block around lines 1068-1071) behaves correctly.
- Around line 1277-1289: The current logic around _get_one + _upsert_rows is
still a read-check-write race; replace it with an atomic conditional update so
the write only succeeds if source_sentence_ids equals the previous CSV on the
server. Instead of calling _get_one(...) then _upsert_rows(...), use
Milvus/Milvus-client's server-side conditional update (update with an
expression/filter like `id == "<fid>" and source_sentence_ids ==
"<previous_csv>"`) or your storage-layer helper that performs an atomic update,
failing the update when the expression doesn't match; update the code paths that
use this pattern (the block referencing _get_one, _facts_col,
source_sentence_ids, previous_csv and the subsequent _upsert_rows call, and the
duplicate block around lines 1406-1417) to use that conditional update and
handle the false/failed update case by retrying or aborting as appropriate.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 344b782b-2689-4387-90ff-296af84f20da

📥 Commits

Reviewing files that changed from the base of the PR and between 8c53c57 and 25ba2ab.

📒 Files selected for processing (1)
  • vektori/storage/milvus.py

Comment thread vektori/storage/milvus.py
Comment on lines +249 to +265
try:
return await fn(**kwargs)
except RuntimeError:
pass # loop mismatch — fall through to sync fallback
except Exception as exc:
# pymilvus decorators wrap RuntimeError in MilvusException
# before our handler sees it; detect the wrapped loop error.
if "attached to a different loop" not in str(exc):
raise
# pytest-asyncio (or other multi-loop runtimes) may reuse a backend
# instance across event loops, which breaks grpc.aio internals.
logger.warning("AsyncMilvusClient loop mismatch detected; falling back to sync client")
from pymilvus import MilvusClient

self._client = MilvusClient(uri=self.url)
fn = getattr(self._client, method)
return await asyncio.to_thread(fn, **kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only fall back to the sync client on an actual loop-mismatch error.

The bare except RuntimeError will treat every runtime failure as a loop issue and replay the Milvus call on a new sync client. For mutating operations, that can resend a write after a real partial failure.

Proposed fix
         if inspect.iscoroutinefunction(fn):
             try:
                 return await fn(**kwargs)
-            except RuntimeError:
-                pass  # loop mismatch — fall through to sync fallback
+            except RuntimeError as exc:
+                if "attached to a different loop" not in str(exc):
+                    raise
             except Exception as exc:
                 # pymilvus decorators wrap RuntimeError in MilvusException
                 # before our handler sees it; detect the wrapped loop error.
                 if "attached to a different loop" not in str(exc):
                     raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 249 - 265, The RuntimeError except
block is too broad and causes any RuntimeError to trigger the sync fallback;
update the exception handling around the await fn(**kwargs) call so you only
fall back when the exception text indicates the loop mismatch. Concretely,
change the bare "except RuntimeError" to capture the exception (e.g., "except
RuntimeError as exc") and re-raise unless "attached to a different loop" (or the
same loop-mismatch substring) appears in str(exc); keep the existing
Milvus-wrapped Exception check but ensure both branches only fall through to
creating MilvusClient and calling fn via asyncio.to_thread when the message
confirms a loop mismatch, otherwise re-raise the original exception to avoid
replaying real failures for functions like the mutating methods on self._client.

Comment thread vektori/storage/milvus.py
Comment on lines +822 to +829
existing = await self._get_one(
self._sentences_col,
(
f'record_type == "{_SENTENCE_RECORD}" and '
f'content_hash == "{_escape(content_hash)}"'
),
self._sentence_output_fields(),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Scope sentence deduplication by tenant.

This lookup only keys on content_hash, but that hash is derived from session/turn/text, not user_id. If two users reuse the same session id and sentence positions, they can collapse onto the same row and overwrite each other's sentence state.

Proposed fix
             existing = await self._get_one(
                 self._sentences_col,
                 (
                     f'record_type == "{_SENTENCE_RECORD}" and '
-                    f'content_hash == "{_escape(content_hash)}"'
+                    f'content_hash == "{_escape(content_hash)}" and '
+                    f'user_id == "{_escape(user_id)}"'
                 ),
                 self._sentence_output_fields(),
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 822 - 829, The current dedupe query
in the call to self._get_one (using self._sentences_col and record type
_SENTENCE_RECORD) only matches on content_hash and can cross tenants/users;
change the WHERE clause to also scope by the tenant/user identifier stored with
sentences (e.g., add a clause like and user_id == "{_escape(user_id)}" or the
appropriate tenant field) so the lookup becomes content_hash +
user/tenant-scoped. Update the query construction around the existing f-string
used for content_hash and ensure you use the same escaping helper (_escape) and
the correct variable name for the current user's id.

Comment thread vektori/storage/milvus.py
Comment on lines +1224 to +1232
rows = await self._query_all(
collection_name=self._sentences_col,
filter_expr=(
f'record_type == "{_SENTENCE_RECORD}" and '
f'session_id == "{_escape(session_id)}" and '
f"sentence_index >= {sentence_index - window} and "
f"sentence_index <= {sentence_index + window} and "
"is_active == true"
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't window a whole session on sentence_index alone.

After dropping the turn_number equality, this range now matches every turn whose local sentence_index falls in [seed - window, seed + window]. Because sentence_index is per-turn, a seed near a boundary can pull unrelated sentences from the entire session. Build the window from the session's ordered sentence stream, or add turn-aware bounds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vektori/storage/milvus.py` around lines 1224 - 1232, The current _query_all
call on self._sentences_col uses only session_id and a sentence_index range
which is per-turn and can pull unrelated sentences; fix by making the window
turn-aware: first fetch the seed sentence (querying for record_type ==
_SENTENCE_RECORD, session_id and the exact sentence_index) to get its
turn_number (or global ordering key), then either (A) constrain the follow-up
_query_all to include turn_number == seed_turn and the sentence_index range
within that turn, or (B) fetch the ordered sentence stream for the session
(order by turn_number, sentence_index) and slice client-side around the seed
position to build the window; update the code paths that call _query_all with
these new bounds (references: _query_all, self._sentences_col, _SENTENCE_RECORD,
session_id, sentence_index, window).

@laxmanclo
laxmanclo merged commit 25ba2ab into vektori-ai:main Apr 13, 2026
2 checks passed
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.

3 participants