feat(oss): port v3 pipeline with hybrid search, entity extraction, and additive scoring (#4805)

Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
Co-authored-by: chaithanyak42 <chaithanya.kumar42a@gmail.com>
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
soumil-rathi
2026-04-14 18:00:58 +05:30
committed by GitHub
co-authored by Soumil Rathi Saket Aryan chaithanyak42 kartik-mem0
parent 57f944e18a
commit a488e19044
120 changed files with 10107 additions and 17135 deletions
+28
View File
@@ -246,6 +246,34 @@ class AzureAISearch(VectorStoreBase):
results.append(OutputData(id=result["id"], score=result["@search.score"], payload=payload))
return results
def keyword_search(self, query, top_k=5, filters=None):
"""Search for memories using keyword/BM25 text matching (no vector queries).
Args:
query (str): The text query to search for.
top_k (int): Maximum number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
List[OutputData]: Search results with id, score, and payload.
"""
filter_expression = None
if filters:
filter_expression = self._build_filter_expression(filters)
search_results = self.search_client.search(
search_text=query,
filter=filter_expression,
top=top_k,
search_fields=["payload"],
)
results = []
for result in search_results:
payload = json.loads(extract_json(result["payload"]))
results.append(OutputData(id=result["id"], score=result["@search.score"], payload=payload))
return results
def delete(self, vector_id):
"""
Delete a vector by ID.
+71 -1
View File
@@ -178,17 +178,32 @@ class AzureMySQL(VectorStoreBase):
dims = vector_size or self.embedding_model_dims
with self._get_cursor(commit=True) as cur:
# Create table with vector column
# Create table with vector column and a generated column for fulltext keyword search
cur.execute(f"""
CREATE TABLE IF NOT EXISTS `{table_name}` (
id VARCHAR(255) PRIMARY KEY,
vector JSON,
payload JSON,
text_lemmatized VARCHAR(1000) GENERATED ALWAYS AS
(CAST(payload->>'$.text_lemmatized' AS CHAR(1000))) STORED,
INDEX idx_payload_keys ((CAST(payload AS CHAR(255)) ARRAY))
)
""")
logger.info(f"Created collection '{table_name}' with vector dimension {dims}")
# Add FULLTEXT index on text_lemmatized for keyword_search()
try:
cur.execute(f"""
CREATE FULLTEXT INDEX ft_text_lemmatized
ON `{table_name}` (text_lemmatized)
""")
logger.info(f"Created FULLTEXT index on '{table_name}.text_lemmatized'")
except Exception as e:
logger.debug(
f"Could not create FULLTEXT index on '{table_name}.text_lemmatized': {e}. "
"It may already exist or FULLTEXT may not be supported."
)
def insert(self, vectors: List[List[float]], payloads: Optional[List[Dict]] = None, ids: Optional[List[str]] = None):
"""
Insert vectors into the collection.
@@ -300,6 +315,61 @@ class AzureMySQL(VectorStoreBase):
for r in scored_results
]
def keyword_search(self, query, top_k=5, filters=None):
"""
Search for memories using MySQL FULLTEXT search via MATCH() AGAINST().
This method attempts to use a FULLTEXT index on the text_lemmatized column.
If the column or index does not exist, it returns None gracefully.
Args:
query (str): The text query for keyword-based search.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (dict, optional): Filters to apply to the search. Defaults to None.
Returns:
list: Search results in the same format as search(), or None if FULLTEXT
search is not supported on this collection.
"""
try:
filter_conditions = []
filter_params = []
if filters:
for k, v in filters.items():
filter_conditions.append("JSON_EXTRACT(payload, %s) = %s")
filter_params.extend([f"$.{k}", json.dumps(v)])
filter_clause = ""
if filter_conditions:
filter_clause = " AND " + " AND ".join(filter_conditions)
with self._get_cursor() as cur:
query_sql = f"""
SELECT id, payload,
MATCH(text_lemmatized) AGAINST(%s IN NATURAL LANGUAGE MODE) AS score
FROM `{self.collection_name}`
WHERE MATCH(text_lemmatized) AGAINST(%s IN NATURAL LANGUAGE MODE)
{filter_clause}
ORDER BY score DESC
LIMIT %s
"""
params = [query, query] + filter_params + [top_k]
cur.execute(query_sql, params)
results = cur.fetchall()
return [
OutputData(
id=r['id'],
score=float(r['score']),
payload=json.loads(r['payload']) if isinstance(r['payload'], str) else r['payload'],
)
for r in results
]
except Exception as e:
logger.debug(f"Keyword search not available for collection {self.collection_name}: {e}")
return None
def delete(self, vector_id: str):
"""
Delete a vector by ID.
+43
View File
@@ -27,6 +27,7 @@ try:
VectorIndex,
)
from pymochow.model.table import (
BM25SearchRequest,
FloatVector,
Partition,
Row,
@@ -227,6 +228,48 @@ class BaiduDB(VectorStoreBase):
return output
def keyword_search(self, query, top_k=5, filters=None):
"""
Perform keyword-based search using Baidu Mochow's BM25 search.
Args:
query (str): The text query to search for.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
list: Search results, or None if the table lacks an inverted index.
"""
try:
search_filter = None
if filters:
search_filter = self._create_filter(filters)
request = BM25SearchRequest(
index_name="data_bm25_idx",
search_text=query,
limit=top_k,
filter=search_filter,
)
projections = ["id", "metadata"]
res = self._table.bm25_search(request=request, projections=projections)
output = []
for row in res.rows:
row_data = row.get("row", {})
output_data = OutputData(
id=row_data.get("id"),
score=row.get("score", 0.0),
payload=row_data.get("metadata", {}),
)
output.append(output_data)
return output
except Exception as e:
logger.error(f"Error during keyword search for query '{query}': {e}")
return None
def delete(self, vector_id):
"""
Delete a vector by ID.
+34
View File
@@ -56,3 +56,37 @@ class VectorStoreBase(ABC):
def reset(self):
"""Reset by delete the collection and recreate it."""
pass
def keyword_search(self, query: str, top_k: int = 5, filters: dict = None):
"""Keyword/BM25 full-text search. Returns None if not supported by this store.
Override in subclasses that support native keyword/BM25 search.
Returns results in the same format as search() -- list of objects with
id, score, and payload attributes.
Args:
query: The search query text (should be lemmatized for best results).
top_k: Maximum number of results to return.
filters: Optional metadata filters (same format as search filters).
Returns:
List of search results with id, score, payload, or None if not supported.
"""
return None
def search_batch(self, queries: list, vectors_list: list, top_k: int = 1, filters: dict = None):
"""Batch search for multiple queries at once.
Default implementation calls search() sequentially. Override in subclasses
with native batch support (e.g., Qdrant query_batch_points).
Args:
queries: List of query texts.
vectors_list: List of query vectors (one per query).
top_k: Maximum results per query.
filters: Optional metadata filters applied to all queries.
Returns:
List of result lists, one per query.
"""
return [self.search(q, v, top_k=top_k, filters=filters) for q, v in zip(queries, vectors_list)]
+49
View File
@@ -515,6 +515,55 @@ class Databricks(VectorStoreBase):
logger.error(f"Search failed: {e}")
raise
def keyword_search(self, query, top_k=5, filters=None):
"""
Search for memories using full-text keyword search.
Only supported for DELTA_SYNC index type. Returns None for DIRECT_ACCESS indexes.
Args:
query (str): Search query text.
top_k (int): Maximum number of results. Defaults to 5.
filters (dict, optional): Filters to apply.
Returns:
List[MemoryResult] or None: Search results, or None if index type is DIRECT_ACCESS.
"""
if self.index_type == VectorIndexType.DIRECT_ACCESS:
logger.warning("keyword_search is not supported for DIRECT_ACCESS index type.")
return None
try:
filters_json = json.dumps(filters) if filters else None
sdk_results = self.client.vector_search_indexes.query_index(
index_name=self.fully_qualified_index_name,
columns=self.column_names,
query_text=query,
num_results=top_k,
query_type="FULL_TEXT",
filters_json=filters_json,
)
result_data = sdk_results.result if hasattr(sdk_results, "result") else sdk_results
data_array = result_data.data_array if getattr(result_data, "data_array", None) else []
memory_results = []
for row in data_array:
row_dict = dict(zip(self.column_names, row)) if isinstance(row, (list, tuple)) else row
score = row_dict.get("score") or (
row[-1] if isinstance(row, (list, tuple)) and len(row) > len(self.column_names) else None
)
payload = {k: row_dict.get(k) for k in self.column_names}
payload["data"] = payload.get("memory", "")
memory_id = row_dict.get("memory_id") or row_dict.get("id")
memory_results.append(MemoryResult(id=memory_id, score=score, payload=payload))
return memory_results
except Exception as e:
logger.error(f"Keyword search failed: {e}")
raise
def delete(self, vector_id):
"""
Delete a vector by ID from the Delta table.
+43
View File
@@ -158,6 +158,49 @@ class ElasticsearchDB(VectorStoreBase):
return results
def keyword_search(self, query, top_k=5, filters=None):
"""Search for memories using BM25 keyword matching.
Args:
query (str): The text query to search for.
top_k (int): Maximum number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
List[OutputData]: Search results with id, score, and payload.
"""
# Build a multi_match query across text fields in metadata
should_clauses = [
{"match": {"metadata.data": query}},
{"match": {"metadata.text_lemmatized": query}},
]
bool_query = {
"should": should_clauses,
"minimum_should_match": 1,
}
if filters:
filter_conditions = []
for key, value in filters.items():
filter_conditions.append({"term": {f"metadata.{key}": value}})
bool_query["filter"] = filter_conditions
search_query = {
"size": top_k,
"query": {"bool": bool_query},
}
response = self.client.search(index=self.collection_name, body=search_query)
results = []
for hit in response["hits"]["hits"]:
results.append(
OutputData(id=hit["_id"], score=hit["_score"], payload=hit.get("_source", {}).get("metadata", {}))
)
return results
def delete(self, vector_id: str) -> None:
"""Delete a vector by ID."""
self.client.delete(index=self.collection_name, id=vector_id)
+67 -5
View File
@@ -11,7 +11,7 @@ try:
except ImportError:
raise ImportError("The 'pymilvus' library is required. Please install it using 'pip install pymilvus'.")
from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient
from pymilvus import CollectionSchema, DataType, FieldSchema, Function, FunctionType, MilvusClient
logger = logging.getLogger(__name__)
@@ -73,14 +73,34 @@ class MilvusDB(VectorStoreBase):
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=512),
FieldSchema(name="vectors", dtype=DataType.FLOAT_VECTOR, dim=vector_size),
FieldSchema(name="metadata", dtype=DataType.JSON),
# Text field for BM25 full-text search (auto-tokenized by Milvus analyzer)
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535, enable_analyzer=True),
# Sparse vector field populated automatically by the BM25 function below
FieldSchema(name="sparse", dtype=DataType.SPARSE_FLOAT_VECTOR),
]
schema = CollectionSchema(fields, enable_dynamic_field=True)
index = self.client.prepare_index_params(
# Add BM25 function so Milvus auto-generates sparse vectors from the text field
bm25_function = Function(
name="bm25",
input_field_names=["text"],
output_field_names=["sparse"],
function_type=FunctionType.BM25,
)
schema.add_function(bm25_function)
index_params = self.client.prepare_index_params()
index_params.add_index(
field_name="vectors", metric_type=metric_type, index_type="AUTOINDEX", index_name="vector_index"
)
self.client.create_collection(collection_name=collection_name, schema=schema, index_params=index)
index_params.add_index(
field_name="sparse",
index_type="SPARSE_INVERTED_INDEX",
metric_type="BM25",
index_name="sparse_index",
)
self.client.create_collection(collection_name=collection_name, schema=schema, index_params=index_params)
def insert(self, ids, vectors, payloads, **kwargs: Optional[dict[str, any]]):
"""Insert vectors into a collection.
@@ -92,7 +112,13 @@ class MilvusDB(VectorStoreBase):
"""
# Batch insert all records at once for better performance and consistency
data = [
{"id": idx, "vectors": embedding, "metadata": metadata}
{
"id": idx,
"vectors": embedding,
"metadata": metadata,
# Populate the text field for BM25 sparse search; prefer lemmatized text, fall back to raw data
"text": (metadata.get("text_lemmatized") or metadata.get("data", ""))[:65535] if metadata else "",
}
for idx, embedding, metadata in zip(ids, vectors, payloads)
]
self.client.insert(collection_name=self.collection_name, data=data, **kwargs)
@@ -163,6 +189,39 @@ class MilvusDB(VectorStoreBase):
result = self._parse_output(data=hits[0])
return result
def keyword_search(self, query, top_k=5, filters=None):
"""
Search for memories using BM25-based full-text search via Milvus sparse vector support.
Milvus 2.5+ supports native BM25 via full-text search with a SPARSE_FLOAT_VECTOR field.
This method attempts to use that capability. If the collection does not have a sparse
field configured, it returns None gracefully.
Args:
query (str): The text query for keyword-based search.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (dict, optional): Filters to apply to the search. Defaults to None.
Returns:
list: Search results in the same format as search(), or None if sparse search
is not supported on this collection.
"""
try:
query_filter = self._create_filter(filters) if filters else None
hits = self.client.search(
collection_name=self.collection_name,
data=[query],
anns_field="sparse",
limit=top_k,
filter=query_filter,
output_fields=["*"],
)
result = self._parse_output(data=hits[0])
return result
except Exception as e:
logger.debug(f"Keyword search not available for collection {self.collection_name}: {e}")
return None
def delete(self, vector_id):
"""
Delete a vector by ID.
@@ -192,7 +251,10 @@ class MilvusDB(VectorStoreBase):
if payload is None:
payload = existing[0].get("metadata")
schema = {"id": vector_id, "vectors": vector, "metadata": payload}
text = ""
if payload:
text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535]
schema = {"id": vector_id, "vectors": vector, "metadata": payload, "text": text}
self.client.upsert(collection_name=self.collection_name, data=schema)
def get(self, vector_id):
+87
View File
@@ -85,6 +85,41 @@ class MongoDB(VectorStoreBase):
logger.info(
f"Search index '{self.index_name}' created successfully for collection '{self.collection_name}'."
)
# Create Atlas Search text index for keyword_search()
text_index_name = f"{self.collection_name}_text_search_index"
try:
found_text_indexes = list(collection.list_search_indexes(name=text_index_name))
if not found_text_indexes:
text_search_index_model = SearchIndexModel(
name=text_index_name,
definition={
"mappings": {
"dynamic": False,
"fields": {
"payload": {
"type": "document",
"fields": {
"data": {"type": "string"},
"text_lemmatized": {"type": "string"},
},
}
},
}
},
)
collection.create_search_index(text_search_index_model)
logger.info(
f"Text search index '{text_index_name}' created successfully for collection '{self.collection_name}'."
)
else:
logger.info(f"Text search index '{text_index_name}' already exists in collection '{self.collection_name}'.")
except Exception as e:
logger.warning(
f"Could not create text search index '{text_index_name}': {e}. "
"Atlas Search may not be available. keyword_search() will not work."
)
return collection
except PyMongoError as e:
logger.error(f"Error creating collection and search index: {e}")
@@ -168,6 +203,58 @@ class MongoDB(VectorStoreBase):
output = [OutputData(id=str(doc["_id"]), score=doc.get("score"), payload=doc.get("payload")) for doc in results]
return output
def keyword_search(self, query, top_k=5, filters=None):
"""
Perform keyword-based search using MongoDB Atlas Search.
Args:
query (str): The text query to search for.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
List[OutputData]: Search results, or None if Atlas Search index is not available.
"""
try:
collection = self.client[self.db_name][self.collection_name]
search_index_name = f"{self.collection_name}_text_search_index"
pipeline = [
{
"$search": {
"index": search_index_name,
"text": {
"query": query,
"path": ["payload.data", "payload.text_lemmatized"],
},
}
},
{"$set": {"score": {"$meta": "searchScore"}}},
{"$project": {"embedding": 0}},
]
# Add filter stage if filters are provided
if filters:
filter_conditions = []
for key, value in filters.items():
filter_conditions.append({"payload." + key: value})
if filter_conditions:
pipeline.insert(1, {"$match": {"$and": filter_conditions}})
pipeline.append({"$limit": top_k})
results = list(collection.aggregate(pipeline))
logger.info(f"Keyword search completed. Found {len(results)} documents.")
output = [
OutputData(id=str(doc["_id"]), score=doc.get("score"), payload=doc.get("payload"))
for doc in results
]
return output
except Exception as e:
logger.error(f"Error during keyword search for query '{query}': {e}")
return None
def delete(self, vector_id: str) -> None:
"""
Delete a vector by ID.
+51
View File
@@ -203,6 +203,57 @@ class OpenSearchDB(VectorStoreBase):
logger.error(f"Error during search: {e}", exc_info=True)
return []
def keyword_search(self, query, top_k=5, filters=None):
"""Search for memories using BM25 keyword matching.
Args:
query (str): The text query to search for.
top_k (int): Maximum number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
List[OutputData]: Search results with id, score, and payload.
"""
# Build a multi_match query across text fields in payload
should_clauses = [
{"match": {"payload.data": query}},
{"match": {"payload.text_lemmatized": query}},
]
bool_query = {
"should": should_clauses,
"minimum_should_match": 1,
}
# Apply filters consistently with the existing search() method
filter_clauses = []
if filters:
for key in ["user_id", "run_id", "agent_id"]:
value = filters.get(key)
if value:
filter_clauses.append({"term": {f"payload.{key}.keyword": value}})
if filter_clauses:
bool_query["filter"] = filter_clauses
query_body = {
"size": top_k,
"query": {"bool": bool_query},
}
try:
response = self.client.search(index=self.collection_name, body=query_body)
hits = response["hits"]["hits"]
results = [
OutputData(id=hit["_source"].get("id"), score=hit["_score"], payload=hit["_source"].get("payload", {}))
for hit in hits[:top_k]
]
return results
except Exception as e:
logger.error(f"Error during keyword search: {e}")
return []
def delete(self, vector_id: str) -> None:
"""Delete a vector by custom ID."""
# First, find the document by custom ID
+51
View File
@@ -179,6 +179,13 @@ class PGVector(VectorStoreBase):
USING hnsw (vector vector_cosine_ops)
"""
)
cur.execute(
f"""
CREATE INDEX IF NOT EXISTS {self.collection_name}_text_lemmatized_idx
ON {self.collection_name}
USING gin(to_tsvector('simple', payload->>'text_lemmatized'));
"""
)
def insert(self, vectors: list[list[float]], payloads=None, ids=None) -> None:
logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}")
@@ -243,6 +250,50 @@ class PGVector(VectorStoreBase):
results = cur.fetchall()
return [OutputData(id=str(r[0]), score=float(r[1]), payload=r[2]) for r in results]
def keyword_search(self, query, top_k=5, filters=None):
"""
Search using PostgreSQL full-text search on lemmatized text.
Args:
query (str): The search query text.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (dict, optional): Filters to apply to the search. Defaults to None.
Returns:
List[OutputData]: Search results ranked by text relevance.
"""
filter_conditions = []
filter_params = []
if filters:
for k, v in filters.items():
filter_conditions.append("payload->>%s = %s")
filter_params.extend([k, str(v)])
filter_clause = ""
if filter_conditions:
filter_clause = "AND " + " AND ".join(filter_conditions)
try:
with self._get_cursor() as cur:
cur.execute(
f"""
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', %s)) AS score, payload
FROM {self.collection_name}
WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', %s)
{filter_clause}
ORDER BY score DESC
LIMIT %s
""",
(query, query, *filter_params, top_k),
)
results = cur.fetchall()
return [OutputData(id=str(r[0]), score=float(r[1]), payload=r[2]) for r in results]
except Exception as e:
logger.debug(f"Keyword search failed: {e}")
return None
def delete(self, vector_id: str) -> None:
"""
Delete a vector by ID.
+36
View File
@@ -241,6 +241,42 @@ class PineconeDB(VectorStoreBase):
results = self._parse_output(response.matches)
return results
def keyword_search(self, query, top_k=5, filters=None):
"""
Search using BM25 sparse vectors for keyword-based retrieval.
Args:
query (str): The search query text.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (dict, optional): Filters to apply to the search. Defaults to None.
Returns:
List[OutputData]: Search results, or None if hybrid search is not configured.
"""
if not self.hybrid_search or self.sparse_encoder is None:
return None
try:
filter_dict = self._create_filter(filters) if filters else None
sparse_vector = self.sparse_encoder.encode_queries(query)
query_params = {
"sparse_vector": sparse_vector,
"top_k": top_k,
"include_metadata": True,
"include_values": False,
}
if filter_dict:
query_params["filter"] = filter_dict
response = self.index.query(**query_params, namespace=self.namespace)
return self._parse_output(response.matches)
except Exception:
return None
def delete(self, vector_id: Union[str, int]):
"""
Delete a vector by ID.
+121 -15
View File
@@ -2,7 +2,7 @@ import logging
import re
from typing import Optional
from qdrant_client import QdrantClient
from qdrant_client import QdrantClient, models
from qdrant_client.models import (
DatetimeRange,
Distance,
@@ -16,6 +16,8 @@ from qdrant_client.models import (
PointStruct,
PointVectors,
Range,
SparseVector,
SparseVectorParams,
VectorParams,
)
@@ -64,7 +66,7 @@ class Qdrant(VectorStoreBase):
if host and port:
params["host"] = host
params["port"] = port
if not params:
params["path"] = path
self.is_local = True
@@ -76,11 +78,44 @@ class Qdrant(VectorStoreBase):
self.collection_name = collection_name
self.embedding_model_dims = embedding_model_dims
self.on_disk = on_disk
self._bm25_encoder = None
self.create_col(embedding_model_dims, on_disk)
def _get_bm25_encoder(self):
"""Lazy-load the BM25 sparse text encoder (fastembed)."""
if self._bm25_encoder is None:
try:
from fastembed import SparseTextEmbedding
self._bm25_encoder = SparseTextEmbedding(model_name="Qdrant/bm25")
logger.info("BM25 encoder loaded (fastembed Qdrant/bm25)")
except ImportError:
logger.warning("fastembed not installed — BM25 keyword search disabled. Install with: pip install fastembed")
self._bm25_encoder = False # sentinel: tried and failed
except Exception as e:
logger.warning(f"Failed to load BM25 encoder: {e}")
self._bm25_encoder = False
return self._bm25_encoder if self._bm25_encoder is not False else None
def _encode_bm25(self, text: str) -> SparseVector | None:
"""Encode text into a BM25 sparse vector."""
encoder = self._get_bm25_encoder()
if encoder is None:
return None
try:
results = list(encoder.embed([text]))
if results:
sparse = results[0]
return SparseVector(
indices=sparse.indices.tolist(),
values=sparse.values.tolist(),
)
except Exception as e:
logger.debug(f"BM25 encoding failed: {e}")
return None
def create_col(self, vector_size: int, on_disk: bool, distance: Distance = Distance.COSINE):
"""
Create a new collection.
Create a new collection with dense vectors and BM25 sparse vectors.
Args:
vector_size (int): Size of the vectors to be stored.
@@ -98,6 +133,11 @@ class Qdrant(VectorStoreBase):
self.client.create_collection(
collection_name=self.collection_name,
vectors_config=VectorParams(size=vector_size, distance=distance, on_disk=on_disk),
sparse_vectors_config={
"bm25": SparseVectorParams(
modifier=models.Modifier.IDF,
),
},
)
self._create_filter_indexes()
@@ -107,9 +147,9 @@ class Qdrant(VectorStoreBase):
if self.is_local:
logger.debug("Skipping payload index creation for local Qdrant (not supported)")
return
common_fields = ["user_id", "agent_id", "run_id", "actor_id"]
for field in common_fields:
try:
self.client.create_payload_index(
@@ -123,7 +163,8 @@ class Qdrant(VectorStoreBase):
def insert(self, vectors: list, payloads: list = None, ids: list = None):
"""
Insert vectors into a collection.
Insert vectors into a collection, including BM25 sparse vectors
computed from the text_lemmatized payload field.
Args:
vectors (list): List of vectors to insert.
@@ -131,14 +172,21 @@ class Qdrant(VectorStoreBase):
ids (list, optional): List of IDs corresponding to vectors. Defaults to None.
"""
logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}")
points = [
PointStruct(
id=idx if ids is None else ids[idx],
vector=vector,
payload=payloads[idx] if payloads else {},
)
for idx, vector in enumerate(vectors)
]
points = []
for idx, vector in enumerate(vectors):
payload = payloads[idx] if payloads else {}
point_id = idx if ids is None else ids[idx]
# Build named vectors: dense + optional BM25 sparse
named_vectors = {"": vector}
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
points.append(PointStruct(id=point_id, vector=named_vectors, payload=payload))
self.client.upsert(collection_name=self.collection_name, points=points)
# ISO 8601 datetime pattern for detecting datetime strings in range filters
@@ -330,6 +378,53 @@ class Qdrant(VectorStoreBase):
)
return hits.points
def search_batch(self, queries: list, vectors_list: list, top_k: int = 1, filters: dict = None):
"""Batch search using Qdrant's query_batch_points for efficiency."""
query_filter = self._create_filter(filters) if filters else None
requests = [
models.QueryRequest(query=vec, filter=query_filter, limit=top_k)
for vec in vectors_list
]
try:
results = self.client.query_batch_points(
collection_name=self.collection_name,
requests=requests,
)
return [r.points for r in results]
except Exception as e:
logger.warning(f"Batch search failed, falling back to sequential: {e}")
return [self.search(q, v, top_k=top_k, filters=filters) for q, v in zip(queries, vectors_list)]
def keyword_search(self, query, top_k=5, filters=None):
"""
Search using BM25 sparse vectors for keyword-based retrieval.
Args:
query (str): The search query text.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (dict, optional): Filters to apply to the search. Defaults to None.
Returns:
list: Search results, or None if BM25 is not available.
"""
sparse_query = self._encode_bm25(query)
if sparse_query is None:
return None
try:
query_filter = self._create_filter(filters) if filters else None
hits = self.client.query_points(
collection_name=self.collection_name,
query=sparse_query,
using="bm25",
query_filter=query_filter,
limit=top_k,
)
return hits.points
except Exception as e:
logger.debug(f"BM25 keyword search failed: {e}")
return None
def delete(self, vector_id: int):
"""
Delete a vector by ID.
@@ -354,9 +449,20 @@ class Qdrant(VectorStoreBase):
payload (dict, optional): Updated payload. Defaults to None.
"""
if vector is not None and payload is not None:
point = PointStruct(id=vector_id, vector=vector, payload=payload)
# Full update: attach BM25 sparse vector alongside dense vector
named_vectors = {"": vector}
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
point = PointStruct(id=vector_id, vector=named_vectors, payload=payload)
self.client.upsert(collection_name=self.collection_name, points=[point])
else:
# Partial update: use Qdrant's dedicated endpoints.
# Note: BM25 sparse vector cannot be refreshed via set_payload alone;
# payload-only updates will leave any existing BM25 vector stale. In
# practice v3 re-embeds on memory text change, so this is acceptable.
if payload is not None:
self.client.set_payload(
collection_name=self.collection_name,
+55 -1
View File
@@ -7,7 +7,7 @@ import numpy as np
import redis
from redis.commands.search.query import Query
from redisvl.index import SearchIndex
from redisvl.query import VectorQuery
from redisvl.query import TextQuery, VectorQuery
from redisvl.query.filter import Tag
from mem0.memory.utils import extract_json
@@ -181,6 +181,60 @@ class RedisDB(VectorStoreBase):
for result in results
]
def keyword_search(self, query, top_k=5, filters=None):
"""
Search for memories using BM25 keyword search on the memory field.
Args:
query (str): Search query text.
top_k (int): Maximum number of results. Defaults to 5.
filters (dict, optional): Filters to apply (user_id, agent_id, run_id).
Returns:
List[MemoryResult]: Search results.
"""
filter_expression = None
if filters:
conditions = [Tag(key) == value for key, value in filters.items() if value is not None]
if conditions:
filter_expression = reduce(lambda x, y: x & y, conditions)
t = TextQuery(
text=query,
text_field_name="memory",
return_fields=["memory_id", "hash", "agent_id", "run_id", "user_id", "memory", "metadata", "created_at"],
filter_expression=filter_expression,
num_results=top_k,
)
results = self.index.query(t)
return [
MemoryResult(
id=result["memory_id"],
score=result.get("text_score", 1.0),
payload={
"hash": result["hash"],
"data": result["memory"],
"created_at": datetime.fromtimestamp(
int(result["created_at"]), tz=timezone.utc
).isoformat(timespec="microseconds"),
**(
{
"updated_at": datetime.fromtimestamp(
int(result["updated_at"]), tz=timezone.utc
).isoformat(timespec="microseconds")
}
if "updated_at" in result
else {}
),
**{field: result[field] for field in ["agent_id", "run_id", "user_id"] if field in result},
**{k: v for k, v in json.loads(extract_json(result["metadata"])).items()},
},
)
for result in results
]
def delete(self, vector_id):
self.index.drop_keys(f"{self.schema['index']['prefix']}:{vector_id}")
+39
View File
@@ -149,6 +149,45 @@ class UpstashVector(VectorStoreBase):
for res in response
]
def keyword_search(self, query, top_k=5, filters=None):
"""
Perform keyword-based search using Upstash's BM25 sparse search.
Args:
query (str): The text query to search for.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
List[OutputData]: Search results, or None if sparse/BM25 search is not supported.
"""
try:
filters_str = (
" AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()])
if filters
else None
)
response = self.client.query(
data=query,
top_k=top_k,
filter=filters_str or "",
include_metadata=True,
namespace=self.collection_name,
)
return [
OutputData(
id=res.id,
score=res.score,
payload=res.metadata,
)
for res in response
]
except Exception as e:
logger.error(f"Error during keyword search for query '{query}': {e}")
return None
def delete(self, vector_id: int):
"""
Delete a vector by ID.
@@ -276,6 +276,11 @@ class GoogleMatchingEngine(VectorStoreBase):
logger.error("Stack trace: %s", traceback.format_exc())
raise
def keyword_search(self, query, top_k=5, filters=None):
# Vertex AI hybrid search requires sparse embeddings configuration.
# Not yet supported - requires HybridQuery with sparse encoder setup.
return None
def delete(self, vector_id: Optional[str] = None, ids: Optional[List[str]] = None) -> bool:
"""
Delete vectors from the Matching Engine index.
+49
View File
@@ -223,6 +223,55 @@ class Weaviate(VectorStoreBase):
)
return results
def keyword_search(self, query, top_k=5, filters=None):
"""
Search for memories using BM25 keyword search.
Args:
query (str): Search query text.
top_k (int): Maximum number of results. Defaults to 5.
filters (dict, optional): Filters to apply (user_id, agent_id, run_id).
Returns:
List[OutputData]: Search results.
"""
collection = self.client.collections.get(str(self.collection_name))
filter_conditions = []
if filters:
for key, value in filters.items():
if value and key in ["user_id", "agent_id", "run_id"]:
filter_conditions.append(Filter.by_property(key).equal(value))
combined_filter = Filter.all_of(filter_conditions) if filter_conditions else None
response = collection.query.bm25(
query=query,
query_properties=["data"],
limit=top_k,
filters=combined_filter,
return_properties=["hash", "created_at", "updated_at", "user_id", "agent_id", "run_id", "data", "category"],
return_metadata=MetadataQuery(score=True),
)
results = []
for obj in response.objects:
payload = obj.properties.copy()
for id_field in ["run_id", "agent_id", "user_id"]:
if id_field in payload and payload[id_field] is None:
del payload[id_field]
payload["id"] = str(obj.uuid).split("'")[0]
if obj.metadata.score is not None:
score = obj.metadata.score
else:
score = 1.0
results.append(
OutputData(
id=str(obj.uuid),
score=score,
payload=payload,
)
)
return results
def delete(self, vector_id):
"""
Delete a vector by ID.