Skip to content
Merged
24 changes: 24 additions & 0 deletions docs/components/vectordbs/dbs/opensearch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ config = {
}
```

### Configuration Options

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `collection_name` | string | required | Name of the OpenSearch index |
| `host` | string | required | OpenSearch endpoint URL |
| `port` | int | 9200 | Port number |
| `http_auth` | object | None | Authentication credentials (e.g., AWSV4SignerAuth) |
| `embedding_model_dims` | int | 1536 | Dimension of embedding vectors |
| `use_ssl` | bool | False | Enable SSL/TLS connection |
| `verify_certs` | bool | False | Verify SSL certificates |
| `auto_refresh` | bool | False | Automatically refresh index after insert. OpenSearch refreshes every ~1 second by default, so this is rarely needed. |

<Note>
The defaults above match a local OpenSearch instance. The AWS OpenSearch Serverless
example earlier on this page intentionally overrides them with `port=443`, `use_ssl=True`,
and `verify_certs=True`, which are required when connecting to a Serverless collection.
</Note>

<Note>
For **AWS OpenSearch Serverless**, keep `auto_refresh=False` (the default).
The `indices.refresh()` API is not supported on Serverless collections.
</Note>

### Add Memories

```python
Expand Down
201 changes: 164 additions & 37 deletions docs/integrations/hermes.mdx

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion mem0-ts/src/client/mem0.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ export interface PaginatedMemories {

export interface ProjectResponse {
customInstructions?: string;
customCategories?: string[];
// The API returns category objects (`[{ "<name>": "<description>" }]`),
// not bare strings (see issue #5738).
customCategories?: custom_categories[];
[key: string]: any;
}

Expand Down
46 changes: 46 additions & 0 deletions mem0-ts/src/client/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,50 @@ describe("camelToSnakeKeys / snakeToCamelKeys", () => {
});
});
});

describe("user-controlled customCategories names (issue #5738)", () => {
it("converts the outer key but leaves multi-word category names on write", () => {
expect(
camelToSnakeKeys({
customCategories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
}),
).toEqual({
// outer SDK key is snake_cased, user-defined category names are not
custom_categories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
});
});

it("converts the outer key but leaves category names verbatim on read", () => {
expect(
snakeToCamelKeys({
custom_categories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
}),
).toEqual({
customCategories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
});
});

it("round-trips category names losslessly (write then read)", () => {
const customCategories = [
{ work_life_balance: "balance between work and life" },
{ AIResearch: "artificial intelligence research" },
];
const roundTripped = snakeToCamelKeys(
camelToSnakeKeys({ customCategories }),
);
expect(roundTripped.customCategories).toEqual(customCategories);
});
});
});
5 changes: 5 additions & 0 deletions mem0-ts/src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const OPAQUE_VALUE_KEYS = new Set([
"metadata",
"structuredDataSchema",
"structured_data_schema",
// Custom-category names are user-controlled keys (`[{ "<name>": "<desc>" }]`).
// Listed in both casings so they round-trip verbatim in both directions
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
"customCategories",
"custom_categories",
]);

