Merge pull request #101 from agentscope-ai/dev_0205

refactor(core): update config parsing and memory management system
This commit is contained in:
jinliyl 2026-02-06 17:48:05 +08:00 committed by GitHub
commit 44a5217fcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 306 additions and 150 deletions

3
.gitignore vendored
View file

@ -41,4 +41,5 @@ meta_memory/*
*.sqlite3
**/data/*.json
*.db
memories/*
memories/*
.reme/*

View file

@ -220,7 +220,7 @@ async def answer_question_with_memories(
result = await reme.llm.simple_request_for_json(
prompt=prompt,
model_name=model_name
model_name=model_name,
)
return result

View file

@ -20,7 +20,6 @@ user_message: |
* Entity-focused queries (extract and search specific names, places, events)
* Keyword-based searches (core concepts, topics)
* Related context queries (broader themes)
- Review all results before proceeding to next phase
### Phase 2(Optional): Temporal Search
**Tool**: `retrieve_memory` (with time filter)
@ -32,8 +31,7 @@ user_message: |
- After date: `20200101,99999999` (from 20200101 onwards)
**Approach**:
- Identify temporal constraints from the user question
- Refine Phase 1 queries with appropriate time filters
- Try multiple time ranges if initial searches yield no results
- Refine Phase 1 queries with 3-5 diverse appropriate different time filters
### Phase 3: Deep Dive into History
**Tool**: `read_history`
@ -57,6 +55,4 @@ user_message: |
- If you find sufficient information to answer the user's question, you may output directly without exhausting all search phases
- Exhaust all search strategies before concluding information doesn't exist
### Output any tangentially related findings, Format:
[timestamp] [memory/profile/history] [relevant content1]
[timestamp] [memory/profile/history] [relevant content2]
Output a summary of all retrieved memories, user profile, and history data.

View file

@ -15,65 +15,41 @@ user_message_s1: |
- Extract all important information comprehensively—do not miss critical details, but avoid any fabrications or unfounded assumptions
- The tool will retrieve similar historical memories via vector search to help you in Step 2
### Step 2: Update and Add Memories
Review each memory draft from Step 1 and compare it with the retrieved historical memories, then use `update_memory` to manage all memories in one call:
### Step 2: Add New Memories
Review each memory draft from Step 1 and compare it with the retrieved historical memories, then use `add_memory` to add new memories:
**For memories_to_update** (updating existing memories):
- For each memory to update, fill in the required parameters:
* `memory_id`: ID of the historical memory to update (from retrieved memories in Step 1)
* `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
* `memory_content`: updated or consolidated memory content
- Update memories when:
* The draft contains additional information that should be merged with existing memories
* Historical memories need to be corrected or refined based on new information
**For memories_to_add** (adding new memories):
- For each new memory, fill in the required parameters:
* `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
* `memory_content`: memory content
- Add memories when:
* The draft contains completely new information not present in historical memories
* The information cannot be merged into any existing memory
**General Guidelines:**
**Parameters for each memory:**
- `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
- `memory_content`: memory content
**When to skip:**
- **Skip** drafts if their content is already fully covered by historical memories (avoid redundancy)
- You can update and add memories in a single `update_memory` tool call
user_message_s2: |
You are a Profile Agent responsible for managing profiles about {memory_target}.
You are a User Profile Agent responsible for managing user profiles about {memory_target}.
## Latest Conversation
Format: round<index> [<timestamp>] <role/name>: <content>
{context}
## Current Profiles
## Current User Profiles
{profiles}
## Task
Analyze the Latest Conversation and use `update_profiles` to manage profiles (both updates and additions in one call):
Analyze the Latest Conversation and use `update_profiles` to manage user profiles (both updates and additions in one call):
**For profiles_to_update** (updating existing profiles):
**For profiles_to_update** (updating existing user profiles):
- For each profile to update, fill in the required parameters:
* `profile_id`: ID of the profile to update (from Current Profiles)
* `profile_id`: ID of the profile to update (from Current User Profiles)
* `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
* `profile_key`: profile key or category (e.g., 'name', 'age', 'occupation')
* `profile_value`: updated profile value (e.g., 'John Smith')
- Update profiles when:
* Information in the conversation conflicts with or supersedes existing profiles
* Profiles need to be consolidated or merged with new information
* Existing profile values need to be corrected or refined
* `profile_key`: key (e.g., 'name', 'age', 'occupation')
* `profile_value`: value (e.g., 'John Smith')
**For profiles_to_add** (adding new profiles):
**For profiles_to_add** (adding new user profiles):
- For each new profile, fill in the required parameters:
* `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
* `profile_key`: profile key or category (e.g., 'name', 'age', 'occupation')
* `profile_value`: profile value (e.g., 'John Smith')
- Add profiles when:
* The information represents a new distinct profile not present in Current Profiles
* The profile key doesn't exist in Current Profiles
* The information cannot be merged into existing profiles
* `profile_key`: key (e.g., 'name', 'age', 'occupation')
* `profile_value`: value (e.g., 'John Smith')
**General Guidelines:**
- Use actual names from the conversation (e.g., "Bob") instead of generic references (e.g., "user")
- Extract all important information comprehensively—do not miss critical details, but avoid any fabrications or unfounded assumptions
- You can update and add profiles in a single tool call
- Avoid any fabrications or unfounded assumptions
- You can update and add user profiles in a single tool call

View file

@ -22,7 +22,7 @@ llm:
backend: openai
model_name: qwen3-30b-a3b-instruct-2507
request_interval: 1
temperature: 0.0001
# temperature: 0.0001
qwen3_max_instruct:
backend: openai

View file

@ -4,8 +4,10 @@ import asyncio
from .context import PromptHandler, ServiceContext
from .embedding import BaseEmbeddingModel
from .file_watcher import BaseFileWatcher
from .flow import BaseFlow
from .llm import BaseLLM
from .memory_storage import BaseMemoryStore
from .schema import Response
from .token_counter import BaseTokenCounter
from .utils import execute_stream_task, PydanticConfigParser
@ -27,7 +29,9 @@ class Application:
llm: dict | None = None,
embedding_model: dict | None = None,
vector_store: dict | None = None,
memory_store: dict | None = None,
token_counter: dict | None = None,
file_watcher: dict | None = None,
**kwargs,
):
self.service_context = ServiceContext(
@ -43,7 +47,9 @@ class Application:
llm=llm,
embedding_model=embedding_model,
vector_store=vector_store,
memory_store=memory_store,
token_counter=token_counter,
file_watcher=file_watcher,
**kwargs,
)
self.prompt_handler = PromptHandler(language=self.service_context.language)
@ -62,7 +68,9 @@ class Application:
llm: dict | None = None,
embedding_model: dict | None = None,
vector_store: dict | None = None,
memory_store: dict | None = None,
token_counter: dict | None = None,
file_watcher: dict | None = None,
**kwargs,
) -> "Application":
"""Create and start an Application instance asynchronously."""
@ -77,7 +85,9 @@ class Application:
llm=llm,
embedding_model=embedding_model,
vector_store=vector_store,
memory_store=memory_store,
token_counter=token_counter,
file_watcher=file_watcher,
**kwargs,
)
await instance.start()
@ -145,6 +155,16 @@ class Application:
"""Get the default vector store instance."""
return self.service_context.vector_stores.get("default")
@property
def memory_store(self) -> BaseMemoryStore:
"""Get the default memory store instance."""
return self.service_context.memory_stores.get("default")
@property
def file_watcher(self) -> BaseFileWatcher:
"""Get the default file watcher instance."""
return self.service_context.file_watchers.get("default")
@property
def token_counter(self) -> BaseTokenCounter:
"""Get the default token counter instance."""

View file

@ -15,10 +15,11 @@ if TYPE_CHECKING:
from ..llm import BaseLLM
from ..embedding import BaseEmbeddingModel
from ..vector_store import BaseVectorStore
from ..memory_storage import BaseMemoryStore
from ..token_counter import BaseTokenCounter
from ..flow import BaseFlow
from ..service import BaseService
from ..memory_storage import BaseMemoryStore
from ..file_watcher import BaseFileWatcher
class ServiceContext(BaseContext):
@ -38,7 +39,9 @@ class ServiceContext(BaseContext):
llm: dict | None = None,
embedding_model: dict | None = None,
vector_store: dict | None = None,
memory_store: dict | None = None,
token_counter: dict | None = None,
file_watcher: dict | None = None,
**kwargs,
):
super().__init__()
@ -55,7 +58,9 @@ class ServiceContext(BaseContext):
llm=llm,
embedding_model=embedding_model,
vector_store=vector_store,
memory_store=memory_store,
token_counter=token_counter,
file_watcher=file_watcher,
**kwargs,
)
@ -79,6 +84,7 @@ class ServiceContext(BaseContext):
self.token_counters: dict[str, "BaseTokenCounter"] = {}
self.vector_stores: dict[str, "BaseVectorStore"] = {}
self.memory_stores: dict[str, "BaseMemoryStore"] = {}
self.file_watchers: dict[str, "BaseFileWatcher"] = {}
self.flows: dict[str, "BaseFlow"] = {}
self.mcp_server_mapping: dict[str, dict] = {}
@ -100,7 +106,9 @@ class ServiceContext(BaseContext):
llm: dict | None = None,
embedding_model: dict | None = None,
vector_store: dict | None = None,
memory_store: dict | None = None,
token_counter: dict | None = None,
file_watcher: dict | None = None,
**kwargs,
) -> ServiceConfig:
@ -118,9 +126,7 @@ class ServiceContext(BaseContext):
input_args.append(f"config={config_path}")
if args:
input_args.extend(args)
if kwargs:
input_args.extend([f"{k}={v}" for k, v in kwargs.items()])
service_config = parser.parse_args(*input_args)
service_config = parser.parse_args(*input_args, **kwargs)
service_config.enable_logo = enable_logo
if llm:
@ -131,6 +137,10 @@ class ServiceContext(BaseContext):
self._update_section_config(service_config, "token_counter", **token_counter)
if vector_store:
self._update_section_config(service_config, "vector_store", **vector_store)
if memory_store:
self._update_section_config(service_config, "memory_store", **memory_store)
if file_watcher:
self._update_section_config(service_config, "file_watcher", **file_watcher)
return service_config
@staticmethod
@ -200,10 +210,25 @@ class ServiceContext(BaseContext):
self.memory_stores[name] = R.memory_store[config.backend](
store_name=config.store_name,
embedding_model=self.embedding_models[config.embedding_model],
fts_enabled=config.fts_enabled,
snippet_max_chars=config.snippet_max_chars,
**config.model_extra,
)
await self.memory_stores[name].start()
for name, config in self.service_config.file_watcher.items():
self.file_watchers[name] = R.file_watcher[config.backend](
watch_paths=config.watch_paths,
suffix_filters=config.suffix_filters,
recursive=config.recursive,
debounce=config.debounce,
chunk_tokens=config.chunk_tokens,
chunk_overlap=config.chunk_overlap,
memory_store=self.memory_stores[config.memory_store],
**config.model_extra,
)
await self.file_watchers[name].start()
if self.service_config.mcp_servers:
await self.prepare_mcp_servers()

View file

@ -25,21 +25,25 @@ class BaseFileWatcher:
def __init__(
self,
watch_paths: list[str] | str,
suffix_filters: list[str] | None = None,
recursive: bool = False,
debounce: int = 500, # Millisecond debounce
suffix_filters: list[str] | None = None,
callback: Callable[[set[tuple[Change, str]]], None | Coroutine[Any, Any, None]] | None = None,
chunk_tokens: int = 400,
chunk_overlap: int = 80,
memory_store: BaseMemoryStore | None = None,
callback: Callable[[set[tuple[Change, str]]], None | Coroutine[Any, Any, None]] | None = None,
**kwargs,
):
"""
Initialize the file watcher"""
self.watch_paths: list[str] = [watch_paths] if isinstance(watch_paths, str) else watch_paths
self.suffix_filters: list[str] = suffix_filters or []
self.recursive: bool = recursive
self.debounce: int = debounce
self.suffix_filters: list[str] = suffix_filters or []
self.callback = callback
self.chunk_tokens: int = chunk_tokens
self.chunk_overlap: int = chunk_overlap
self.memory_store: BaseMemoryStore = memory_store
self.callback = callback
self.kwargs: dict = kwargs
self._stop_event = asyncio.Event()
@ -81,6 +85,10 @@ class BaseFileWatcher:
async def _watch_loop(self):
"""Core monitoring loop"""
if not self.watch_paths:
logger.warning("No watch paths specified")
return
async for changes in awatch(
*self.watch_paths,
watch_filter=self.watch_filter,

View file

@ -29,7 +29,7 @@ class DeltaFileWatcher(BaseFileWatcher):
- Delete affected old chunks and insert new chunks
"""
def __init__(self, chunk_tokens: int = 400, chunk_overlap: int = 80, overlap_lines: int = 2, **kwargs):
def __init__(self, overlap_lines: int = 2, **kwargs):
"""
Initialize delta file watcher.
@ -38,10 +38,7 @@ class DeltaFileWatcher(BaseFileWatcher):
chunk_overlap: Overlap tokens between chunks
"""
super().__init__(**kwargs)
self.chunk_tokens = chunk_tokens
self.chunk_overlap = chunk_overlap
self.overlap_lines = overlap_lines
self.dirty = False
@staticmethod

View file

@ -19,12 +19,10 @@ from ..utils import chunk_markdown, hash_text
class FullFileWatcher(BaseFileWatcher):
"""Full file watcher implementation for full synchronization"""
def __init__(self, chunk_tokens: int = 400, chunk_overlap: int = 80, **kwargs):
def __init__(self, **kwargs):
"""
Initialize full file watcher"""
super().__init__(**kwargs)
self.chunk_tokens = chunk_tokens
self.chunk_overlap = chunk_overlap
self.dirty = False
@staticmethod

View file

@ -2,7 +2,6 @@
from .file_metadata import FileMetadata
from .memory_chunk import MemoryChunk
from .memory_index_meta import MemoryIndexMeta
from .memory_node import MemoryNode
from .memory_search_result import MemorySearchResult
from .message import ContentBlock, Message, Trajectory
@ -34,7 +33,6 @@ __all__ = [
"LLMConfig",
"MCPConfig",
"MemoryChunk",
"MemoryIndexMeta",
"MemoryNode",
"MemorySearchResult",
"Message",

View file

@ -1,14 +0,0 @@
"""Memory index metadata schema."""
from typing import Optional
from pydantic import BaseModel, Field
class MemoryIndexMeta(BaseModel):
"""Metadata for memory index configuration."""
model: str = Field(..., description="Name of the embedding model")
chunk_tokens: int = Field(..., description="Maximum tokens per chunk")
chunk_overlap: int = Field(..., description="Number of overlapping tokens between chunks")
vector_dims: Optional[int] = Field(default=None, description="Vector embedding dimensions")

View file

@ -42,6 +42,7 @@ class MemoryNode(BaseModel):
time_modified: Last modification timestamp.
author: Author or source of this memory.
score: Relevance or importance score.
vector: Vector embedding of the memory content.
metadata: Additional metadata for extensibility.
"""
@ -58,6 +59,7 @@ class MemoryNode(BaseModel):
author: str = Field(default="", description="Author or source of the memory")
score: float = Field(default=0, description="Relevance or importance score")
vector: list[float] | None = Field(default=None, description="Vector embedding of the memory content")
metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
def _update_modified_time(self) -> "MemoryNode":
@ -145,6 +147,7 @@ class MemoryNode(BaseModel):
return VectorNode(
vector_id=self.memory_id,
content=vector_content,
vector=self.vector,
metadata=metadata,
)
@ -226,5 +229,6 @@ class MemoryNode(BaseModel):
time_modified=metadata.pop("time_modified", ""),
author=metadata.pop("author", ""),
score=metadata.pop("score", 0),
vector=node.vector,
metadata=metadata,
)

View file

@ -85,6 +85,8 @@ class MemoryStoreConfig(BaseModel):
backend: str = Field(default="sqlite")
store_name: str = Field(default="reme")
embedding_model: str = Field(default="default")
fts_enabled: bool = Field(default=True)
snippet_max_chars: int = Field(default=700)
class TokenCounterConfig(BaseModel):
@ -96,6 +98,20 @@ class TokenCounterConfig(BaseModel):
model_name: str = Field(default="")
class FileWatcherConfig(BaseModel):
"""Configuration for file watcher service."""
model_config = ConfigDict(extra="allow")
watch_paths: list[str] = Field(default_factory=list)
suffix_filters: list[str] = Field(default_factory=list)
recursive: bool = Field(default=False)
debounce: int = Field(default=500)
chunk_tokens: int = Field(default=400)
chunk_overlap: int = Field(default=80)
memory_store: str = Field(default="default")
class ServiceConfig(BaseModel):
"""Root configuration schema aggregating all service-level settings and components."""
@ -121,3 +137,4 @@ class ServiceConfig(BaseModel):
vector_store: dict[str, VectorStoreConfig] = Field(default_factory=dict)
memory_store: dict[str, MemoryStoreConfig] = Field(default_factory=dict)
token_counter: dict[str, TokenCounterConfig] = Field(default_factory=dict)
file_watcher: dict[str, FileWatcherConfig] = Field(default_factory=dict)

View file

@ -3,7 +3,7 @@
from .cache_handler import CacheHandler
from .case_converter import snake_to_camel, camel_to_snake
from .chunking_utils import chunk_markdown
from .common_utils import run_coro_safely, execute_stream_task, hash_text
from .common_utils import run_coro_safely, execute_stream_task, hash_text, cosine_similarity, batch_cosine_similarity
from .env_utils import load_env
from .execute_utils import exec_code, run_shell_command
from .http_client import HttpClient
@ -24,6 +24,8 @@ __all__ = [
"run_coro_safely",
"execute_stream_task",
"hash_text",
"cosine_similarity",
"batch_cosine_similarity",
"load_env",
"exec_code",
"run_shell_command",

View file

@ -5,6 +5,7 @@ import hashlib
from collections.abc import AsyncGenerator, Coroutine
from typing import Any
import numpy as np
from loguru import logger
from ..enumeration import ChunkEnum
@ -94,3 +95,54 @@ def hash_text(text: str) -> str:
Hexadecimal representation of the SHA-256 hash
"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""Calculate the cosine similarity between two numeric vectors."""
if len(vec1) != len(vec2):
raise ValueError(f"Vectors must have same length: {len(vec1)} != {len(vec2)}")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def batch_cosine_similarity(nd_array1: np.ndarray, nd_array2: np.ndarray) -> np.ndarray:
"""Calculate cosine similarity matrix between two batches of vectors.
Args:
nd_array1: Matrix of shape (batch_size1, emb_size)
nd_array2: Matrix of shape (batch_size2, emb_size)
Returns:
Similarity matrix of shape (batch_size1, batch_size2) where
result[i, j] is the cosine similarity between nd_array1[i] and nd_array2[j]
Raises:
ValueError: If embedding dimensions don't match
"""
if nd_array1.shape[1] != nd_array2.shape[1]:
raise ValueError(f"Embedding dimensions must match: {nd_array1.shape[1]} != {nd_array2.shape[1]}")
# Compute dot products: (batch_size1, emb_size) @ (emb_size, batch_size2)
# Result shape: (batch_size1, batch_size2)
dot_products = np.dot(nd_array1, nd_array2.T)
# Compute L2 norms for each vector
norms1 = np.linalg.norm(nd_array1, axis=1) # Shape: (batch_size1,)
norms2 = np.linalg.norm(nd_array2, axis=1) # Shape: (batch_size2,)
# Compute outer product of norms: (batch_size1, 1) @ (1, batch_size2)
# Result shape: (batch_size1, batch_size2)
norm_products = np.outer(norms1, norms2)
# Avoid division by zero
norm_products = np.where(norm_products == 0, 1e-10, norm_products)
# Compute cosine similarities
return dot_products / norm_products

View file

@ -81,4 +81,4 @@ def print_logo(service_config: "ServiceConfig"):
expand=False,
)
Console().print(Group("\n", panel, "\n"))
Console().print(Group("\n", panel, "\n"), justify="center")

View file

@ -145,19 +145,8 @@ class PydanticConfigParser:
raise FileNotFoundError(f"config={config_path} not found")
return config_path
def parse_args(self, *args: str) -> T:
"""Parse CLI arguments and load configs from YAML files.
Args:
*args: CLI arguments in format "key=value" or "config=file.yaml".
Returns:
Validated Pydantic config instance.
Raises:
ValueError: If no config file is specified.
FileNotFoundError: If specified config file does not exist.
"""
def parse_args(self, *args: str, **kwargs) -> T:
"""Parse CLI arguments and load configs from YAML files."""
configs_to_merge = [self.config_class().model_dump()]
# Separate config file path from other arguments
@ -184,6 +173,9 @@ class PydanticConfigParser:
if filter_args:
configs_to_merge.append(self.parse_dot_notation(filter_args))
if kwargs:
configs_to_merge.append(kwargs)
# Merge all configs and validate
self.config_dict = self.merge_configs(*configs_to_merge)
return self.config_class.model_validate(self.config_dict)

View file

@ -9,6 +9,7 @@ from loguru import logger
from .base_vector_store import BaseVectorStore
from ..embedding import BaseEmbeddingModel
from ..schema import VectorNode
from ..utils import cosine_similarity
class LocalVectorStore(BaseVectorStore):
@ -79,21 +80,6 @@ class LocalVectorStore(BaseVectorStore):
return nodes
@staticmethod
def _cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""Calculate the cosine similarity between two numeric vectors."""
if len(vec1) != len(vec2):
raise ValueError(f"Vectors must have same length: {len(vec1)} != {len(vec2)}")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
@staticmethod
def _match_filters(node: VectorNode, filters: dict | None) -> bool:
"""Check if a vector node matches the provided metadata filters.
@ -208,7 +194,7 @@ class LocalVectorStore(BaseVectorStore):
continue
try:
score = self._cosine_similarity(query_vector, node.vector)
score = cosine_similarity(query_vector, node.vector)
scored_nodes.append((node, score))
except ValueError as e:
logger.warning(f"Failed to calculate similarity for node {node.vector_id}: {e}")

View file

@ -37,7 +37,7 @@ from .tool.memory import (
AddHistory,
ReadAllProfiles,
UpdateProfilesV1,
UpdateMemoryV1,
AddMemory,
)
@ -223,7 +223,7 @@ class ReMe(Application):
enable_when_to_use=False,
enable_multiple=True,
),
UpdateMemoryV1(
AddMemory(
enable_thinking_params=enable_thinking_params,
enable_memory_target=False,
enable_when_to_use=False,

41
reme/reme_fs.py Normal file
View file

@ -0,0 +1,41 @@
"""ReMe File System"""
from .config import ReMeConfigParser
from .core import Application
class ReMeFs(Application):
"""ReMe File System"""
def __init__(
self,
*args,
llm_api_key: str | None = None,
llm_api_base: str | None = None,
embedding_api_key: str | None = None,
embedding_api_base: str | None = None,
enable_logo: bool = True,
llm: dict | None = None,
embedding_model: dict | None = None,
vector_store: dict | None = None,
token_counter: dict | None = None,
working_dir: str = "./agent",
**kwargs,
):
"""Initialize ReMe with config."""
super().__init__(
*args,
llm_api_key=llm_api_key,
llm_api_base=llm_api_base,
embedding_api_key=embedding_api_key,
embedding_api_base=embedding_api_base,
enable_logo=enable_logo,
parser=ReMeConfigParser,
llm=llm,
embedding_model=embedding_model,
vector_store=vector_store,
token_counter=token_counter,
**kwargs,
)
self.working_dir: str = working_dir

View file

@ -6,7 +6,7 @@ from loguru import logger
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import MemoryNode, ToolCall, Message
from ....core.utils import format_messages
from ....core.utils import format_messages, cosine_similarity
class ReadHistoryV2(BaseMemoryTool):
@ -103,7 +103,7 @@ class ReadHistoryV2(BaseMemoryTool):
for block in message_blocks:
block_text = format_messages(block, add_index=False)
block_embedding = await self.embedding_model.get_embedding(block_text)
similarity = self._calculate_cosine_similarity(query_embedding, block_embedding)
similarity = cosine_similarity(query_embedding, block_embedding)
block_similarities.append((similarity, block_text))
block_similarities.sort(key=lambda x: x[0], reverse=True)
@ -121,22 +121,3 @@ class ReadHistoryV2(BaseMemoryTool):
history_ids = [item["history_id"] for item in history_items]
logger.info(f"Successfully read {len(all_results)} history result(s): {history_ids}")
return output
@staticmethod
def _calculate_cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""Calculate cosine similarity between two vectors"""
if len(vec1) != len(vec2):
raise ValueError(f"Vectors must have same length: {len(vec1)} != {len(vec2)}")
try:
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
except Exception as e:
logger.error(f"Error calculating cosine similarity: {e}")
return 0.0

View file

@ -1,8 +1,12 @@
"""Memory handler"""
import numpy as np
from loguru import logger
from ....core.context import ServiceContext
from ....core.enumeration import MemoryType
from ....core.schema import MemoryNode
from ....core.utils.common_utils import batch_cosine_similarity
from ....core.vector_store import BaseVectorStore
@ -195,17 +199,77 @@ class MemoryHandler:
return list(seen_ids.values())
async def batch_search(self, searches: list[dict]) -> list[MemoryNode]:
async def batch_search(self, searches: list[dict], hybrid_threshold: float = None) -> list[MemoryNode]:
"""Execute multiple search queries in batch and return deduplicated results."""
seen_ids: dict[str, MemoryNode] = {}
if hybrid_threshold is not None:
# Extract query list from searches
query_list: list[str] = [search["query"] for search in searches]
for search_params in searches:
search_result = await self.search(**search_params)
for memory_node in search_result:
if memory_node.memory_id not in seen_ids:
seen_ids[memory_node.memory_id] = memory_node
# Step 1: Get embeddings for all queries using the embedding model
# Shape: [query_size X emb_size]
embedding_model = self.vector_store.embedding_model
query_embeddings_list: list[list[float]] = await embedding_model.get_embeddings(query_list)
query_embeddings = np.array(query_embeddings_list) # Convert to numpy array
return list(seen_ids.values())
# Step 2: Use self.search to get search results for each query and deduplicate
seen_ids: dict[str, MemoryNode] = {}
for search_params in searches:
search_result = await self.search(**search_params)
for memory_node in search_result:
if memory_node.memory_id not in seen_ids:
seen_ids[memory_node.memory_id] = memory_node
# Step 3: Get deduplicated results
deduplicated_results = list(seen_ids.values())
# If no results, return empty list
if not deduplicated_results:
return []
# Step 4: Extract embeddings from results
# Shape: [result_size X emb_size]
result_embeddings_list = [node.vector for node in deduplicated_results if node.vector]
# Filter out nodes without embeddings
results_with_embeddings = [node for node in deduplicated_results if node.vector]
if not result_embeddings_list:
logger.warning("No results with embeddings found")
return deduplicated_results
result_embeddings = np.array(result_embeddings_list)
# Step 5: Compute cosine similarity matrix
# Shape: [query_size X result_size]
similarity_matrix = batch_cosine_similarity(query_embeddings, result_embeddings)
# Step 6: Calculate average score for each result across all queries
# Shape: [result_size]
avg_scores = np.mean(similarity_matrix, axis=0)
# Step 7: Filter results by hybrid_threshold and sort by average score
filtered_results = []
for idx, node in enumerate(results_with_embeddings):
if avg_scores[idx] >= hybrid_threshold:
node.score = float(avg_scores[idx])
filtered_results.append(node)
# Sort by score in descending order
filtered_results.sort(key=lambda x: x.score, reverse=True)
return filtered_results
else:
# Original behavior: simple deduplication without hybrid scoring
seen_ids: dict[str, MemoryNode] = {}
for search_params in searches:
search_result = await self.search(**search_params)
for memory_node in search_result:
if memory_node.memory_id not in seen_ids:
seen_ids[memory_node.memory_id] = memory_node
return list(seen_ids.values())
async def list(
self,

View file

@ -11,11 +11,19 @@ from ....core.utils import deduplicate_memories
class RetrieveMemory(BaseMemoryTool):
"""Tool to retrieve memories using similarity search"""
def __init__(self, top_k: int = 20, enable_memory_target: bool = False, enable_time_filter: bool = False, **kwargs):
def __init__(
self,
top_k: int = 20,
enable_memory_target: bool = False,
enable_time_filter: bool = False,
hybrid_threshold: float | None = None,
**kwargs,
):
super().__init__(**kwargs)
self.top_k: int = top_k
self.enable_memory_target: bool = enable_memory_target
self.enable_time_filter: bool = enable_time_filter
self.hybrid_threshold: float | None = hybrid_threshold
def _build_query_parameters(self) -> dict:
"""Build the query parameters schema based on enabled features."""
@ -111,7 +119,11 @@ class RetrieveMemory(BaseMemoryTool):
memory_nodes: list[MemoryNode] = []
for target, searches in queries_by_target.items():
handler = MemoryHandler(target, self.service_context)
nodes = await handler.batch_search(searches)
if self.hybrid_threshold is not None:
nodes = await handler.batch_search(searches, self.hybrid_threshold)
nodes = nodes[: self.top_k]
else:
nodes = await handler.batch_search(searches)
memory_nodes.extend(nodes)
memory_nodes = deduplicate_memories(memory_nodes)

View file

@ -26,7 +26,7 @@ from loguru import logger
from reme.core.embedding import OpenAIEmbeddingModel
from reme.core.schema import VectorNode
from reme.core.utils import load_env
from reme.core.utils import load_env, cosine_similarity
from reme.core.vector_store import (
BaseVectorStore,
ChromaVectorStore,
@ -657,19 +657,19 @@ async def test_cosine_similarity(store_name: str):
vec3 = [1.0, 0.0, 0.0]
# Test perpendicular vectors (similarity = 0)
sim1 = LocalVectorStore._cosine_similarity(vec1, vec2) # pylint: disable=protected-access
sim1 = cosine_similarity(vec1, vec2) # pylint: disable=protected-access
logger.info(f"Similarity between perpendicular vectors: {sim1:.4f}")
assert abs(sim1) < 0.0001, "Perpendicular vectors should have similarity close to 0"
# Test identical vectors (similarity = 1)
sim2 = LocalVectorStore._cosine_similarity(vec1, vec3) # pylint: disable=protected-access
sim2 = cosine_similarity(vec1, vec3) # pylint: disable=protected-access
logger.info(f"Similarity between identical vectors: {sim2:.4f}")
assert abs(sim2 - 1.0) < 0.0001, "Identical vectors should have similarity close to 1"
# Test with real-world like vectors
vec4 = [0.5, 0.5, 0.5]
vec5 = [0.6, 0.4, 0.5]
sim3 = LocalVectorStore._cosine_similarity(vec4, vec5) # pylint: disable=protected-access
sim3 = cosine_similarity(vec4, vec5) # pylint: disable=protected-access
logger.info(f"Similarity between similar vectors: {sim3:.4f}")
assert sim3 > 0.9, "Similar vectors should have high similarity"