feat(ext): add Valkey vector store integration (#3051)

Signed-off-by: Daria Korenieva <daric2612@gmail.com>
This commit is contained in:
Daria Korenieva
2026-05-09 04:13:52 -07:00
committed by GitHub
parent 22e87ece6e
commit ca126edfab
14 changed files with 2277 additions and 45 deletions

View File

@@ -28,8 +28,18 @@ import { ConfigDetail } from "@site/src/components/mdx/ConfigDetail";
},
{
"type": "link",
"text": "pgvector configuration",
"url": "../vector_store/pgvector_store_pgvectorconfig_3ef448"
"text": "qdrant configuration",
"url": "../vector_store/qdrant_store_qdrantvectorconfig_3d0339"
},
{
"type": "link",
"text": "valkey configuration",
"url": "../vector_store/valkey_store_valkeyvectorconfig_a0fffb"
},
{
"type": "link",
"text": "oceanbase configuration",
"url": "../vector_store/oceanbase_store_oceanbaseconfig_220e36"
},
{
"type": "link",
@@ -38,13 +48,8 @@ import { ConfigDetail } from "@site/src/components/mdx/ConfigDetail";
},
{
"type": "link",
"text": "milvus configuration",
"url": "../vector_store/milvus_store_milvusvectorconfig_20af52"
},
{
"type": "link",
"text": "oceanbase configuration",
"url": "../vector_store/oceanbase_store_oceanbaseconfig_220e36"
"text": "pgvector configuration",
"url": "../vector_store/pgvector_store_pgvectorconfig_3ef448"
}
],
"defaultValue": "ChromaVectorConfig"
@@ -59,6 +64,11 @@ import { ConfigDetail } from "@site/src/components/mdx/ConfigDetail";
"type": "link",
"text": "tugraph configuration",
"url": "../graph_store/tugraph_store_tugraphstoreconfig_7ca8a8"
},
{
"type": "link",
"text": "neo4j configuration",
"url": "../graph_store/neo4j_store_neo4jstoreconfig_a4db5d"
}
]
},
@@ -80,8 +90,18 @@ import { ConfigDetail } from "@site/src/components/mdx/ConfigDetail";
},
{
"type": "link",
"text": "pgvector configuration",
"url": "../vector_store/pgvector_store_pgvectorconfig_3ef448"
"text": "qdrant configuration",
"url": "../vector_store/qdrant_store_qdrantvectorconfig_3d0339"
},
{
"type": "link",
"text": "valkey configuration",
"url": "../vector_store/valkey_store_valkeyvectorconfig_a0fffb"
},
{
"type": "link",
"text": "oceanbase configuration",
"url": "../vector_store/oceanbase_store_oceanbaseconfig_220e36"
},
{
"type": "link",
@@ -90,13 +110,8 @@ import { ConfigDetail } from "@site/src/components/mdx/ConfigDetail";
},
{
"type": "link",
"text": "milvus configuration",
"url": "../vector_store/milvus_store_milvusvectorconfig_20af52"
},
{
"type": "link",
"text": "oceanbase configuration",
"url": "../vector_store/oceanbase_store_oceanbaseconfig_220e36"
"text": "pgvector configuration",
"url": "../vector_store/pgvector_store_pgvectorconfig_3ef448"
}
]
}

View File