/**
Expand Down
8 changes: 5 additions & 3 deletions mem0-ts/src/oss/src/utils/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ const parse_vision_messages = async (messages: Message[]) => {
typeof message.content === "object" &&
message.content.type === "image_url"
) {
const description = await get_image_description(
message.content.image_url.url,
);
const imageUrl = message.content.image_url?.url;
if (!imageUrl) {
throw new Error("image_url content part is missing image_url.url");
}
const description = await get_image_description(imageUrl);
new_message.content =
typeof description === "string"
? description
Expand Down
6 changes: 6 additions & 0 deletions mem0/configs/vector_stores/opensearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class OpenSearchConfig(BaseModel):
"RequestsHttpConnection", description="Connection class for OpenSearch"
)
pool_maxsize: int = Field(20, description="Maximum number of connections in the pool")
auto_refresh: bool = Field(
False,
description="Automatically refresh index after insert operations to make documents "
"immediately searchable. Disabled by default for OpenSearch Serverless compatibility. "
"OpenSearch automatically refreshes indices every ~1 second, so most users don't need this.",
)

@model_validator(mode="before")
@classmethod
Expand Down
5 changes: 4 additions & 1 deletion mem0/memory/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"):
elif isinstance(content, dict) and content.get("type") == "image_url":
if llm is None:
continue
image_url = content["image_url"]["url"]
image_url_obj = content.get("image_url")
image_url = image_url_obj.get("url") if isinstance(image_url_obj, dict) else None
if not image_url:
raise ValueError("image_url content part is missing image_url.url")
try:
description = get_image_description(image_url, llm, vision_details)
returned_messages.append({"role": role, "content": description})
Expand Down
6 changes: 5 additions & 1 deletion mem0/reranker/cohere_reranker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
from typing import List, Dict, Any

Expand All @@ -9,6 +10,8 @@
except ImportError:
COHERE_AVAILABLE = False

logger = logging.getLogger(__name__)


class CohereReranker(BaseReranker):
"""Cohere-based reranker implementation."""
Expand Down Expand Up @@ -78,8 +81,9 @@ def rerank(self, query: str, documents: List[Dict[str, Any]], top_k: int = None)

return reranked_docs

except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("Cohere reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
Expand Down
6 changes: 5 additions & 1 deletion mem0/reranker/huggingface_reranker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import List, Dict, Any, Union
import numpy as np

Expand All @@ -12,6 +13,8 @@
except ImportError:
TRANSFORMERS_AVAILABLE = False

logger = logging.getLogger(__name__)


class HuggingFaceReranker(BaseReranker):
"""HuggingFace Transformers based reranker implementation."""
Expand Down Expand Up @@ -139,8 +142,9 @@ def rerank(self, query: str, documents: List[Dict[str, Any]], top_k: int = None)

return reranked_docs

except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("HuggingFace reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
Expand Down
6 changes: 5 additions & 1 deletion mem0/reranker/llm_reranker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import re
from typing import Any, Dict, List, Union

Expand All @@ -6,6 +7,8 @@
from mem0.reranker.base import BaseReranker
from mem0.utils.factory import LlmFactory

logger = logging.getLogger(__name__)


class LLMReranker(BaseReranker):
"""LLM-based reranker implementation."""
Expand Down Expand Up @@ -151,8 +154,9 @@ def rerank(self, query: str, documents: List[Dict[str, Any]], top_k: int = None)
scored_doc['rerank_score'] = score
scored_docs.append(scored_doc)

except Exception:
except Exception as e:
# Fallback: assign neutral score if scoring fails
logger.warning("LLM reranking failed for a document, assigning neutral score: %s", e)
scored_doc = doc.copy()
scored_doc['rerank_score'] = 0.5
scored_docs.append(scored_doc)
Expand Down
6 changes: 5 additions & 1 deletion mem0/reranker/sentence_transformer_reranker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import List, Dict, Any, Union
import numpy as np

Expand All @@ -11,6 +12,8 @@
except ImportError:
SENTENCE_TRANSFORMERS_AVAILABLE = False

logger = logging.getLogger(__name__)


class SentenceTransformerReranker(BaseReranker):
"""Sentence Transformer based reranker implementation."""
Expand Down Expand Up @@ -102,8 +105,9 @@ def rerank(self, query: str, documents: List[Dict[str, Any]], top_k: int = None)

return reranked_docs

except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
Expand Down
6 changes: 5 additions & 1 deletion mem0/reranker/zero_entropy_reranker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
from typing import List, Dict, Any

Expand All @@ -9,6 +10,8 @@
except ImportError:
ZERO_ENTROPY_AVAILABLE = False

logger = logging.getLogger(__name__)


class ZeroEntropyReranker(BaseReranker):
"""Zero Entropy-based reranker implementation."""
Expand Down Expand Up @@ -89,8 +92,9 @@ def rerank(self, query: str, documents: List[Dict[str, Any]], top_k: int = None)

return reranked_docs

except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("Zero Entropy reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
Expand Down
8 changes: 6 additions & 2 deletions mem0/vector_stores/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def delete(self, vector_id: str):
Args:
vector_id (str): ID of the vector to delete.
"""
self.collection.delete(ids=vector_id)
self.collection.delete(ids=[vector_id])

def update(
self,
Expand All @@ -185,7 +185,11 @@ def update(
vector (Optional[List[float]], optional): Updated vector. Defaults to None.
payload (Optional[Dict], optional): Updated payload. Defaults to None.
"""
self.collection.update(ids=vector_id, embeddings=vector, metadatas=payload)
self.collection.update(
ids=[vector_id],
embeddings=[vector] if vector is not None else None,
metadatas=[payload] if payload is not None else None,
)

def get(self, vector_id: str) -> Optional[OutputData]:
"""
Expand Down
12 changes: 10 additions & 2 deletions mem0/vector_stores/opensearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ def __init__(self, **kwargs):

self.collection_name = config.collection_name
self.embedding_model_dims = config.embedding_model_dims
self.auto_refresh = config.auto_refresh

self.create_col(self.collection_name, self.embedding_model_dims)

def create_index(self) -> None:
Expand Down Expand Up @@ -148,8 +150,6 @@ def insert(
}
try:
self.client.index(index=self.collection_name, body=body)
# Force refresh to make documents immediately searchable for tests
self.client.indices.refresh(index=self.collection_name)

results.append(
OutputData(
Expand All @@ -162,6 +162,14 @@ def insert(
logger.error(f"Error inserting vector {id_}: {e}", exc_info=True)
raise

# Refresh once after the full batch (not per document) if explicitly enabled.
# Disabled by default for Serverless compatibility: OpenSearch Serverless does not
# support the indices.refresh() API, and refreshing per document would cause a
# cluster-level I/O stall on every insert.
# See: https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-genref.html
if self.auto_refresh:
self.client.indices.refresh(index=self.collection_name)

return results

def search(
Expand Down
8 changes: 6 additions & 2 deletions server/dashboard/src/app/(root)/dashboard/memories/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { useApiQuery } from "@/hooks/use-api-query";
import { Memory } from "@/types/api";

const PAGE_SIZE = 20;
// Keep in sync with ALL_MEMORIES_LIMIT in server/main.py.
const MEMORY_FETCH_LIMIT = 1000;

export default function MemoriesPage() {
const [userId, setUserId] = useState("");
Expand All @@ -41,7 +43,9 @@ export default function MemoriesPage() {
refetch,
} = useApiQuery<Memory[]>(
async () => {
const params = userId.trim() ? { user_id: userId.trim() } : undefined;
const params = userId.trim()
? { user_id: userId.trim(), top_k: MEMORY_FETCH_LIMIT }
: { top_k: MEMORY_FETCH_LIMIT };
const res = await api.get(MEMORY_ENDPOINTS.BASE, { params });
const raw = res.data?.results ?? res.data ?? [];
return Array.isArray(raw) ? raw : [];
Expand Down Expand Up @@ -96,7 +100,7 @@ export default function MemoriesPage() {
<div className="space-y-4">
<h1 className="text-xl font-semibold font-fustat">Memories</h1>

{memories.length >= 1000 && (
{memories.length >= MEMORY_FETCH_LIMIT && (
<UpgradeBanner
id="memories-1k"
message="1,000+ memories stored. Categories can help organize them."
Expand Down
10 changes: 7 additions & 3 deletions server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
upstream_error,
upstream_error_handler,
)
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from mem0.exceptions import ValidationError as Mem0ValidationError
Expand Down Expand Up @@ -409,6 +409,7 @@ def get_all_memories(
user_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
top_k: Optional[int] = Query(None, ge=0, le=ALL_MEMORIES_LIMIT),
_auth=Depends(verify_auth),
):
"""Retrieve stored memories. Lists all memories when no identifier is provided (admin only)."""
Expand All @@ -417,11 +418,14 @@ def get_all_memories(
auth_type = getattr(request.state, "auth_type", "none")
if _auth is not None and _auth.role != "admin" and auth_type not in {"admin_api_key", "disabled"}:
raise HTTPException(status_code=403, detail="Admin role required to list all memories.")
return _list_all_memories()
return _list_all_memories(limit=top_k if top_k is not None else ALL_MEMORIES_LIMIT)
filters = {
k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None
}
return get_memory_instance().get_all(filters=filters)
params = {"filters": filters}
if top_k is not None:
params["top_k"] = top_k
return get_memory_instance().get_all(**params)
except HTTPException:
raise
except Exception:
Expand Down
Loading
Loading