Skip to content

Commit f696b41

Browse files
fancyboi999CarltonXiangfridayLCaralHsi
authored
feat(qdrant):support qdrant cloud and add index (#522)
* docs: update .env.example with comprehensive variables and comments * hotfix:hotfix * feat(qdrant):support qdrant cloud and add index * chore: format qdrant test --------- Co-authored-by: HarveyXiang <[email protected]> Co-authored-by: fridayL <[email protected]> Co-authored-by: chunyu li <[email protected]> Co-authored-by: CaralHsi <[email protected]>
1 parent 9686810 commit f696b41

File tree

8 files changed

+183
-22
lines changed

8 files changed

+183
-22
lines changed

docker/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ OLLAMA_API_BASE=http://localhost:11434 # required when backend=ollama
4747
MOS_RERANKER_BACKEND=http_bge # http_bge | http_bge_strategy | cosine_local
4848
MOS_RERANKER_URL=http://localhost:8001 # required when backend=http_bge*
4949
MOS_RERANKER_MODEL=bge-reranker-v2-m3 # siliconflow → use BAAI/bge-reranker-v2-m3
50-
MOS_RERANKER_HEADERS_EXTRA= # extra headers, JSON string
50+
MOS_RERANKER_HEADERS_EXTRA= # extra headers, JSON string, e.g. {"Authorization":"Bearer your_token"}
5151
MOS_RERANKER_STRATEGY=single_turn
5252
MOS_RERANK_SOURCE= # optional rerank scope, e.g., history/stream/custom
5353

@@ -93,6 +93,9 @@ NEO4J_DB_NAME=neo4j # required for shared-db mode
9393
MOS_NEO4J_SHARED_DB=false
9494
QDRANT_HOST=localhost
9595
QDRANT_PORT=6333
96+
# For Qdrant Cloud / remote endpoint (takes priority if set):
97+
QDRANT_URL=your_qdrant_url
98+
QDRANT_API_KEY=your_qdrant_key
9699
MILVUS_URI=http://localhost:19530 # required when ENABLE_PREFERENCE_MEMORY=true
97100
MILVUS_USER_NAME=root # same as above
98101
MILVUS_PASSWORD=12345678 # same as above

docs/product-api-tests.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
## Product API smoke tests (local 0.0.0.0:8001)
2+
3+
Source: https://github.com/MemTensor/MemOS/issues/518
4+
5+
### Prerequisites
6+
- Service is running: `python -m uvicorn memos.api.server_api:app --host 0.0.0.0 --port 8001`
7+
- `.env` is configured for Redis, embeddings, and the vector DB (current test setup: Redis reachable, Qdrant Cloud connected).
8+
9+
### 1) /product/add
10+
- Purpose: Write a memory (sync/async).
11+
- Example request (sync):
12+
13+
```bash
14+
curl -s -X POST http://127.0.0.1:8001/product/add \
15+
-H 'Content-Type: application/json' \
16+
-d '{
17+
"user_id": "tester",
18+
"mem_cube_id": "default_cube",
19+
"memory_content": "Apple is a fruit rich in fiber.",
20+
"async_mode": "sync"
21+
}'
22+
```
23+
24+
- Observed result: `200`, message: "Memory added successfully", returns the written `memory_id` and related info.
25+
26+
### 2) /product/get_all
27+
- Purpose: List all memories for the user/type to confirm writes.
28+
- Example request:
29+
30+
```bash
31+
curl -s -X POST http://127.0.0.1:8001/product/get_all \
32+
-H 'Content-Type: application/json' \
33+
-d '{
34+
"user_id": "tester",
35+
"memory_type": "text_mem",
36+
"mem_cube_ids": ["default_cube"]
37+
}'
38+
```
39+
40+
- Observed result: `200`, shows the recently written apple memories (WorkingMemory/LongTermMemory/UserMemory present, `vector_sync=success`).
41+
42+
### 3) /product/search
43+
- Purpose: Vector search memories.
44+
- Example request:
45+
46+
```bash
47+
curl -s -X POST http://127.0.0.1:8001/product/search \
48+
-H 'Content-Type: application/json' \
49+
-d '{
50+
"query": "What fruit is rich in fiber?",
51+
"user_id": "tester",
52+
"mem_cube_id": "default_cube",
53+
"top_k": 5,
54+
"pref_top_k": 3,
55+
"include_preference": false
56+
}'
57+
```
58+
59+
- Observed result: previously returned 400 because payload indexes (e.g., `vector_sync`) were missing in Qdrant. Index creation is now automatic during Qdrant initialization (memory_type/status/vector_sync/user_name).
60+
- If results are empty or errors persist, verify indexes exist (auto-created on restart) or recreate/clean the collection.
61+
62+
### Notes / Next steps
63+
- `/product/add` and `/product/get_all` are healthy.
64+
- `/product/search` still returns empty results even with vectors present; likely related to search filters or vector retrieval.
65+
- Suggested follow-ups: inspect `SearchHandler` flow, filter conditions (user_id/session/cube_name), and vector DB search calls; capture logs or compare with direct `VecDBFactory.search` calls.

src/memos/api/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,9 @@ def get_neo4j_community_config(user_id: str | None = None) -> dict[str, Any]:
500500
"distance_metric": "cosine",
501501
"host": os.getenv("QDRANT_HOST", "localhost"),
502502
"port": int(os.getenv("QDRANT_PORT", "6333")),
503+
"path": os.getenv("QDRANT_PATH"),
504+
"url": os.getenv("QDRANT_URL"),
505+
"api_key": os.getenv("QDRANT_API_KEY"),
503506
},
504507
},
505508
}

