Files
LangBot/src/langbot/pkg/vector/mgr.py
T
Daria Korenieva 0c405901d2 feat(vector): add Valkey Search vector database backend (#2276)
* feat(vector): add Valkey Search vector database backend

Add a new opt-in VectorDatabase backend backed by the Valkey Search module
(valkey/valkey-bundle), accessed via the official valkey-glide client's native
ft command namespace.

- Implements the full VectorDatabase ABC: VECTOR, FULL_TEXT and HYBRID search,
  all 8 metadata filter operators, and pagination with exact totals.
- HYBRID uses filter-then-KNN (no app-side weighted fusion); vector_weight is
  accepted for interface parity but NOT honored (docstring + one-time warning +
  docs caveat).
- Lazy connect so a down Valkey never blocks boot; mandatory
  client_name=langbot_vector_client; optional auth + TLS (never logged).
- Registered via a single elif branch in vector/mgr.py; disabled by default
  (vdb.use stays chroma) for toC compatibility.
- Adds valkey-glide>=2.4.1,<3.0.0; no protobuf/pydantic downgrade; no ORM
  change so no Alembic migration.
- Unit tests (fast lane, no server) + slow-gated integration tests
  (TEST_VALKEY_URL, valkey/valkey-bundle:9.1.0) + integration doc.

* fix(vector): paginate Valkey Search deletes and guard delete_by_filter

Address self-review follow-ups for the Valkey Search VDB backend:

- _search_keys now paginates through the full result set in batches of
  _DELETE_SCAN_BATCH instead of capping at a single hard-coded 10000-key
  page, so delete_by_file_id / delete_by_filter fully remove files and
  filters that match more than one page of chunks (no orphaned vectors).
- Add unit regression tests for the delete_by_filter mass-deletion guard:
  a filter referencing only non-indexed fields must skip and return 0
  (never fall back to match-all), and a supported filter still deletes
  matching keys.

* refactor(vector): harden Valkey Search backend and add adversarial tests

Address the self-review NICE-TO-HAVE items for the Valkey Search VDB backend:
- Guard the username-without-password credential edge (skip auth + warn
  instead of building ServerCredentials(password=None, ...), which glide
  rejects).
- Add an async close() teardown that closes the glide client and resets
  cached state (re-init is safe via the existing None guard).
- Hoist 'import json' to module top (was imported inside three methods).
- Document the FT TAG literal-brace limitation in _escape_tag (fails closed,
  never widens).

Tests:
- Add an adversarial-input integration test proving crafted file_id /
  query_text cannot break out of or widen a query (fail-closed on braces).
- Add unit tests for close() and the credential-build guard.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* fix(vector): make Valkey Search file_id TAG support arbitrary characters

Valkey Search's FT TAG query parser cannot handle '{', '}' or '*' even when
backslash-escaped, so a file_id containing those characters previously
produced an unparseable query (it failed closed / raised). Percent-encode
exactly those FT-unsafe characters (plus '%' for reversibility) in the
file_id TAG value, applied identically at write time and query time, so an
arbitrary file_id round-trips. For normal UUID/hash ids this is a no-op and
the stored value is unchanged; the original file_id is always preserved
verbatim in metadata_json.

Strengthen the adversarial integration test to assert a brace/star-bearing
file_id matches and deletes exactly its own row (no widening, no raise), and
add unit tests for _encode_file_id and the filter encoding.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* refactor(vector): address Valkey Search review feedback

- Add configurable request_timeout (default 5000ms; glide default 250ms is
  too low for KNN); expose in config.yaml + docs table
- Validate embedding dimension consistency in add_embeddings (fail fast on
  mixed lengths to avoid silent KNN corruption)
- Use ft.info (O(1)) instead of ft.list (O(n)) for index existence checks in
  the query hot path; also closes the check-then-create TOCTOU window
- Pipeline HSETs via a non-atomic Batch instead of N sequential awaits
- Extract shared _iter_reply_docs to deduplicate reply parsing between
  _reply_to_chroma and list_by_filter
- Parenthesize multi-condition pre-filters before the => KNN clause
- Fail closed when a username is configured without a password
- Catch only RequestError on ft.dropindex (let connection/auth errors surface)
- Bound the delete_collection SCAN loop with a safety cap
- Add VectorDatabase.close() (no-op default) + VectorDBManager.shutdown()
- Simplify _MATCH_ALL literal; normalize typing to builtin generics

* fix(vector/valkey_search): address round-2 review feedback

- Serialize lazy client creation with an asyncio.Lock (double-checked) so
  concurrent first-use callers don't construct and leak duplicate clients.
- Make the filter operator chain exhaustive: raise on an unhandled op rather
  than silently dropping the condition (which could widen delete_by_filter).
- Cast numeric range (///) values to float, failing closed on
  non-numeric input and pre-empting a future NUMERIC-field injection surface.

* refactor(vector): remove shutdown/close from base ABC per maintainer feedback Per maintainer request, interface changes to VectorDatabase ABC and VectorDBManager should be in a separate PR with implementation across all backends. The ValkeySearchVectorDatabase.close() method remains but does not override an ABC method.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* docs(test): list valkey_search in vdb coverage exclusions Add valkey_search to the documented vector/vdbs/ coverage-exclusion list, matching the existing chroma/milvus/pgvector/qdrant/seekdb entries. These adapters require a live database instance and are covered by env-gated integration tests instead of unit tests.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

---------

Signed-off-by: Daria Korenieva <daric2612@gmail.com>
2026-07-08 06:59:16 +08:00

191 lines
7.4 KiB
Python

from __future__ import annotations
from ..core import app
from .vdb import VectorDatabase, SearchType
class VectorDBManager:
ap: app.Application
vector_db: VectorDatabase = None
def __init__(self, ap: app.Application):
self.ap = ap
async def initialize(self):
kb_config = self.ap.instance_config.data.get('vdb')
if kb_config:
vdb_type = kb_config.get('use')
if vdb_type == 'chroma':
from .vdbs.chroma import ChromaVectorDatabase
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.info('Initialized Chroma vector database backend.')
elif vdb_type == 'qdrant':
from .vdbs.qdrant import QdrantVectorDatabase
self.vector_db = QdrantVectorDatabase(self.ap)
self.ap.logger.info('Initialized Qdrant vector database backend.')
elif vdb_type == 'seekdb':
from .vdbs.seekdb import SeekDBVectorDatabase
self.vector_db = SeekDBVectorDatabase(self.ap)
self.ap.logger.info('Initialized SeekDB vector database backend.')
elif vdb_type == 'valkey_search':
from .vdbs.valkey_search import ValkeySearchVectorDatabase
self.vector_db = ValkeySearchVectorDatabase(self.ap)
self.ap.logger.info('Initialized Valkey Search vector database backend.')
elif vdb_type == 'milvus':
from .vdbs.milvus import MilvusVectorDatabase
# Get Milvus configuration
milvus_config = kb_config.get('milvus', {})
uri = milvus_config.get('uri', './data/milvus.db')
token = milvus_config.get('token')
db_name = milvus_config.get('db_name', 'default')
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token, db_name=db_name)
self.ap.logger.info('Initialized Milvus vector database backend.')
elif vdb_type == 'pgvector':
from .vdbs.pgvector_db import PgVectorDatabase
# Get pgvector configuration
pgvector_config = kb_config.get('pgvector', {})
connection_string = pgvector_config.get('connection_string')
if connection_string:
self.vector_db = PgVectorDatabase(self.ap, connection_string=connection_string)
else:
# Use individual parameters
host = pgvector_config.get('host', 'localhost')
port = pgvector_config.get('port', 5432)
database = pgvector_config.get('database', 'langbot')
user = pgvector_config.get('user', 'postgres')
password = pgvector_config.get('password', 'postgres')
self.vector_db = PgVectorDatabase(
self.ap, host=host, port=port, database=database, user=user, password=password
)
self.ap.logger.info('Initialized pgvector database backend.')
else:
from .vdbs.chroma import ChromaVectorDatabase
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')
else:
from .vdbs.chroma import ChromaVectorDatabase
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No vector database backend configured, defaulting to Chroma.')
def get_supported_search_types(self) -> list[str]:
"""Return the search types supported by the current VDB backend."""
if self.vector_db is None:
return [SearchType.VECTOR.value]
return [st.value for st in self.vector_db.supported_search_types()]
async def upsert(
self,
collection_name: str,
vectors: list[list[float]],
ids: list[str],
metadata: list[dict] | None = None,
documents: list[str] | None = None,
):
"""Proxy: Upsert vectors"""
await self.vector_db.add_embeddings(
collection=collection_name,
ids=ids,
embeddings_list=vectors,
metadatas=metadata or [{} for _ in vectors],
documents=documents,
)
async def search(
self,
collection_name: str,
query_vector: list[float],
limit: int,
filter: dict | None = None,
search_type: str = 'vector',
query_text: str = '',
vector_weight: float | None = None,
) -> list[dict]:
"""Proxy: Search vectors.
Returns a list of dicts with keys: 'id', 'distance', 'metadata'.
The underlying VectorDatabase.search returns Chroma-style format:
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
"""
results = await self.vector_db.search(
collection=collection_name,
query_embedding=query_vector,
k=limit,
search_type=search_type,
query_text=query_text,
filter=filter,
vector_weight=vector_weight,
)
if not results or 'ids' not in results or not results['ids']:
return []
# Flatten nested lists (Chroma returns batch-style: list of lists)
raw_ids = results['ids']
raw_dists = results.get('distances', [])
raw_metas = results.get('metadatas', [])
r_ids = raw_ids[0] if raw_ids and isinstance(raw_ids[0], list) else raw_ids
r_dists = raw_dists[0] if raw_dists and isinstance(raw_dists[0], list) else raw_dists
r_metas = raw_metas[0] if raw_metas and isinstance(raw_metas[0], list) else raw_metas
parsed_results = []
for i, id_val in enumerate(r_ids):
parsed_results.append(
{
'id': id_val,
'distance': r_dists[i] if r_dists and i < len(r_dists) else 0.0,
'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},
}
)
return parsed_results
async def delete_by_file_id(self, collection_name: str, file_ids: list[str]):
"""Proxy: Delete vectors by file_id (metadata-level identifier).
This delegates to VectorDatabase.delete_by_file_id which removes
all vectors associated with the given file IDs.
"""
for file_id in file_ids:
await self.vector_db.delete_by_file_id(collection_name, file_id)
async def delete_collection(self, collection_name: str):
"""Proxy: Delete an entire collection."""
await self.vector_db.delete_collection(collection_name)
async def delete_by_filter(self, collection_name: str, filter: dict) -> int:
"""Proxy: Delete vectors by metadata filter.
Returns:
Number of deleted vectors (best-effort; some backends return 0).
"""
return await self.vector_db.delete_by_filter(collection_name, filter)
async def list_by_filter(
self,
collection_name: str,
filter: dict | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Proxy: List vectors by metadata filter with pagination.
Returns:
Tuple of (items, total).
"""
return await self.vector_db.list_by_filter(collection_name, filter, limit, offset)