@@ -9,17 +9,17 @@ This document provides an overview of all configuration classes organized by typ
## Configuration Types
- [app](#type-app) (6 classes)
- [datasource](#type-datasource) (14 classes)
- [embedding](#type-embedding) (6 classes)
- [graph_store](#type-graph_store) (4 classes)
- [llm](#type-llm) (22 classes)
- [datasource](#type-datasource) (18 classes)
- [embedding](#type-embedding) (8 classes)
- [graph_store](#type-graph_store) (3 classes)
- [llm](#type-llm) (28 classes)
- [memory](#type-memory) (2 classes)
- [other](#type-other) (1 classes)
- [reranker](#type-reranker) (3 classes)
- [reranker](#type-reranker) (6 classes)
- [serve](#type-serve) (13 classes)
- [service](#type-service) (1 classes)
- [utils](#type-utils) (2 classes)
- [vector_store](#type-vector_store) (6 classes)
- [vector_store](#type-vector_store) (7 classes)
## Type Details
@@ -42,7 +42,7 @@ This type contains 6 configuration classes.
### datasource {#type-datasource}
This type contains 14 configuration classes.
This type contains 18 configuration classes.
#### Configuration Classes
@@ -51,10 +51,13 @@ This type contains 14 configuration classes.
| [ClickhouseParameters](datasource/conn_clickhouse_clickhouseparameters_4a1237) | |
| [DorisParameters](datasource/conn_doris_dorisparameters_e33c53) | |
| [DuckDbConnectorParameters](datasource/conn_duckdb_duckdbconnectorparameters_c672c7) | |
| [GaussDBParameters](datasource/conn_gaussdb_gaussdbparameters_9c9811) | |
| [HiveParameters](datasource/conn_hive_hiveparameters_ec3601) | |
| [MSSQLParameters](datasource/conn_mssql_mssqlparameters_d79d1c) | |
| [MySQLParameters](datasource/conn_mysql_mysqlparameters_4393c4) | |
| [Neo4jParameters](datasource/conn_neo4j_neo4jparameters_041b12) | |
| [OceanBaseParameters](datasource/conn_oceanbase_oceanbaseparameters_260d2d) | |
| [OracleParameters](datasource/conn_oracle_oracleparameters_c011ed) | |
| [PostgreSQLParameters](datasource/conn_postgresql_postgresqlparameters_22efa5) | |
| [RDBMSDatasourceParameters](datasource/base_rdbmsdatasourceparameters_4f774f) | |
| [SQLiteConnectorParameters](datasource/conn_sqlite_sqliteconnectorparameters_82c8b5) | |
@@ -62,35 +65,37 @@ This type contains 14 configuration classes.
| [StarRocksParameters](datasource/conn_starrocks_starrocksparameters_e511f7) | |
| [TuGraphParameters](datasource/conn_tugraph_tugraphparameters_0c844e) | |
| [VerticaParameters](datasource/conn_vertica_verticaparameters_c712b8) | |
| [openGaussParameters](datasource/conn_opengauss_opengaussparameters_85409c) | |
---
### embedding {#type-embedding}
This type contains 6 configuration classes.
This type contains 8 configuration classes.
#### Configuration Classes
| Class | Description |
|-------|-------------|
| [AimlapiEmbeddingDeployModelParameters](embedding/aimlapi_aimlapiembeddingdeploymodelparameters_97d1c4) | |
| [HFEmbeddingDeployModelParameters](embedding/embeddings_hfembeddingdeploymodelparameters_f588e1) | |
| [JinaEmbeddingsDeployModelParameters](embedding/jina_jinaembeddingsdeploymodelparameters_40b0f2) | |
| [OllamaEmbeddingDeployModelParameters](embedding/ollama_ollamaembeddingdeploymodelparameters_b511e0) | |
| [OpenAPIEmbeddingDeployModelParameters](embedding/embeddings_openapiembeddingdeploymodelparameters_f9ba47) | |
| [QianfanEmbeddingDeployModelParameters](embedding/qianfan_qianfanembeddingdeploymodelparameters_257d2a) | |
| [SiliconFlowEmbeddingDeployModelParameters](embedding/siliconflow_siliconflowembeddingdeploymodelparameters_113c43) | |
| [TongyiEmbeddingDeployModelParameters](embedding/tongyi_tongyiembeddingdeploymodelparameters_a7cbb4) | |
---
### graph_store {#type-graph_store}
This type contains 4 configuration classes.
This type contains 3 configuration classes.
#### Configuration Classes
| Class | Description |
|-------|-------------|
| [BuiltinKnowledgeGraphConfig](graph_store/knowledge_graph_builtinknowledgegraphconfig_f26e05) | |
| [Neo4jStoreConfig](graph_store/neo4j_store_neo4jstoreconfig_a4db5d) | |
| [OpenSPGConfig](graph_store/open_spg_openspgconfig_a744fd) | |
| [TuGraphStoreConfig](graph_store/tugraph_store_tugraphstoreconfig_7ca8a8) | |
@@ -99,7 +104,7 @@ This type contains 4 configuration classes.
### llm {#type-llm}
This type contains 22 configuration classes.
This type contains 28 configuration classes.
#### Relationships
@@ -114,17 +119,23 @@ graph TD
| Class | Description |
|-------|-------------|
| [AimlapiDeployModelParameters](llm/aimlapi_aimlapideploymodelparameters_7c1b54) | |
| [BaichuanDeployModelParameters](llm/baichuan_baichuandeploymodelparameters_0bf9cc) | |
| [BitsandbytesQuantization](llm/parameter_bitsandbytesquantization_d40e3b) | |
| [BitsandbytesQuantization4bits](llm/parameter_bitsandbytesquantization4bits_52b778) | |
| [BitsandbytesQuantization8bits](llm/parameter_bitsandbytesquantization8bits_909aed) | |
| [BurnCloudDeployModelParameters](llm/burncloud_burnclouddeploymodelparameters_e59cbb) | |
| [ClaudeDeployModelParameters](llm/claude_claudedeploymodelparameters_1f0c45) | |
| [DeepSeekDeployModelParameters](llm/deepseek_deepseekdeploymodelparameters_194cbd) | |
| [GeminiDeployModelParameters](llm/gemini_geminideploymodelparameters_5113b9) | |
| [GiteeDeployModelParameters](llm/gitee_giteedeploymodelparameters_d1bdb3) | |
| [HFLLMDeployModelParameters](llm/hf_adapter_hfllmdeploymodelparameters_103e81) | |
| [InfiniAIDeployModelParameters](llm/infiniai_infiniaideploymodelparameters_682301) | |
| [LiteLLMDeployModelParameters](llm/litellm_litellmdeploymodelparameters_d053f9) | |
| [LlamaCppModelParameters](llm/llama_cpp_py_adapter_llamacppmodelparameters_e88874) | |
| [LlamaServerParameters](llm/llama_cpp_adapter_llamaserverparameters_421f40) | |
| [MLXDeployModelParameters](llm/mlx_adapter_mlxdeploymodelparameters_f26f45) | |
| [MiniMaxDeployModelParameters](llm/minimax_minimaxdeploymodelparameters_97ce61) | |
| [MoonshotDeployModelParameters](llm/moonshot_moonshotdeploymodelparameters_aa2f6b) | |
| [OllamaDeployModelParameters](llm/ollama_ollamadeploymodelparameters_d55be6) | |
| [OpenAICompatibleDeployModelParameters](llm/chatgpt_openaicompatibledeploymodelparameters_c3d426) | |
@@ -166,15 +177,18 @@ This type contains 1 configuration classes.
### reranker {#type-reranker}
This type contains 3 configuration classes.
This type contains 6 configuration classes.
#### Configuration Classes
| Class | Description |
|-------|-------------|
| [CrossEncoderRerankEmbeddingsParameters](reranker/rerank_crossencoderrerankembeddingsparameters_63ec13) | |
| [InfiniAIRerankEmbeddingsParameters](reranker/rerank_infiniairerankembeddingsparameters_879440) | |
| [OpenAPIRerankerDeployModelParameters](reranker/rerank_openapirerankerdeploymodelparameters_778108) | |
| [QwenRerankEmbeddingsParameters](reranker/rerank_qwenrerankembeddingsparameters_18e7d2) | |
| [SiliconFlowRerankEmbeddingsParameters](reranker/rerank_siliconflowrerankembeddingsparameters_af0257) | |
| [TeiEmbeddingsParameters](reranker/rerank_teiembeddingsparameters_e38e28) | |
---
@@ -229,7 +243,7 @@ This type contains 2 configuration classes.
### vector_store {#type-vector_store}
This type contains 6 configuration classes.
This type contains 7 configuration classes.
#### Configuration Classes
@@ -237,9 +251,10 @@ This type contains 6 configuration classes.
|-------|-------------|
| [ChromaVectorConfig](vector_store/chroma_store_chromavectorconfig_16224f) | |
| [ElasticsearchStoreConfig](vector_store/elastic_store_elasticsearchstoreconfig_15bdb6) | |
| [MilvusVectorConfig](vector_store/milvus_store_milvusvectorconfig_20af52) | |
| [OceanBaseConfig](vector_store/oceanbase_store_oceanbaseconfig_220e36) | |
| [PGVectorConfig](vector_store/pgvector_store_pgvectorconfig_3ef448) | |
| [QdrantVectorConfig](vector_store/qdrant_store_qdrantvectorconfig_3d0339) | |
| [ValkeyVectorConfig](vector_store/valkey_store_valkeyvectorconfig_a0fffb) | |
| [WeaviateVectorConfig](vector_store/weaviate_store_weaviatevectorconfig_093ce3) | |
---
@@ -251,18 +266,18 @@ The following diagram shows relationships between different configuration types:
```mermaid
graph TD
other[other - 1 classes]
datasource[datasource - 14 classes]
llm[llm - 22 classes]
embedding[embedding - 6 classes]
reranker[reranker - 3 classes]
datasource[datasource - 18 classes]
llm[llm - 28 classes]
embedding[embedding - 8 classes]
reranker[reranker - 6 classes]
service[service - 1 classes]
graph_store[graph_store - 4 classes]
vector_store[vector_store - 6 classes]
graph_store[graph_store - 3 classes]
vector_store[vector_store - 7 classes]
serve[serve - 13 classes]
memory[memory - 2 classes]
app[app - 6 classes]
utils[utils - 2 classes]
service -->|14 connections| datasource
service -->|18 connections| datasource
other -->|13 connections| serve
serve -->|2 connections| utils
app -->|14 connections| memory

View File

@@ -22,11 +22,6 @@ import { ConfigClassTable } from '@site/src/components/mdx/ConfigClassTable';
"description": "Elasticsearch vector config.",
"link": "./elastic_store_elasticsearchstoreconfig_15bdb6"
},
{
"name": "MilvusVectorConfig",
"description": "Milvus vector config.",
"link": "./milvus_store_milvusvectorconfig_20af52"
},
{
"name": "OceanBaseConfig",
"description": "OceanBase vector store config.",
@@ -37,6 +32,16 @@ import { ConfigClassTable } from '@site/src/components/mdx/ConfigClassTable';
"description": "PG vector config.",
"link": "./pgvector_store_pgvectorconfig_3ef448"
},
{
"name": "QdrantVectorConfig",
"description": "Qdrant vector store config.",
"link": "./qdrant_store_qdrantvectorconfig_3d0339"
},
{
"name": "ValkeyVectorConfig",
"description": "Valkey vector store config.",
"link": "./valkey_store_valkeyvectorconfig_a0fffb"
},
{
"name": "WeaviateVectorConfig",
"description": "Weaviate vector config.",

View File

@@ -0,0 +1,115 @@
---
title: "Valkey Config Configuration"
description: "Valkey vector store config."
---
import { ConfigDetail } from "@site/src/components/mdx/ConfigDetail";
<ConfigDetail config={{
"name": "ValkeyVectorConfig",
"description": "Valkey vector store config.",
"documentationUrl": null,
"parameters": [
{
"name": "user",
"type": "string",
"required": false,
"description": "The user of vector store, if not set, will use the default user."
},
{
"name": "password",
"type": "string",
"required": false,
"description": "The password for Valkey store."
},
{
"name": "max_chunks_once_load",
"type": "integer",
"required": false,
"description": "The max chunks once load in vector store, if not set, will use the default value 10."
},
{
"name": "max_threads",
"type": "integer",
"required": false,
"description": "The max threads in vector store, if not set, will use the default value 1."
},
{
"name": "host",
"type": "string",
"required": false,
"description": "The host of Valkey store.",
"defaultValue": "localhost"
},
{
"name": "port",
"type": "integer",
"required": false,
"description": "The port of Valkey store.",
"defaultValue": "6379"
},
{
"name": "use_ssl",
"type": "boolean",
"required": false,
"description": "Whether to use SSL for the Valkey connection.",
"defaultValue": "False"
},
{
"name": "index_type",
"type": "string",
"required": false,
"description": "The vector index type: 'HNSW' (approximate, fast) or 'FLAT' (exact, slower).",
"defaultValue": "HNSW"
},
{
"name": "distance_metric",
"type": "string",
"required": false,
"description": "The distance metric: 'COSINE', 'L2' (Euclidean), or 'IP' (Inner Product).",
"defaultValue": "COSINE"
},
{
"name": "key_prefix",
"type": "string",
"required": false,
"description": "The key prefix for all vector store keys.",
"defaultValue": "dbgpt_vec:"
},
{
"name": "hnsw_m",
"type": "integer",
"required": false,
"description": "HNSW: number of connections per node.",
"defaultValue": "16"
},
{
"name": "hnsw_ef_construction",
"type": "integer",
"required": false,
"description": "HNSW: construction time quality factor.",
"defaultValue": "200"
},
{
"name": "hnsw_ef_runtime",
"type": "integer",
"required": false,
"description": "HNSW: runtime search quality factor.",
"defaultValue": "10"
},
{
"name": "request_timeout",
"type": "integer",
"required": false,
"description": "Request timeout in milliseconds for Valkey operations. Prevents indefinite hangs on network issues.",
"defaultValue": "5000"
},
{
"name": "metadata_schema",
"type": "object",
"required": false,
"description": "Metadata fields to index for filtering. Dict mapping field name to type: 'tag' (string) or 'numeric'. E.g. {'source': 'tag', 'page': 'numeric'}. Must be defined at store creation time."
}
]
}} />

View File

@@ -18,7 +18,7 @@ git lfs clone https://huggingface.co/GanymedeNil/text2vec-large-chinese
Update .env file and set VECTOR_STORE_TYPE.
DB-GPT currently support Chroma(Default), Milvus(>2.1), Weaviate, OceanBase vector database.
DB-GPT currently support Chroma(Default), Milvus(>2.1), Weaviate, Valkey, OceanBase vector database.
If you want to change vector db, Update your .env, set your vector store type, VECTOR_STORE_TYPE=Chroma (now only support Chroma and Milvus(>2.1), if you set Milvus, please set MILVUS_URL and MILVUS_PORT).
If you want to use OceanBase, please first start a docker container via the following command:

View File

@@ -369,6 +369,7 @@ The core framework is included by default when you `pip install dbgpt-app`. Use
|-------|---------|-------------|
| `storage_chromadb` | ChromaDB | `chromadb`, `onnxruntime` |
| `storage_milvus` | Milvus | `pymilvus` |
| `storage_valkey` | Valkey | `valkey-glide` |
| `storage_weaviate` | Weaviate | `weaviate-client` |
| `storage_elasticsearch` | Elasticsearch | `elasticsearch` |
| `storage_obvector` | OBVector | `pyobvector` |

View File

@@ -182,6 +182,7 @@ Here are some useful extras you can add:
| Extra Package | Description |
|--------------|-------------|
| `storage_milvus` | Vector store integration with Milvus |
| `storage_valkey` | Vector store integration with Valkey |
| `storage_elasticsearch` | Vector store integration with Elasticsearch |
| `datasource_postgres` | Database connector for PostgreSQL |
| `vllm` | VLLM integration for optimized inference |

View File

@@ -95,6 +95,7 @@ When creating a knowledge base, you can choose from three storage types:
| **ChromaDB** | Default embedded vector database, zero setup | `storage_chromadb` |
| **Milvus** | Distributed vector database for production scale | `storage_milvus` |
| **PGVector** | PostgreSQL extension for vector operations | `storage_pgvector` |
| **Valkey** | High-performance in-memory vector store with HNSW/FLAT indexing | `storage_valkey` |
| **Weaviate** | Cloud-native vector search engine | `storage_weaviate` |
| **Elasticsearch** | Full-text + vector hybrid search | `storage_elasticsearch` |
| **OceanBase** | Cloud-native distributed database | `storage_oceanbase` |

View File

@@ -80,6 +80,7 @@ storage_chromadb = [
storage_elasticsearch = ["elasticsearch"]
storage_qdrant = ["qdrant-client>=1.17.1; python_version < '3.13'"]
storage_obvector = ["pyobvector"]
storage_valkey = ["valkey-glide>=2.3.0"]
file_oss = [
"oss2" # Aliyun OSS

View File

@@ -66,6 +66,15 @@ def _import_qdrant() -> Tuple[Type, Type]:
return QdrantStore, QdrantVectorConfig
def _import_valkey() -> Tuple[Type, Type]:
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
)
return ValkeyStore, ValkeyVectorConfig
def _import_builtin_knowledge_graph() -> Tuple[Type, Type]:
from dbgpt_ext.storage.knowledge_graph.knowledge_graph import (
BuiltinKnowledgeGraph,
@@ -112,6 +121,8 @@ def _select_rag_storage(name: str) -> Tuple[Type, Type]:
return _import_elastic()
elif name == "Qdrant":
return _import_qdrant()
elif name == "Valkey":
return _import_valkey()
elif name == "KnowledgeGraph":
return _import_builtin_knowledge_graph()
elif name == "CommunitySummaryKnowledgeGraph":
@@ -161,6 +172,7 @@ __vector_store__ = [
"PGVector",
"ElasticSearch",
"Qdrant",
"Valkey",
]
__knowledge_graph__ = ["KnowledgeGraph", "CommunitySummaryKnowledgeGraph", "OpenSPG"]

View File

@@ -0,0 +1 @@
"""Tests for vector store implementations."""

View File

@@ -0,0 +1,533 @@
"""Unit tests for ValkeyStore.
These tests cover pure logic that does not require a running Valkey server
or heavy mocking. Integration tests cover actual Valkey operations.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from dbgpt.storage.vector_store.filters import (
FilterCondition,
FilterOperator,
MetadataFilter,
MetadataFilters,
)
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
_escape_tag_value,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_embedding_fn():
"""Create a mock embedding function."""
embedding = MagicMock()
embedding.embed_query.return_value = [0.1, 0.2, 0.3, 0.4]
embedding.embed_documents.return_value = [
[0.1, 0.2, 0.3, 0.4],
[0.5, 0.6, 0.7, 0.8],
]
return embedding
@pytest.fixture
def valkey_config():
"""Create a ValkeyVectorConfig for testing."""
return ValkeyVectorConfig(
host="localhost",
port=6379,
password=None,
use_ssl=False,
index_type="HNSW",
distance_metric="COSINE",
key_prefix="test_vec:",
hnsw_m=16,
hnsw_ef_construction=200,
hnsw_ef_runtime=10,
metadata_schema={
"source": "tag",
"page": "numeric",
"score": "numeric",
"category": "tag",
"status": "tag",
},
)
@pytest.fixture
def valkey_store(valkey_config, mock_embedding_fn):
"""Create a ValkeyStore with mocked client for pure logic tests."""
mock_client = MagicMock()
with (
patch.object(ValkeyStore, "_create_client", return_value=mock_client),
patch.object(ValkeyStore, "_index_exists", return_value=True),
):
store = ValkeyStore(
vector_store_config=valkey_config,
name="test_collection",
embedding_fn=mock_embedding_fn,
)
store._client = mock_client
return store
# ---------------------------------------------------------------------------
# ValkeyVectorConfig tests (pure dataclass logic)
# ---------------------------------------------------------------------------
class TestValkeyVectorConfig:
"""Tests for ValkeyVectorConfig."""
def test_default_config(self):
"""Test default configuration values."""
config = ValkeyVectorConfig()
assert config.host == "localhost"
assert config.port == 6379
assert config.index_type == "HNSW"
assert config.distance_metric == "COSINE"
assert config.hnsw_m == 16
assert config.hnsw_ef_construction == 200
assert config.hnsw_ef_runtime == 10
assert config.metadata_schema is None
def test_custom_config(self, valkey_config):
"""Test custom configuration values."""
assert valkey_config.host == "localhost"
assert valkey_config.port == 6379
assert valkey_config.key_prefix == "test_vec:"
assert valkey_config.index_type == "HNSW"
assert valkey_config.distance_metric == "COSINE"
assert valkey_config.metadata_schema == {
"source": "tag",
"page": "numeric",
"score": "numeric",
"category": "tag",
"status": "tag",
}
def test_config_type(self, valkey_config):
"""Test that __type__ is set correctly."""
assert valkey_config.__type__ == "valkey"
def test_flat_index_config(self):
"""Test FLAT index type configuration."""
config = ValkeyVectorConfig(index_type="FLAT")
assert config.index_type == "FLAT"
def test_distance_metrics(self):
"""Test different distance metric configurations."""
for metric in ["COSINE", "L2", "IP"]:
config = ValkeyVectorConfig(distance_metric=metric)
assert config.distance_metric == metric
# ---------------------------------------------------------------------------
# Store initialization tests (pure validation logic)
# ---------------------------------------------------------------------------
class TestValkeyStoreInit:
"""Tests for ValkeyStore initialization logic."""
def test_init_without_embedding_raises(self, valkey_config):
"""Test that init without embedding_fn raises ValueError."""
with pytest.raises(ValueError, match="embedding_fn is required"):
ValkeyStore(vector_store_config=valkey_config, name="test")
def test_init_sets_collection_name(self, valkey_store):
"""Test that collection name is set correctly."""
assert valkey_store._collection_name == "test_collection"
def test_init_sets_index_name(self, valkey_store):
"""Test that index name is derived from collection name."""
assert valkey_store._index_name == "idx:test_collection"
def test_init_sets_key_prefix(self, valkey_store):
"""Test that key prefix includes collection name."""
assert valkey_store._key_prefix == "test_vec:test_collection:"
def test_init_default_collection_name(self, valkey_config, mock_embedding_fn):
"""Test default collection name when none is provided."""
mock_client = MagicMock()
with (
patch.object(ValkeyStore, "_create_client", return_value=mock_client),
patch.object(ValkeyStore, "_index_exists", return_value=True),
):
store = ValkeyStore(
vector_store_config=valkey_config,
embedding_fn=mock_embedding_fn,
)
assert store._collection_name == "dbgpt_collection"
def test_get_config(self, valkey_store, valkey_config):
"""Test get_config returns the config."""
assert valkey_store.get_config() == valkey_config
# ---------------------------------------------------------------------------
# Filter expression building (pure string logic)
# ---------------------------------------------------------------------------
class TestValkeyStoreFilters:
"""Tests for metadata filter conversion — pure logic, no I/O."""
def test_no_filters(self, valkey_store):
"""Test that no filters returns wildcard."""
assert valkey_store._build_filter_expression(None) == "*"
def test_empty_filters(self, valkey_store):
"""Test that empty filter list returns wildcard."""
filters = MetadataFilters(filters=[])
assert valkey_store._build_filter_expression(filters) == "*"
def test_eq_string_filter(self, valkey_store):
"""Test equality filter for string values."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="web")
]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_source:{web}" in result
def test_eq_numeric_filter(self, valkey_store):
"""Test equality filter for numeric values."""
filters = MetadataFilters(
filters=[MetadataFilter(key="page", operator=FilterOperator.EQ, value=5)]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_page:[5 5]" in result
def test_gt_filter(self, valkey_store):
"""Test greater-than filter."""
filters = MetadataFilters(
filters=[MetadataFilter(key="score", operator=FilterOperator.GT, value=0.5)]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_score:[(0.5 +inf]" in result
def test_gte_filter(self, valkey_store):
"""Test greater-than-or-equal filter."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="score", operator=FilterOperator.GTE, value=0.5)
]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_score:[0.5 +inf]" in result
def test_lt_filter(self, valkey_store):
"""Test less-than filter."""
filters = MetadataFilters(
filters=[MetadataFilter(key="score", operator=FilterOperator.LT, value=0.8)]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_score:[-inf (0.8]" in result
def test_lte_filter(self, valkey_store):
"""Test less-than-or-equal filter."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="score", operator=FilterOperator.LTE, value=0.8)
]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_score:[-inf 0.8]" in result
def test_in_filter(self, valkey_store):
"""Test IN filter."""
filters = MetadataFilters(
filters=[
MetadataFilter(
key="category", operator=FilterOperator.IN, value=["a", "b", "c"]
)
]
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_category:{a|b|c}" in result
def test_nin_filter(self, valkey_store):
"""Test NOT IN filter."""
filters = MetadataFilters(
filters=[
MetadataFilter(
key="category", operator=FilterOperator.NIN, value=["x", "y"]
)
]
)
result = valkey_store._build_filter_expression(filters)
assert "-@meta_category:{x|y}" in result
def test_ne_string_filter(self, valkey_store):
"""Test not-equal filter for strings."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="status", operator=FilterOperator.NE, value="draft")
]
)
result = valkey_store._build_filter_expression(filters)
assert "-@meta_status:{draft}" in result
def test_ne_numeric_filter(self, valkey_store):
"""Test not-equal filter for numeric values."""
filters = MetadataFilters(
filters=[MetadataFilter(key="page", operator=FilterOperator.NE, value=3)]
)
result = valkey_store._build_filter_expression(filters)
assert "-@meta_page:[3 3]" in result
def test_and_condition(self, valkey_store):
"""Test AND condition joins with space."""
filters = MetadataFilters(
condition=FilterCondition.AND,
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="web"),
MetadataFilter(key="page", operator=FilterOperator.GT, value=1),
],
)
result = valkey_store._build_filter_expression(filters)
assert "@meta_source:{web}" in result
assert "@meta_page:[(1 +inf]" in result
# AND uses space separator
assert " | " not in result
def test_or_condition(self, valkey_store):
"""Test OR condition joins with pipe."""
filters = MetadataFilters(
condition=FilterCondition.OR,
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="web"),
MetadataFilter(key="source", operator=FilterOperator.EQ, value="pdf"),
],
)
result = valkey_store._build_filter_expression(filters)
assert " | " in result
def test_filters_without_metadata_schema(self, mock_embedding_fn):
"""Test that filters raise ValueError when no metadata_schema is set."""
config = ValkeyVectorConfig(key_prefix="test_vec:", metadata_schema=None)
mock_client = MagicMock()
with (
patch.object(ValkeyStore, "_create_client", return_value=mock_client),
patch.object(ValkeyStore, "_index_exists", return_value=True),
):
store = ValkeyStore(
vector_store_config=config,
name="test_no_schema",
embedding_fn=mock_embedding_fn,
)
store._client = mock_client
filters = MetadataFilters(
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="web")
]
)
with pytest.raises(ValueError, match="no metadata_schema configured"):
store._build_filter_expression(filters)
# ---------------------------------------------------------------------------
# Result parsing (pure logic)
# ---------------------------------------------------------------------------
class TestValkeyStoreParseResults:
"""Tests for search result parsing — pure logic, no I/O."""
def test_parse_empty_results(self, valkey_store):
"""Test parsing None and empty list."""
assert valkey_store._parse_search_results(None) == []
assert valkey_store._parse_search_results([]) == []
def test_parse_structured_results(self, valkey_store):
"""Test parsing results with .results attribute (newer glide)."""
mock_doc = MagicMock(spec=[])
mock_doc.fields = {
"content": "test content",
"metadata": '{"source": "test"}',
"chunk_id": "c1",
"score": "0.1",
}
mock_result = MagicMock(spec=[])
mock_result.results = [mock_doc]
chunks = valkey_store._parse_search_results(mock_result)
assert len(chunks) == 1
assert chunks[0].content == "test content"
assert chunks[0].chunk_id == "c1"
assert chunks[0].score == pytest.approx(0.9)
def test_parse_list_results_with_dict(self, valkey_store):
"""Test parsing list-based results with dict format."""
result = [
1, # total count
{
b"key1": {
b"content": b"hello",
b"metadata": b'{"k": "v"}',
b"chunk_id": b"id1",
b"score": b"0.3",
}
},
]
chunks = valkey_store._parse_search_results(result)
assert len(chunks) == 1
assert chunks[0].content == "hello"
assert chunks[0].chunk_id == "id1"
assert chunks[0].score == pytest.approx(0.7)
def test_doc_to_chunk_with_bytes(self, valkey_store):
"""Test parsing document with bytes values."""
doc = {
"content": b"byte content",
"metadata": b'{"key": "val"}',
"chunk_id": b"c1",
"score": b"0.2",
}
chunk = valkey_store._doc_to_chunk(doc)
assert chunk is not None
assert chunk.content == "byte content"
assert chunk.metadata == {"key": "val"}
assert chunk.chunk_id == "c1"
assert chunk.score == pytest.approx(0.8)
def test_doc_to_chunk_with_strings(self, valkey_store):
"""Test parsing document with string values."""
doc = {
"content": "string content",
"metadata": '{"key": "val"}',
"chunk_id": "c2",
"score": "0.0",
}
chunk = valkey_store._doc_to_chunk(doc)
assert chunk is not None
assert chunk.content == "string content"
assert chunk.score == pytest.approx(1.0)
def test_doc_to_chunk_with_flat_list(self, valkey_store):
"""Test parsing flat list format [field, value, field, value, ...]."""
doc = [
"content",
"list content",
"metadata",
"{}",
"chunk_id",
"c3",
"score",
"0.5",
]
chunk = valkey_store._doc_to_chunk(doc)
assert chunk is not None
assert chunk.content == "list content"
assert chunk.chunk_id == "c3"
assert chunk.score == pytest.approx(0.5)
def test_doc_to_chunk_invalid_returns_none(self, valkey_store):
"""Test that invalid doc types return None."""
assert valkey_store._doc_to_chunk(42) is None
assert valkey_store._doc_to_chunk("invalid") is None
# ---------------------------------------------------------------------------
# Score conversion for different metrics (pure logic)
# ---------------------------------------------------------------------------
class TestScoreConversion:
"""Tests for distance-to-similarity score conversion."""
def _make_store_with_metric(self, metric, mock_embedding_fn):
"""Helper to create a store with a specific distance metric."""
config = ValkeyVectorConfig(
key_prefix="test_vec:",
distance_metric=metric,
)
mock_client = MagicMock()
with (
patch.object(ValkeyStore, "_create_client", return_value=mock_client),
patch.object(ValkeyStore, "_index_exists", return_value=True),
):
store = ValkeyStore(
vector_store_config=config,
name="test",
embedding_fn=mock_embedding_fn,
)
store._client = mock_client
return store
def test_cosine_score(self, mock_embedding_fn):
"""COSINE: score = 1.0 - distance."""
store = self._make_store_with_metric("COSINE", mock_embedding_fn)
doc = {"content": "x", "metadata": "{}", "chunk_id": "c1", "score": "0.3"}
chunk = store._doc_to_chunk(doc)
assert chunk.score == pytest.approx(0.7)
def test_l2_score(self, mock_embedding_fn):
"""L2: score = 1/(1+distance)."""
store = self._make_store_with_metric("L2", mock_embedding_fn)
doc = {"content": "x", "metadata": "{}", "chunk_id": "c1", "score": "4.0"}
chunk = store._doc_to_chunk(doc)
assert chunk.score == pytest.approx(0.2) # 1/(1+4)
def test_l2_score_zero_distance(self, mock_embedding_fn):
"""L2: identical vectors have distance 0, score = 1.0."""
store = self._make_store_with_metric("L2", mock_embedding_fn)
doc = {"content": "x", "metadata": "{}", "chunk_id": "c1", "score": "0.0"}
chunk = store._doc_to_chunk(doc)
assert chunk.score == pytest.approx(1.0)
def test_ip_score(self, mock_embedding_fn):
"""IP: score = 1.0 + distance (distance is negative inner product)."""
store = self._make_store_with_metric("IP", mock_embedding_fn)
doc = {"content": "x", "metadata": "{}", "chunk_id": "c1", "score": "-0.8"}
chunk = store._doc_to_chunk(doc)
assert chunk.score == pytest.approx(0.2) # 1.0 + (-0.8)
# ---------------------------------------------------------------------------
# Tag value escaping (pure function)
# ---------------------------------------------------------------------------
class TestEscapeTagValue:
"""Tests for tag value escaping."""
def test_simple_value(self):
"""Test that simple values pass through."""
assert _escape_tag_value("hello") == "hello"
def test_space(self):
"""Test that spaces are escaped."""
assert _escape_tag_value("hello world") == "hello\\ world"
def test_dot_and_comma(self):
"""Test escaping dots and commas."""
assert _escape_tag_value("a.b,c") == "a\\.b\\,c"
def test_special_chars_comprehensive(self):
"""Test all special chars that need escaping."""
# Braces are special in Valkey tag queries
assert _escape_tag_value("a{b}c") == "a\\{b\\}c"
assert _escape_tag_value("x@y") == "x\\@y"
assert _escape_tag_value("path/to") == "path\\/to"
def test_pipe_escaped(self):
"""Test that pipe character is escaped to prevent OR injection."""
assert _escape_tag_value("foo|bar") == "foo\\|bar"
def test_empty_string(self):
"""Test empty string passes through."""
assert _escape_tag_value("") == ""

View File

@@ -0,0 +1,664 @@
"""Integration tests for ValkeyStore.
These tests require a running Valkey server with the valkey-search module loaded.
Skip if Valkey is not available.
To run locally::
docker run -d --name valkey -p 6379:6379 valkey/valkey:latest \\
--loadmodule /usr/lib/valkey/modules/valkey-search.so
pytest -v -m integration
"""
from __future__ import annotations
import os
import uuid
from typing import List
import pytest
from dbgpt.core import Chunk
from dbgpt.storage.vector_store.filters import (
FilterOperator,
MetadataFilter,
MetadataFilters,
)
pytestmark = pytest.mark.integration
VALKEY_HOST = os.environ.get("VALKEY_HOST", "localhost")
VALKEY_PORT = int(os.environ.get("VALKEY_PORT", "6379"))
VALKEY_PASSWORD = os.environ.get("VALKEY_PASSWORD", None)
def _valkey_available() -> bool:
"""Check if Valkey is available and has the search module."""
try:
import asyncio
from glide import GlideClient, GlideClientConfiguration, NodeAddress
async def _check():
config = GlideClientConfiguration(
addresses=[NodeAddress(host=VALKEY_HOST, port=VALKEY_PORT)]
)
client = await GlideClient.create(config)
result = await client.custom_command(["MODULE", "LIST"])
await client.close()
if result:
for module in result:
if isinstance(module, dict):
name = module.get(b"name", b"")
if name == b"search":
return True
return False
return asyncio.run(_check())
except Exception:
return False
if not _valkey_available():
pytest.skip(
"Valkey server with search module not available", allow_module_level=True
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class MockEmbeddings:
"""Deterministic embeddings for integration testing."""
def __init__(self, dim: int = 128):
self.dim = dim
def embed_query(self, text: str) -> List[float]:
"""Generate a deterministic embedding from text."""
import hashlib
h = hashlib.sha256(text.encode()).digest()
vector = []
for i in range(self.dim):
byte_idx = i % len(h)
vector.append((h[byte_idx] - 128) / 128.0)
return vector
def embed_documents(self, texts: List[str]) -> List[List[float]]:
"""Generate embeddings for multiple documents."""
return [self.embed_query(text) for text in texts]
def _unique_name(prefix: str = "test") -> str:
return f"{prefix}_{uuid.uuid4().hex[:8]}"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def collection_name():
"""Generate a unique collection name for test isolation."""
return _unique_name()
@pytest.fixture
def valkey_store(collection_name):
"""Create a ValkeyStore for integration testing (COSINE, HNSW)."""
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
)
config = ValkeyVectorConfig(
host=VALKEY_HOST,
port=VALKEY_PORT,
password=VALKEY_PASSWORD,
index_type="HNSW",
distance_metric="COSINE",
key_prefix="inttest_vec:",
metadata_schema={"source": "tag", "page": "numeric"},
)
store = ValkeyStore(
vector_store_config=config,
name=collection_name,
embedding_fn=MockEmbeddings(dim=128),
)
yield store
try:
store.delete_vector_name(collection_name)
except Exception:
pass
store.close()
# ---------------------------------------------------------------------------
# Tests: create_collection
# ---------------------------------------------------------------------------
class TestCreateCollection:
"""Tests for index creation."""
def test_create_collection(self, valkey_store):
"""Test creating a vector index."""
valkey_store.create_collection(valkey_store._collection_name)
assert valkey_store._index_exists()
def test_create_collection_idempotent(self, valkey_store):
"""Test that calling create_collection twice does not error."""
valkey_store.create_collection(valkey_store._collection_name)
valkey_store.create_collection(valkey_store._collection_name)
assert valkey_store._index_exists()
# ---------------------------------------------------------------------------
# Tests: load_document
# ---------------------------------------------------------------------------
class TestLoadDocument:
"""Tests for document loading."""
def test_load_document_returns_ids(self, valkey_store):
"""Test that load_document returns chunk IDs."""
chunks = [
Chunk(content="doc one", metadata={"source": "test"}, chunk_id="d1"),
Chunk(content="doc two", metadata={"source": "test"}, chunk_id="d2"),
]
ids = valkey_store.load_document(chunks)
assert ids == ["d1", "d2"]
def test_load_document_empty_list(self, valkey_store):
"""Test loading an empty list."""
ids = valkey_store.load_document([])
assert ids == []
def test_load_document_with_metadata_fields(self, valkey_store):
"""Test that metadata fields are stored for indexing."""
chunks = [
Chunk(
content="Python guide",
metadata={"source": "wiki", "page": 5},
chunk_id="m1",
),
]
valkey_store.load_document(chunks)
# Verify the data is searchable
assert valkey_store.vector_name_exists() is True
# ---------------------------------------------------------------------------
# Tests: similar_search
# ---------------------------------------------------------------------------
class TestSimilarSearch:
"""Tests for vector similarity search."""
def test_similar_search_returns_results(self, valkey_store):
"""Test basic search returns results."""
chunks = [
Chunk(
content="Python is a programming language",
metadata={"source": "wiki"},
chunk_id="s1",
),
Chunk(
content="Java is also a programming language",
metadata={"source": "wiki"},
chunk_id="s2",
),
Chunk(
content="The weather is sunny today",
metadata={"source": "news"},
chunk_id="s3",
),
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search("programming language", topk=2)
assert len(results) <= 2
assert len(results) > 0
# Programming-related chunks should rank higher
contents = [r.content.lower() for r in results]
assert any("programming" in c for c in contents)
def test_similar_search_topk_limit(self, valkey_store):
"""Test that topk limits results."""
chunks = [
Chunk(content=f"document number {i}", metadata={}, chunk_id=f"t{i}")
for i in range(5)
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search("document", topk=2)
assert len(results) <= 2
def test_similar_search_has_scores(self, valkey_store):
"""Test that results have valid scores."""
chunks = [
Chunk(content="exact query text", metadata={}, chunk_id="sc1"),
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search("exact query text", topk=1)
assert len(results) == 1
# Score should be high for exact match (COSINE similarity)
assert results[0].score > 0.5
def test_similar_search_preserves_metadata(self, valkey_store):
"""Test that search results include metadata."""
chunks = [
Chunk(
content="metadata test",
metadata={"source": "unit_test", "page": 42},
chunk_id="meta1",
),
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search("metadata test", topk=1)
assert len(results) == 1
assert results[0].metadata["source"] == "unit_test"
assert results[0].metadata["page"] == 42
def test_similar_search_preserves_chunk_id(self, valkey_store):
"""Test that search results include chunk_id."""
chunks = [
Chunk(content="id test", metadata={}, chunk_id="myid123"),
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search("id test", topk=1)
assert len(results) == 1
assert results[0].chunk_id == "myid123"
# ---------------------------------------------------------------------------
# Tests: similar_search_with_scores
# ---------------------------------------------------------------------------
class TestSimilarSearchWithScores:
"""Tests for score-threshold filtering."""
def test_high_threshold_filters_low_scores(self, valkey_store):
"""Test that high threshold filters out dissimilar results."""
chunks = [
Chunk(content="exact match phrase", metadata={}, chunk_id="h1"),
Chunk(content="completely unrelated xyz abc", metadata={}, chunk_id="h2"),
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search_with_scores(
"exact match phrase", topk=5, score_threshold=0.8
)
if results:
assert all(r.score >= 0.8 for r in results)
def test_zero_threshold_returns_all(self, valkey_store):
"""Test that threshold=0 does not filter positive-score results."""
chunks = [
Chunk(content="search query text", metadata={}, chunk_id="z1"),
Chunk(content="search query text again", metadata={}, chunk_id="z2"),
]
valkey_store.load_document(chunks)
results = valkey_store.similar_search_with_scores(
"search query text", topk=5, score_threshold=0.0
)
# Both chunks are very similar to query, scores should be >= 0
assert len(results) >= 1
# ---------------------------------------------------------------------------
# Tests: vector_name_exists
# ---------------------------------------------------------------------------
class TestVectorNameExists:
"""Tests for checking index existence."""
def test_exists_false_before_load(self, valkey_store):
"""Test that exists is False before any data is loaded."""
valkey_store.create_collection(valkey_store._collection_name)
assert valkey_store.vector_name_exists() is False
def test_exists_true_after_load(self, valkey_store):
"""Test that exists is True after loading data."""
chunks = [Chunk(content="exists test", metadata={}, chunk_id="e1")]
valkey_store.load_document(chunks)
assert valkey_store.vector_name_exists() is True
def test_exists_false_after_delete(self, valkey_store):
"""Test that exists is False after deleting the index."""
chunks = [Chunk(content="delete test", metadata={}, chunk_id="ed1")]
valkey_store.load_document(chunks)
valkey_store.delete_vector_name(valkey_store._collection_name)
assert valkey_store.vector_name_exists() is False
# ---------------------------------------------------------------------------
# Tests: delete_by_ids
# ---------------------------------------------------------------------------
class TestDeleteByIds:
"""Tests for deleting specific documents."""
def test_delete_single_id(self, valkey_store):
"""Test deleting a single document."""
chunks = [
Chunk(content="keep this", metadata={}, chunk_id="keep1"),
Chunk(content="delete this", metadata={}, chunk_id="del1"),
]
valkey_store.load_document(chunks)
deleted = valkey_store.delete_by_ids("del1")
assert deleted == ["del1"]
def test_delete_multiple_ids(self, valkey_store):
"""Test deleting multiple documents."""
chunks = [
Chunk(content=f"doc {i}", metadata={}, chunk_id=f"multi{i}")
for i in range(4)
]
valkey_store.load_document(chunks)
deleted = valkey_store.delete_by_ids("multi0, multi1, multi2")
assert deleted == ["multi0", "multi1", "multi2"]
def test_delete_nonexistent_id(self, valkey_store):
"""Test deleting an ID that doesn't exist doesn't error."""
valkey_store.create_collection(valkey_store._collection_name)
deleted = valkey_store.delete_by_ids("nonexistent_id")
assert deleted == ["nonexistent_id"]
# ---------------------------------------------------------------------------
# Tests: delete_vector_name
# ---------------------------------------------------------------------------
class TestDeleteVectorName:
"""Tests for deleting the entire index."""
def test_delete_removes_index(self, valkey_store):
"""Test that delete removes the index."""
chunks = [Chunk(content="to delete", metadata={}, chunk_id="dv1")]
valkey_store.load_document(chunks)
result = valkey_store.delete_vector_name(valkey_store._collection_name)
assert result is True
assert valkey_store._index_exists() is False
def test_delete_nonexistent_index(self, valkey_store):
"""Test deleting when no index exists doesn't error."""
result = valkey_store.delete_vector_name("nonexistent")
assert result is True
# ---------------------------------------------------------------------------
# Tests: truncate
# ---------------------------------------------------------------------------
class TestTruncate:
"""Tests for truncating data."""
def test_truncate_removes_keys(self, valkey_store):
"""Test that truncate removes all data keys."""
chunks = [
Chunk(content="data 1", metadata={}, chunk_id="tr1"),
Chunk(content="data 2", metadata={}, chunk_id="tr2"),
]
valkey_store.load_document(chunks)
deleted = valkey_store.truncate()
assert len(deleted) >= 2
def test_truncate_empty_collection(self, valkey_store):
"""Test truncating when no data exists."""
valkey_store.create_collection(valkey_store._collection_name)
deleted = valkey_store.truncate()
assert deleted == []
# ---------------------------------------------------------------------------
# Tests: metadata filtering
# ---------------------------------------------------------------------------
class TestMetadataFiltering:
"""Tests for metadata-based filtering during search."""
@pytest.fixture
def store_with_data(self, collection_name):
"""Create a store with diverse metadata for filter testing."""
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
)
config = ValkeyVectorConfig(
host=VALKEY_HOST,
port=VALKEY_PORT,
password=VALKEY_PASSWORD,
index_type="HNSW",
distance_metric="COSINE",
key_prefix="inttest_filter:",
metadata_schema={"source": "tag", "page": "numeric"},
)
store = ValkeyStore(
vector_store_config=config,
name=collection_name + "_filter",
embedding_fn=MockEmbeddings(dim=128),
)
chunks = [
Chunk(
content="Python programming tutorial",
metadata={"source": "wiki", "page": 1},
chunk_id="ft1",
),
Chunk(
content="Java programming tutorial",
metadata={"source": "wiki", "page": 2},
chunk_id="ft2",
),
Chunk(
content="Weather forecast report",
metadata={"source": "news", "page": 1},
chunk_id="ft3",
),
]
store.load_document(chunks)
yield store
try:
store.delete_vector_name(collection_name + "_filter")
except Exception:
pass
store.close()
def test_filter_by_tag_eq(self, store_with_data):
"""Test filtering by tag equality."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="wiki")
]
)
results = store_with_data.similar_search("programming", topk=5, filters=filters)
assert len(results) > 0
for r in results:
assert r.metadata.get("source") == "wiki"
def test_filter_by_numeric_gt(self, store_with_data):
"""Test filtering by numeric greater-than."""
filters = MetadataFilters(
filters=[MetadataFilter(key="page", operator=FilterOperator.GT, value=1)]
)
results = store_with_data.similar_search("programming", topk=5, filters=filters)
if results:
for r in results:
assert r.metadata.get("page", 0) > 1
def test_filter_excludes_non_matching(self, store_with_data):
"""Test that filter actually excludes non-matching docs."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="news")
]
)
results = store_with_data.similar_search("programming", topk=5, filters=filters)
# Only the news doc should match the filter
for r in results:
assert r.metadata.get("source") == "news"
# ---------------------------------------------------------------------------
# Tests: FLAT index type
# ---------------------------------------------------------------------------
class TestFlatIndex:
"""Tests for FLAT index type."""
def test_flat_index_load_and_search(self, collection_name):
"""Test with FLAT index type."""
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
)
config = ValkeyVectorConfig(
host=VALKEY_HOST,
port=VALKEY_PORT,
password=VALKEY_PASSWORD,
index_type="FLAT",
distance_metric="L2",
key_prefix="inttest_flat:",
)
store = ValkeyStore(
vector_store_config=config,
name=collection_name + "_flat",
embedding_fn=MockEmbeddings(dim=128),
)
try:
chunks = [
Chunk(content="flat index test", metadata={}, chunk_id="f1"),
Chunk(content="another flat doc", metadata={}, chunk_id="f2"),
]
ids = store.load_document(chunks)
assert ids == ["f1", "f2"]
results = store.similar_search("flat index", topk=1)
assert len(results) >= 1
# L2 scores should be in (0, 1] range via 1/(1+d)
assert results[0].score > 0
assert results[0].score <= 1.0
finally:
try:
store.delete_vector_name(collection_name + "_flat")
except Exception:
pass
store.close()
# ---------------------------------------------------------------------------
# Tests: close()
# ---------------------------------------------------------------------------
class TestClose:
"""Tests for resource cleanup."""
def test_close_does_not_error(self, collection_name):
"""Test that close() can be called without error."""
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
)
config = ValkeyVectorConfig(
host=VALKEY_HOST,
port=VALKEY_PORT,
password=VALKEY_PASSWORD,
key_prefix="inttest_close:",
)
store = ValkeyStore(
vector_store_config=config,
name=collection_name + "_close",
embedding_fn=MockEmbeddings(dim=128),
)
# Force client creation
_ = store.client
store.close()
assert store._client is None
assert store._loop.is_closed()
def test_close_without_client(self, collection_name):
"""Test that close() works even if client was never created."""
from dbgpt_ext.storage.vector_store.valkey_store import (
ValkeyStore,
ValkeyVectorConfig,
)
config = ValkeyVectorConfig(
host=VALKEY_HOST,
port=VALKEY_PORT,
password=VALKEY_PASSWORD,
key_prefix="inttest_close2:",
)
store = ValkeyStore(
vector_store_config=config,
name=collection_name + "_close2",
embedding_fn=MockEmbeddings(dim=128),
)
# Don't create client, just close
store.close()
assert store._client is None
# ---------------------------------------------------------------------------
# Tests: convert_metadata_filters (public method)
# ---------------------------------------------------------------------------
class TestConvertMetadataFilters:
"""Tests for the public convert_metadata_filters method."""
def test_convert_returns_filter_string(self, valkey_store):
"""Test that convert_metadata_filters returns a valid string."""
filters = MetadataFilters(
filters=[
MetadataFilter(key="source", operator=FilterOperator.EQ, value="wiki")
]
)
result = valkey_store.convert_metadata_filters(filters)
assert isinstance(result, str)
assert "@meta_source:{wiki}" in result
def test_convert_empty_filters(self, valkey_store):
"""Test converting empty filters."""
filters = MetadataFilters(filters=[])
result = valkey_store.convert_metadata_filters(filters)
assert result == "*"

View File

@@ -0,0 +1,868 @@
"""Valkey vector store.
Requires a Valkey server with the valkey-search module loaded for vector
similarity search. Uses the valkey-glide client library.
To run Valkey with the search module::
docker run -d --name valkey -p 6379:6379 valkey/valkey:latest \\
--loadmodule /usr/lib/valkey/modules/valkey-search.so
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from dbgpt.core import Chunk, Embeddings
from dbgpt.core.awel.flow import Parameter, ResourceCategory, register_resource
from dbgpt.storage.vector_store.base import (
_COMMON_PARAMETERS,
_VECTOR_STORE_COMMON_PARAMETERS,
VectorStoreBase,
VectorStoreConfig,
)
from dbgpt.storage.vector_store.filters import (
FilterCondition,
FilterOperator,
MetadataFilters,
)
from dbgpt.util.i18n_utils import _
logger = logging.getLogger(__name__)
_VALKEY_DEFAULT_INDEX_TYPE = "HNSW"
_VALKEY_DEFAULT_DISTANCE_METRIC = "COSINE"
_VALKEY_DEFAULT_KEY_PREFIX = "dbgpt_vec:"
_VALKEY_VECTOR_FIELD = "vector"
_VALKEY_CONTENT_FIELD = "content"
_VALKEY_METADATA_FIELD = "metadata"
_VALKEY_CHUNK_ID_FIELD = "chunk_id"
_VALKEY_METADATA_PREFIX = "meta_"
@register_resource(
_("Valkey Config"),
"valkey_vector_config",
category=ResourceCategory.VECTOR_STORE,
description=_("Valkey vector store config."),
parameters=[
*_COMMON_PARAMETERS,
Parameter.build_from(
_("Host"),
"host",
str,
description=_("The host of the Valkey instance."),
optional=True,
default="localhost",
),
Parameter.build_from(
_("Port"),
"port",
int,
description=_("The port of the Valkey instance."),
optional=True,
default=6379,
),
Parameter.build_from(
_("Password"),
"password",
str,
description=_("The password for the Valkey instance."),
optional=True,
default=None,
),
Parameter.build_from(
_("Use SSL"),
"use_ssl",
bool,
description=_("Whether to use SSL for the Valkey connection."),
optional=True,
default=False,
),
Parameter.build_from(
_("Index Type"),
"index_type",
str,
description=_(
"The vector index type: 'HNSW' (approximate, fast) or "
"'FLAT' (exact, slower)."
),
optional=True,
default="HNSW",
),
Parameter.build_from(
_("Distance Metric"),
"distance_metric",
str,
description=_(
"The distance metric: 'COSINE', 'L2' (Euclidean), or "
"'IP' (Inner Product)."
),
optional=True,
default="COSINE",
),
],
)
@dataclass
class ValkeyVectorConfig(VectorStoreConfig):
"""Valkey vector store config."""
__type__ = "valkey"
host: str = field(
default_factory=lambda: os.getenv("VALKEY_HOST", "localhost"),
metadata={"help": _("The host of Valkey store.")},
)
port: int = field(
default_factory=lambda: int(os.getenv("VALKEY_PORT", "6379")),
metadata={"help": _("The port of Valkey store.")},
)
password: Optional[str] = field(
default_factory=lambda: os.getenv("VALKEY_PASSWORD"),
metadata={"help": _("The password for Valkey store.")},
)
use_ssl: bool = field(
default=False,
metadata={"help": _("Whether to use SSL for the Valkey connection.")},
)
index_type: str = field(
default_factory=lambda: os.getenv(
"VALKEY_INDEX_TYPE", _VALKEY_DEFAULT_INDEX_TYPE
),
metadata={
"help": _(
"The vector index type: 'HNSW' (approximate, fast) or "
"'FLAT' (exact, slower)."
)
},
)
distance_metric: str = field(
default_factory=lambda: os.getenv(
"VALKEY_DISTANCE_METRIC", _VALKEY_DEFAULT_DISTANCE_METRIC
),
metadata={
"help": _(
"The distance metric: 'COSINE', 'L2' (Euclidean), or "
"'IP' (Inner Product)."
)
},
)
key_prefix: str = field(
default_factory=lambda: os.getenv(
"VALKEY_KEY_PREFIX", _VALKEY_DEFAULT_KEY_PREFIX
),
metadata={"help": _("The key prefix for all vector store keys.")},
)
hnsw_m: int = field(
default=16,
metadata={"help": _("HNSW: number of connections per node.")},
)
hnsw_ef_construction: int = field(
default=200,
metadata={"help": _("HNSW: construction time quality factor.")},
)
hnsw_ef_runtime: int = field(
default=10,
metadata={"help": _("HNSW: runtime search quality factor.")},
)
request_timeout: int = field(
default_factory=lambda: int(os.getenv("VALKEY_REQUEST_TIMEOUT", "5000")),
metadata={
"help": _(
"Request timeout in milliseconds for Valkey operations. "
"Prevents indefinite hangs on network issues."
)
},
)
metadata_schema: Optional[Dict[str, str]] = field(
default=None,
metadata={
"help": _(
"Metadata fields to index for filtering. Dict mapping field name "
"to type: 'tag' (string) or 'numeric'. "
"E.g. {'source': 'tag', 'page': 'numeric'}. "
"Must be defined at store creation time."
)
},
)
def create_store(self, **kwargs) -> "ValkeyStore":
"""Create ValkeyStore."""
return ValkeyStore(vector_store_config=self, **kwargs)
@register_resource(
_("Valkey Vector Store"),
"valkey_vector_store",
category=ResourceCategory.VECTOR_STORE,
description=_("Valkey vector store."),
parameters=[
Parameter.build_from(
_("Valkey Config"),
"vector_store_config",
ValkeyVectorConfig,
description=_("the valkey config of vector store."),
optional=True,
default=None,
),
*_VECTOR_STORE_COMMON_PARAMETERS,
],
)
class ValkeyStore(VectorStoreBase):
"""Valkey vector store using valkey-glide client.
Requires a Valkey server with the valkey-search module loaded.
"""
def __init__(
self,
vector_store_config: ValkeyVectorConfig,
name: Optional[str] = None,
embedding_fn: Optional[Embeddings] = None,
max_chunks_once_load: Optional[int] = None,
max_threads: Optional[int] = None,
) -> None:
"""Initialize ValkeyStore.
Args:
vector_store_config: Valkey connection and index configuration.
name: Collection/index name.
embedding_fn: Embedding function for vectorizing text.
max_chunks_once_load: Max chunks per load batch.
max_threads: Max threads for parallel loading.
"""
try:
import glide # noqa: F401
except ImportError:
raise ImportError(
"Please install valkey-glide: pip install 'valkey-glide>=2.3.0'"
)
super().__init__(
max_chunks_once_load=max_chunks_once_load, max_threads=max_threads
)
if embedding_fn is None:
raise ValueError("embedding_fn is required for ValkeyStore")
self._vector_store_config = vector_store_config
self._embedding_fn = embedding_fn
self._collection_name = name or "dbgpt_collection"
self._key_prefix = vector_store_config.key_prefix + self._collection_name + ":"
self._index_name = f"idx:{self._collection_name}"
self._client: Optional[Any] = None
self._dim: Optional[int] = None
# Dedicated event loop for async glide operations
import asyncio
self._loop = asyncio.new_event_loop()
def close(self):
"""Close the client connection and event loop."""
if self._client:
try:
self._loop.run_until_complete(self._client.close())
except Exception:
pass
self._client = None
if self._loop and not self._loop.is_closed():
self._loop.close()
def __del__(self):
"""Clean up resources if close() was not called explicitly."""
if hasattr(self, "_loop"):
self.close()
@property
def client(self) -> Any:
"""Get or create the Valkey client (lazy initialization)."""
if self._client is None:
self._client = self._create_client()
return self._client
def _create_client(self) -> Any:
"""Create a Valkey-glide client."""
from glide import GlideClient, GlideClientConfiguration, NodeAddress
config = self._vector_store_config
node = NodeAddress(host=config.host, port=config.port)
if config.password:
from glide import ServerCredentials
client_config = GlideClientConfiguration(
addresses=[node],
use_tls=config.use_ssl,
request_timeout=config.request_timeout,
credentials=ServerCredentials(password=config.password),
)
else:
client_config = GlideClientConfiguration(
addresses=[node],
use_tls=config.use_ssl,
request_timeout=config.request_timeout,
)
# GlideClient.create() is async — run it in our dedicated loop
return self._loop.run_until_complete(GlideClient.create(client_config))
def _get_dimension(self) -> int:
"""Get embedding dimension by running a probe embedding."""
if self._dim is None:
self._dim = len(self._embedding_fn.embed_query("probe"))
return self._dim
def _run_async(self, coro):
"""Run an async coroutine synchronously using the dedicated event loop."""
if self._loop.is_closed():
raise RuntimeError("ValkeyStore has been closed")
return self._loop.run_until_complete(coro)
def get_config(self) -> ValkeyVectorConfig:
"""Get the vector store config."""
return self._vector_store_config
def create_collection(self, collection_name: str, **kwargs) -> None:
"""Create a vector index in Valkey.
Args:
collection_name: The name for the collection/index.
"""
from glide import DataType, ft
# Check if index already exists
if self._index_exists():
return
dim = self._get_dimension()
config = self._vector_store_config
# Build the schema fields
schema = self._build_index_schema(dim, config)
# Create the index
prefix = self._key_prefix
options = ft.FtCreateOptions(
data_type=DataType.HASH,
prefixes=[prefix],
)
self._run_async(
ft.create(self.client, self._index_name, schema=schema, options=options)
)
logger.info(
f"Created Valkey index '{self._index_name}' with {config.index_type} "
f"algorithm, {config.distance_metric} metric, dim={dim}"
)
def _build_index_schema(self, dim: int, config: ValkeyVectorConfig) -> List:
"""Build the FT.CREATE schema fields."""
from glide import (
DistanceMetricType,
NumericField,
TagField,
TextField,
VectorAlgorithm,
VectorField,
VectorFieldAttributesFlat,
VectorFieldAttributesHnsw,
VectorType,
)
# Map string config to enum values
distance_map = {
"COSINE": DistanceMetricType.COSINE,
"L2": DistanceMetricType.L2,
"IP": DistanceMetricType.IP,
}
distance_metric = distance_map.get(
config.distance_metric.upper(), DistanceMetricType.COSINE
)
fields = [
TextField(_VALKEY_CONTENT_FIELD),
TextField(_VALKEY_METADATA_FIELD),
TagField(_VALKEY_CHUNK_ID_FIELD),
]
# Add user-defined metadata fields to schema
if config.metadata_schema:
for field_name, field_type in config.metadata_schema.items():
prefixed = _VALKEY_METADATA_PREFIX + field_name
if field_type.lower() == "numeric":
fields.append(NumericField(prefixed))
else:
# Default to TAG for string fields
fields.append(TagField(prefixed))
# Vector field with algorithm-specific parameters
if config.index_type.upper() == "FLAT":
attributes = VectorFieldAttributesFlat(
dimensions=dim,
distance_metric=distance_metric,
type=VectorType.FLOAT32,
)
vector_field = VectorField(
_VALKEY_VECTOR_FIELD,
algorithm=VectorAlgorithm.FLAT,
attributes=attributes,
)
else:
# Default to HNSW
attributes = VectorFieldAttributesHnsw(
dimensions=dim,
distance_metric=distance_metric,
type=VectorType.FLOAT32,
number_of_edges=config.hnsw_m,
vectors_examined_on_construction=config.hnsw_ef_construction,
vectors_examined_on_runtime=config.hnsw_ef_runtime,
)
vector_field = VectorField(
_VALKEY_VECTOR_FIELD,
algorithm=VectorAlgorithm.HNSW,
attributes=attributes,
)
fields.append(vector_field)
return fields
def _index_exists(self) -> bool:
"""Check if the index already exists."""
from glide import ft
existing = self._run_async(ft.list(self.client))
names = {
i.decode() if isinstance(i, bytes) else str(i) for i in (existing or [])
}
return self._index_name in names
def load_document(self, chunks: List[Chunk]) -> List[str]:
"""Load document chunks into Valkey.
Args:
chunks: Document chunks to store.
Returns:
List of chunk IDs that were stored.
"""
import struct
# Ensure index exists
self.create_collection(self._collection_name)
texts = [chunk.content for chunk in chunks]
vectors = self._embedding_fn.embed_documents(texts)
for chunk, vector in zip(chunks, vectors):
key = self._key_prefix + chunk.chunk_id
# Pack vector as binary float32
vector_bytes = struct.pack(f"{len(vector)}f", *vector)
# Store as hash fields
field_map = {
_VALKEY_CONTENT_FIELD: chunk.content,
_VALKEY_METADATA_FIELD: json.dumps(
chunk.metadata if chunk.metadata else {}
),
_VALKEY_CHUNK_ID_FIELD: chunk.chunk_id,
_VALKEY_VECTOR_FIELD: vector_bytes,
}
# Store indexed metadata fields as top-level hash fields
metadata_schema = self._vector_store_config.metadata_schema
if metadata_schema and chunk.metadata:
for field_name in metadata_schema:
if field_name in chunk.metadata:
prefixed = _VALKEY_METADATA_PREFIX + field_name
field_map[prefixed] = str(chunk.metadata[field_name])
self._run_async(self.client.hset(key, field_map))
return [chunk.chunk_id for chunk in chunks]
def similar_search(
self, text: str, topk: int, filters: Optional[MetadataFilters] = None
) -> List[Chunk]:
"""Search for similar documents.
Args:
text: Query text.
topk: Number of results to return.
filters: Optional metadata filters.
Returns:
List of similar chunks.
"""
return self._search(text, topk, filters=filters)
def similar_search_with_scores(
self,
text: str,
topk: int,
score_threshold: float,
filters: Optional[MetadataFilters] = None,
) -> List[Chunk]:
"""Search for similar documents with score filtering.
Args:
text: Query text.
topk: Number of results to return.
score_threshold: Minimum score threshold.
filters: Optional metadata filters.
Returns:
List of similar chunks with scores above threshold.
"""
chunks = self._search(text, topk, filters=filters)
return self.filter_by_score_threshold(chunks, score_threshold)
def _search(
self,
text: str,
topk: int,
filters: Optional[MetadataFilters] = None,
) -> List[Chunk]:
"""Execute a vector similarity search.
Args:
text: Query text to embed and search.
topk: Number of results.
filters: Optional metadata filters.
Returns:
List of matching chunks with scores.
"""
import struct
from glide import FtSearchLimit, FtSearchOptions, ReturnField, ft
query_vector = self._embedding_fn.embed_query(text)
vector_bytes = struct.pack(f"{len(query_vector)}f", *query_vector)
# Build the KNN query
filter_expr = self._build_filter_expression(filters)
query_str = f"{filter_expr}=>[KNN {topk} @{_VALKEY_VECTOR_FIELD} $vec AS score]"
# Execute search
options = FtSearchOptions(
params={"vec": vector_bytes},
return_fields=[
ReturnField(_VALKEY_CONTENT_FIELD),
ReturnField(_VALKEY_METADATA_FIELD),
ReturnField(_VALKEY_CHUNK_ID_FIELD),
ReturnField("score"),
],
limit=FtSearchLimit(0, topk),
)
result = self._run_async(
ft.search(self.client, self._index_name, query_str, options)
)
return self._parse_search_results(result)
def _parse_search_results(self, result) -> List[Chunk]:
"""Parse FT.SEARCH results into Chunk objects.
The result format from valkey-glide ft.search is:
[total_count, {key1: {field: value, ...}, key2: {field: value, ...}}]
"""
chunks: List[Chunk] = []
if not result:
return chunks
# Handle structured results (newer glide versions)
if hasattr(result, "results"):
for doc in result.results:
chunk = self._doc_to_chunk(doc)
if chunk:
chunks.append(chunk)
return chunks
# Handle list-based results: [total_count, {key: {fields}, ...}]
if not isinstance(result, (list, tuple)) or len(result) < 2:
return chunks
docs = result[1]
if isinstance(docs, dict):
# Format: {b'key1': {b'field': b'value', ...}, ...}
for key, fields in docs.items():
if isinstance(fields, dict):
chunk = self._doc_to_chunk(fields)
if chunk:
chunks.append(chunk)
elif isinstance(docs, list):
for entry in docs:
chunk = self._doc_to_chunk(entry)
if chunk:
chunks.append(chunk)
return chunks
def _doc_to_chunk(self, doc) -> Optional[Chunk]:
"""Convert a search result document to a Chunk."""
try:
if hasattr(doc, "fields"):
fields = doc.fields
elif isinstance(doc, dict):
# Normalize bytes keys to strings
fields = {}
for k, v in doc.items():
key = k.decode() if isinstance(k, bytes) else k
fields[key] = v
elif isinstance(doc, (list, tuple)):
# Convert flat list [field, value, field, value, ...] to dict
fields = {}
for j in range(0, len(doc), 2):
key = doc[j] if isinstance(doc[j], str) else doc[j].decode()
fields[key] = doc[j + 1]
else:
return None
content = fields.get(_VALKEY_CONTENT_FIELD, "")
if isinstance(content, bytes):
content = content.decode()
metadata_raw = fields.get(_VALKEY_METADATA_FIELD, "{}")
if isinstance(metadata_raw, bytes):
metadata_raw = metadata_raw.decode()
metadata = json.loads(metadata_raw) if metadata_raw else {}
chunk_id = fields.get(_VALKEY_CHUNK_ID_FIELD, "")
if isinstance(chunk_id, bytes):
chunk_id = chunk_id.decode()
score_raw = fields.get("score", 0.0)
if isinstance(score_raw, bytes):
score_raw = score_raw.decode()
# Convert distance to similarity score based on metric
distance = float(score_raw)
metric = self._vector_store_config.distance_metric.upper()
if metric == "COSINE":
# COSINE distance range: 0 (identical) to 2 (opposite)
score = 1.0 - distance
elif metric == "IP":
# Inner Product: assumes normalized vectors; clamp for safety
score = max(0.0, min(1.0, 1.0 + distance))
else:
# L2 (Euclidean): distance >= 0, convert via 1/(1+d)
score = 1.0 / (1.0 + distance)
return Chunk(
content=content,
metadata=metadata,
chunk_id=chunk_id,
score=score,
)
except Exception as e:
logger.warning(f"Failed to parse search result document: {e}")
return None
def _build_filter_expression(self, filters: Optional[MetadataFilters]) -> str:
"""Build a Valkey search filter expression.
Args:
filters: Metadata filters to convert.
Returns:
Filter expression string for FT.SEARCH query.
"""
if not filters or not filters.filters:
return "*"
if not self._vector_store_config.metadata_schema:
raise ValueError(
"Metadata filters provided but no metadata_schema configured. "
"Configure metadata_schema in ValkeyVectorConfig to enable filtering."
)
expressions = []
for f in filters.filters:
expr = self._single_filter_to_expr(f)
if expr:
expressions.append(expr)
if not expressions:
return "*"
if filters.condition == FilterCondition.OR:
return " | ".join(expressions)
else:
# AND condition
return " ".join(expressions)
def _single_filter_to_expr(self, f) -> Optional[str]:
"""Convert a single MetadataFilter to a Valkey search expression."""
key = _VALKEY_METADATA_PREFIX + f.key
op = f.operator
val = f.value
if op == FilterOperator.EQ:
if isinstance(val, (int, float)):
return f"@{key}:[{val} {val}]"
else:
# Tag field exact match
return f"@{key}:{{{_escape_tag_value(str(val))}}}"
elif op == FilterOperator.GT:
return f"@{key}:[({val} +inf]"
elif op == FilterOperator.GTE:
return f"@{key}:[{val} +inf]"
elif op == FilterOperator.LT:
return f"@{key}:[-inf ({val}]"
elif op == FilterOperator.LTE:
return f"@{key}:[-inf {val}]"
elif op == FilterOperator.NE:
if isinstance(val, (int, float)):
return f"-@{key}:[{val} {val}]"
else:
return f"-@{key}:{{{_escape_tag_value(str(val))}}}"
elif op == FilterOperator.IN:
if isinstance(val, list):
escaped = "|".join(_escape_tag_value(str(v)) for v in val)
return f"@{key}:{{{escaped}}}"
return None
elif op == FilterOperator.NIN:
if isinstance(val, list):
escaped = "|".join(_escape_tag_value(str(v)) for v in val)
return f"-@{key}:{{{escaped}}}"
return None
else:
logger.warning(f"Unsupported filter operator for Valkey: {op}")
return None
def vector_name_exists(self) -> bool:
"""Check whether the vector index exists and has data."""
try:
if not self._index_exists():
return False
# Use FT.INFO to get the number of documents in the index
from glide import ft
info = self._run_async(ft.info(self.client, self._index_name))
# info is a dict with index stats
if isinstance(info, dict):
num_docs = info.get(b"num_docs", info.get("num_docs", 0))
if isinstance(num_docs, bytes):
num_docs = num_docs.decode()
return int(num_docs) > 0
return False
except Exception as e:
logger.error(f"vector_name_exists error: {e}")
return False
def delete_vector_name(self, vector_name: str) -> bool:
"""Delete the vector index and all associated keys.
Args:
vector_name: The name of the vector index to delete.
Returns:
True if deletion was successful.
"""
from glide import ft
logger.info(f"Deleting Valkey vector index: {self._index_name}")
try:
# Drop the index (does not delete the underlying hash keys)
self._run_async(ft.dropindex(self.client, self._index_name))
except Exception as e:
logger.warning(f"Error dropping index: {e}")
# Also delete any remaining keys with the prefix
self._delete_keys_with_prefix(self._key_prefix)
return True
def delete_by_ids(self, ids: str) -> List[str]:
"""Delete vectors by their IDs.
Args:
ids: Comma-separated string of chunk IDs to delete.
Returns:
List of deleted chunk IDs.
"""
id_list = [i.strip() for i in ids.split(",") if i.strip()]
for chunk_id in id_list:
key = self._key_prefix + chunk_id
self._run_async(self.client.delete([key]))
return id_list
def truncate(self) -> List[str]:
"""Truncate all data in the collection.
Returns:
List of deleted key names.
"""
logger.info(f"Truncating Valkey collection: {self._collection_name}")
deleted = self._delete_keys_with_prefix(self._key_prefix)
return deleted
def _delete_keys_with_prefix(self, prefix: str) -> List[str]:
"""Delete all keys matching a prefix using SCAN.
Args:
prefix: Key prefix to match.
Returns:
List of deleted key names.
"""
deleted_keys = []
cursor = "0"
while True:
# Use SCAN to find keys with prefix
result = self._run_async(
self.client.custom_command(
["SCAN", cursor, "MATCH", f"{prefix}*", "COUNT", "100"]
)
)
if isinstance(result, (list, tuple)) and len(result) == 2:
cursor = result[0]
if isinstance(cursor, bytes):
cursor = cursor.decode()
keys = result[1]
if keys:
key_list = [k.decode() if isinstance(k, bytes) else k for k in keys]
self._run_async(self.client.delete(key_list))
deleted_keys.extend(key_list)
else:
break
if str(cursor) == "0":
break
return deleted_keys
def convert_metadata_filters(self, filters: MetadataFilters) -> str:
"""Convert metadata filters to Valkey search filter expression.
Args:
filters: Metadata filters.
Returns:
Filter expression string.
"""
return self._build_filter_expression(filters)
def _escape_tag_value(value: str) -> str:
"""Escape special characters in tag values for Valkey search.
Args:
value: The tag value to escape.
Returns:
Escaped tag value safe for use in FT.SEARCH queries.
"""
special_chars = r".,<>{}[]\"':;!@#$%^&*()-+=~/ |"
escaped = ""
for char in value:
if char in special_chars:
escaped += f"\\{char}"
else:
escaped += char
return escaped