src/memos/configs/vec_db.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ class QdrantVecDBConfig(BaseVecDBConfig):
2727
host: str | None = Field(default=None, description="Host for Qdrant")
2828
port: int | None = Field(default=None, description="Port for Qdrant")
2929
path: str | None = Field(default=None, description="Path for Qdrant")
30+
url: str | None = Field(default=None, description="Qdrant Cloud/remote endpoint URL")
31+
api_key: str | None = Field(default=None, description="Qdrant Cloud API key")
3032

3133
@model_validator(mode="after")
3234
def set_default_path(self):
33-
if all(x is None for x in (self.host, self.port, self.path)):
35+
# Only fall back to embedded/local path when no remote host/port/path/url is provided.
36+
if all(x is None for x in (self.host, self.port, self.path, self.url)):
3437
logger.warning(
3538
"No host, port, or path provided for Qdrant. Defaulting to local path: %s",
3639
settings.MEMOS_DIR / "qdrant",

src/memos/reranker/factory.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# memos/reranker/factory.py
22
from __future__ import annotations
33

4+
import json
45
from typing import TYPE_CHECKING, Any
56

67
# Import singleton decorator
@@ -28,12 +29,19 @@ def from_config(cfg: RerankerConfigFactory | None) -> BaseReranker | None:
2829
backend = (cfg.backend or "").lower()
2930
c: dict[str, Any] = cfg.config or {}
3031

32+
headers_extra = c.get("headers_extra")
33+
if isinstance(headers_extra, str):
34+
try:
35+
headers_extra = json.loads(headers_extra)
36+
except Exception:
37+
headers_extra = None
38+
3139
if backend in {"http_bge", "bge"}:
3240
return HTTPBGEReranker(
3341
reranker_url=c.get("url") or c.get("endpoint") or c.get("reranker_url"),
3442
model=c.get("model", "bge-reranker-v2-m3"),
3543
timeout=int(c.get("timeout", 10)),
36-
headers_extra=c.get("headers_extra"),
44+
headers_extra=headers_extra,
3745
rerank_source=c.get("rerank_source"),
3846
)
3947

@@ -51,7 +59,7 @@ def from_config(cfg: RerankerConfigFactory | None) -> BaseReranker | None:
5159
reranker_url=c.get("url") or c.get("endpoint") or c.get("reranker_url"),
5260
model=c.get("model", "bge-reranker-v2-m3"),
5361
timeout=int(c.get("timeout", 10)),
54-
headers_extra=c.get("headers_extra"),
62+
headers_extra=headers_extra,
5563
rerank_source=c.get("rerank_source"),
5664
reranker_strategy=c.get("reranker_strategy"),
5765
)

src/memos/vec_dbs/qdrant.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,49 @@ def __init__(self, config: QdrantVecDBConfig):
2323
from qdrant_client import QdrantClient
2424

2525
self.config = config
26+
# Default payload fields we always index because query filters rely on them
27+
self._default_payload_index_fields = [
28+
"memory_type",
29+
"status",
30+
"vector_sync",
31+
"user_name",
32+
]
2633

