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:
co-authored by
Soumil Rathi
Saket Aryan
chaithanyak42
kartik-mem0
parent
57f944e18a
commit
a488e19044
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user