27-
# If both host and port are None, we are running in local mode
28-
if self.config.host is None and self.config.port is None:
29-
logger.warning(
30-
"Qdrant is running in local mode (host and port are both None). "
31-
"In local mode, there may be race conditions during concurrent reads/writes. "
32-
"It is strongly recommended to deploy a standalone Qdrant server "
33-
"(e.g., via Docker: https://qdrant.tech/documentation/quickstart/)."
34+
client_kwargs: dict[str, Any] = {}
35+
if self.config.url:
36+
client_kwargs["url"] = self.config.url
37+
if self.config.api_key:
38+
client_kwargs["api_key"] = self.config.api_key
39+
else:
40+
client_kwargs.update(
41+
{
42+
"host": self.config.host,
43+
"port": self.config.port,
44+
"path": self.config.path,
45+
}
3446
)
3547

36-
self.client = QdrantClient(
37-
host=self.config.host, port=self.config.port, path=self.config.path
38-
)
48+
# If both host and port are None, we are running in local/embedded mode
49+
if self.config.host is None and self.config.port is None:
50+
logger.warning(
51+
"Qdrant is running in local mode (host and port are both None). "
52+
"In local mode, there may be race conditions during concurrent reads/writes. "
53+
"It is strongly recommended to deploy a standalone Qdrant server "
54+
"(e.g., via Docker: https://qdrant.tech/documentation/quickstart/)."
55+
)
56+
57+
self.client = QdrantClient(**client_kwargs)
3958
self.create_collection()
59+
# Ensure common payload indexes exist (idempotent)
60+
try:
61+
self.ensure_payload_indexes(self._default_payload_index_fields)
62+
except Exception as e:
63+
logger.warning(f"Failed to ensure default payload indexes: {e}")
4064

4165
def create_collection(self) -> None:
4266
"""Create a new collection with specified parameters."""
4367
from qdrant_client.http import models
68+
from qdrant_client.http.exceptions import UnexpectedResponse
4469

4570
if self.collection_exists(self.config.collection_name):
4671
collection_info = self.client.get_collection(self.config.collection_name)
@@ -57,13 +82,25 @@ def create_collection(self) -> None:
5782
"dot": models.Distance.DOT,
5883
}
5984

60-
self.client.create_collection(
61-
collection_name=self.config.collection_name,
62-
vectors_config=models.VectorParams(
63-
size=self.config.vector_dimension,
64-
distance=distance_map[self.config.distance_metric],
65-
),
66-
)
85+
try:
86+
self.client.create_collection(
87+
collection_name=self.config.collection_name,
88+
vectors_config=models.VectorParams(
89+
size=self.config.vector_dimension,
90+
distance=distance_map[self.config.distance_metric],
91+
),
92+
)
93+
except UnexpectedResponse as err:
94+
# Cloud Qdrant returns 409 when the collection already exists; tolerate and continue.
95+
if getattr(err, "status_code", None) == 409 or "already exists" in str(err).lower():
96+
logger.warning(
97+
f"Collection '{self.config.collection_name}' already exists. Skipping creation."
98+
)
99+
return
100+
raise
101+
except Exception:
102+
# Bubble up other exceptions so callers can observe failures
103+
raise
67104

68105
logger.info(
69106
f"Collection '{self.config.collection_name}' created with {self.config.vector_dimension} dimensions."

tests/configs/test_vec_db.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ def test_qdrant_vec_db_config():
4040
required_fields=[
4141
"collection_name",
4242
],
43-
optional_fields=["vector_dimension", "distance_metric", "host", "port", "path"],
43+
optional_fields=[
44+
"vector_dimension",
45+
"distance_metric",
46+
"host",
47+
"port",
48+
"path",
49+
"url",
50+
"api_key",
51+
],
4452
)
4553

4654
check_config_instantiation_valid(
@@ -53,6 +61,17 @@ def test_qdrant_vec_db_config():
5361
},
5462
)
5563

64+
check_config_instantiation_valid(
65+
QdrantVecDBConfig,
66+
{
67+
"collection_name": "test_collection",
68+
"vector_dimension": 768,
69+
"distance_metric": "cosine",
70+
"url": "https://cloud.qdrant.example",
71+
"api_key": "dummy",
72+
},
73+
)
74+
5675
check_config_instantiation_invalid(QdrantVecDBConfig)
5776

5877

tests/vec_dbs/test_qdrant.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,26 @@ def test_get_all(vec_db):
113113
results = vec_db.get_all()
114114
assert len(results) == 1
115115
assert isinstance(results[0], VecDBItem)
116+
117+
118+
def test_qdrant_client_cloud_init():
119+
config = VectorDBConfigFactory.model_validate(
120+
{
121+
"backend": "qdrant",
122+
"config": {
123+
"collection_name": "cloud_collection",
124+
"vector_dimension": 3,
125+
"distance_metric": "cosine",
126+
"url": "https://cloud.qdrant.example",
127+
"api_key": "secret-key",
128+
},
129+
}
130+
)
131+
132+
with patch("qdrant_client.QdrantClient") as mockclient:
133+
mock_instance = mockclient.return_value
134+
mock_instance.get_collection.side_effect = Exception("Not found")
135+
136+
VecDBFactory.from_config(config)
137+
138+
mockclient.assert_called_once_with(url="https://cloud.qdrant.example", api_key="secret-key")

0 commit comments

Comments
 (0)