mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-05 08:06:15 +00:00
* feat(benchmark): 添加 LongMemEval 评估功能和内存检索器 - 实现了 LongMemEval 数据集的评估管道 - 添加了 PersonalLongmemevalRetriever 和 PersonalLongmemevalSummarizer - 创建了详细的统计分析工具 compute_stats.py - 实现了完整的答案判断和准确性计算功能 - 集成了 ReMe 内存操作和查询功能 - 添加了性能指标和时间统计功能 * feat(benchmark): 添加内存准确性和完整性评估功能 - 实现了 evaluation_for_memory_accuracy 函数用于评估提取内存的准确性 - 实现了 evaluation_for_memory_integrity 函数用于评估内存完整性 - 创建了 MemoryIntegrityEvaluator 类来评估内存点覆盖情况 - 创建了 MemoryAccuracyEvaluator 类来评估提取内存的准确性 - 添加了 compute_memory_integrity_metrics 和 compute_memory_accuracy_metrics 统计函数 - 在 MetricsAggregator 中集成内存完整性和准确性指标计算 - 更新了命令行参数默认值:top_k 改为 10,batch_size 改为 16 - 重构了个人记忆汇总器中的工具循环逻辑 - 更新了评估提示词模板以支持内存质量评估 - 添加了新的检索配置和模型设置 * feat(memory): 添加个人记忆摘要器配置文件 - 新增 personal_halumem_summarizer_adddraft.yaml 配置文件 - 新增 personal_halumem_summarizer_original_backup.yaml 备份配置文件 - 实现记忆架构师系统提示和用户消息模板 - 实现个人资料代理系统提示和用户消息模板 - 支持生物特征和行为模式记忆存储 - 实现记忆去重和合并功能 - 支持用户个人资料的动态更新和删除操作 * feat(memory): 添加个人记忆摘要器配置文件 - 新增 personal_halumem_summarizer_adddraft.yaml 配置文件 - 新增 personal_halumem_summarizer_original_backup.yaml 备份配置文件 - 实现记忆架构师系统提示和用户消息模板 - 实现个人资料代理系统提示和用户消息模板 - 支持生物特征和行为模式记忆存储 - 实现记忆去重和合并功能 - 支持用户个人资料的动态更新和删除操作 * docs(readme): 添加 ReMe Memory Agent 详细介绍文档 - 创建英文版 README.md 包含核心概念、架构设计和使用指南 - 创建中文版 README_ZH.md 提供完整的本地化文档 - 介绍 Agent 驱动的记忆管理理念和层次化检索机制 - 详述项目架构包括 ReMeSummarizer 和 ReMeRetriever 组件 - 提供快速开始示例和程序化内存操作方法 - 展示 LoCoMo、LongMemEval、HaluMem 基准测试结果 - 包含完整的项目结构说明和配置要求 * chore(config): 移除配置文件中的API密钥 - 从配置文件中删除FLOW_LLM_API_KEY环境变量设置 - 移除相关的API密钥配置项 - 更新配置文档以反映新的安全实践 - 确保敏感信息不再硬编码在配置文件中 - 添加注释说明如何通过环境变量方式配置API密钥 * fix(benchmark): 修复模型调用和配置参数问题 - 修正了reme.get_llm方法的参数传递,移除冗余的name参数 - 添加了qwen3-max模型的配置支持 - 调整了默认并发数从16降至4以提高稳定性 - 修改算法版本默认值从longmemeval和v1统一为default - 减少每类样本数量默认值从16至2以优化测试效率 * feat(benchmark): 添加记忆准确性和完整性评估功能 - 修改了 simple_request_for_json 调用以支持模型名称参数 - 新增 evaluation_for_memory_accuracy 函数用于评估记忆准确性 - 新增 evaluation_for_memory_integrity 函数用于评估记忆完整性 - 将默认模型名称从 qwen3-max 更改为 None - 更新提取记忆逻辑以过滤 time_int 和 when_to_use 字段 - 新增 MemoryIntegrityEvaluator 类用于评估记忆完整性 - 新增 MemoryAccuracyEvaluator 类用于评估记忆准确性 - 添加 compute_memory_integrity_metrics 方法计算记忆完整性指标 - 添加 compute_memory_accuracy_metrics 方法计算记忆准确性指标 - 配置多种新 LLM 模型包括 qwen-plus-t、qwen-max-t、gpt-4o-mini 等 - 初始化完整性评估器和准确性评估器实例 - 在会话数据中添加记忆完整性和准确性评估结果 - 收集记忆完整性记录和准确性记录用于统计 - 在最终结果中包含记忆完整性和准确性指标 - 更新摘要打印方法显示记忆完整性和准确性统计信息 - 更新默认评估模型为 gpt-4o-mini-2024-07-18 * docs(readme): 删除 ReMe Memory Agent 的中英文文档 - 移除英文版 README.md 中关于 ReMe Memory Agent 的详细介绍 - 删除中文版 README_ZH.md 中关于 ReMe Memory Agent 的完整文档 - 清理了包括架构图、功能特性、快速开始和实验数据在内的所有文档内容
921 lines
34 KiB
Python
921 lines
34 KiB
Python
"""
|
|
LongMemEval Benchmark Evaluator for ReMe - Retrieve Only
|
|
|
|
A simplified evaluation pipeline that only runs the retrieve and judge phases:
|
|
1. Loads LongMemEval benchmark data
|
|
2. Skips memory summarization (assumes memories are already in vector store)
|
|
3. Uses questions to query memory and generate answers
|
|
4. Uses LLM to judge answer correctness
|
|
5. Generates comprehensive metrics
|
|
|
|
This is useful for debugging/tuning the retrieve phase without re-running summary.
|
|
|
|
Usage:
|
|
python benchmark/longmemeval/eval_longmemeval_reme_retrieve.py \
|
|
--data_path dataset/longmemeval/longmemeval_s_cleaned.json \
|
|
--top_k 20 --start_index 0 --end_index 10
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from loguru import logger
|
|
|
|
from reme.reme import ReMe
|
|
|
|
|
|
# ==================== Configuration ====================
|
|
|
|
|
|
@dataclass
|
|
class RetrieveEvalConfig:
|
|
"""Evaluation configuration parameters for retrieve-only mode."""
|
|
|
|
data_path: str
|
|
top_k: int = 10
|
|
start_index: int = 0
|
|
end_index: Optional[int] = None
|
|
max_concurrency: int = 1
|
|
output_dir: str = "cache/bench_results/longmemeval_reme_retrieve"
|
|
reme_model_name: str = "qwen-flash"
|
|
eval_model_name: str = "qwen3-max"
|
|
algo_version: str = "v1"
|
|
samples_per_type: int = -1 # Number of samples per question type, -1 for all
|
|
enable_thinking_params: bool = False
|
|
# Optional: path to previous summary results to reload memories
|
|
summary_results_dir: Optional[str] = None
|
|
|
|
|
|
# ==================== Answer Judge Prompts ====================
|
|
|
|
|
|
def get_anscheck_prompt(task: str, question: str, answer: str, response: str, abstention: bool = False) -> str:
|
|
"""Generate the answer checking prompt based on question type.
|
|
|
|
Args:
|
|
task: Question type, e.g. 'single-session-user', 'multi-session', 'temporal-reasoning'
|
|
question: The question content
|
|
answer: The reference answer
|
|
response: The model's response
|
|
abstention: Whether this is an unanswerable question
|
|
|
|
Returns:
|
|
Prompt for judging answer correctness
|
|
"""
|
|
if not abstention:
|
|
if task in ["single-session-user", "single-session-assistant", "multi-session"]:
|
|
template = (
|
|
"I will give you a question, a correct answer, and a response from a model. Please answer yes i"
|
|
"f the response contains the correct answer. Otherwise, answer no. If the response is equival"
|
|
"ent to the correct answer or contains all the intermediate steps to get the correct answer, "
|
|
"you should also answer yes. If the response only contains a subset of the information required"
|
|
" by the answer, answer no. \n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs"
|
|
" the model response correct? Answer yes or no only."
|
|
)
|
|
prompt = template.format(question, answer, response)
|
|
elif task == "temporal-reasoning":
|
|
template = (
|
|
"I will give you a question, a correct answer, and a response from a model. Please answer yes"
|
|
" if the response contains the correct answer. Otherwise, answer no. If the response is equiv"
|
|
"alent to the correct answer or contains all the intermediate steps to get the correct answer"
|
|
", you should also answer yes. If the response only contains a subset of the information requ"
|
|
"ired by the answer, answer no. In addition, do not penalize off-by-one errors for the numbe"
|
|
"r of days. If the question asks for the number of days/weeks/months, etc., and the model ma"
|
|
"kes off-by-one errors (e.g., predicting 19 days when the answer is 18), the model's respon"
|
|
"se is still correct. \n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs th"
|
|
"e model response correct? Answer yes or no only."
|
|
)
|
|
prompt = template.format(question, answer, response)
|
|
elif task == "knowledge-update":
|
|
template = (
|
|
"I will give you a question, a correct answer, and a response from a model. Please answer yes "
|
|
"if the response contains the correct answer. Otherwise, answer no. If the response contains "
|
|
"some previous information along with an updated answer, the response should be considered "
|
|
"as correct as long as the updated answer is the required answer.\n\nQuestion: {}\n\nCorrec"
|
|
"t Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only."
|
|
)
|
|
prompt = template.format(question, answer, response)
|
|
elif task == "single-session-preference":
|
|
template = (
|
|
"I will give you a question, a rubric for desired personalized response, and a response from a"
|
|
" model. Please answer yes if the response satisfies the desired response. Otherwise, answer"
|
|
" no. The model does not need to reflect all the points in the rubric. The response is corr"
|
|
"ect as long as it recalls and utilizes the user's personal information correctly.\n\nQuest"
|
|
"ion: {}\n\nRubric: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes o"
|
|
"r no only."
|
|
)
|
|
prompt = template.format(question, answer, response)
|
|
else:
|
|
# Default template
|
|
template = (
|
|
"I will give you a question, a correct answer, and a response from a model. Please answer y"
|
|
"es if the response contains the correct answer. Otherwise, answer no. If the response is "
|
|
"equivalent to the correct answer or contains all the intermediate steps to get the correc"
|
|
"t answer, you should also answer yes. If the response only contains a subset of the infor"
|
|
"mation required by the answer, answer no. \n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel"
|
|
" Response: {}\n\nIs the model response correct? Answer yes or no only."
|
|
)
|
|
prompt = template.format(question, answer, response)
|
|
|
|
else:
|
|
template = (
|
|
"I will give you an unanswerable question, an explanation, and a response from a model. Please "
|
|
"answer yes if the model correctly identifies the question as unanswerable. The model could say "
|
|
"that the information is incomplete, or some other information is given but the asked informati"
|
|
"on is not.\n\nQuestion: {}\n\nExplanation: {}\n\nModel Response: {}\n\nDoes the model correct"
|
|
"ly identify the question as unanswerable? Answer yes or no only."
|
|
)
|
|
prompt = template.format(question, answer, response)
|
|
return prompt
|
|
|
|
|
|
# ==================== Utilities ====================
|
|
|
|
|
|
class DataLoader:
|
|
"""Handles loading and parsing of LongMemEval data."""
|
|
|
|
@staticmethod
|
|
def load_json(file_path: str) -> list[dict]:
|
|
"""Load all entries from a JSON file."""
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
@staticmethod
|
|
def filter_by_type(data: list[dict], samples_per_type: int = -1) -> list[tuple[int, dict]]:
|
|
"""Filter data by question type with specified number of samples per type.
|
|
|
|
Args:
|
|
data: List of question entries
|
|
samples_per_type: Number of samples per type, -1 for all
|
|
|
|
Returns:
|
|
List of tuples (original_index, entry) for selected samples
|
|
"""
|
|
if samples_per_type == -1:
|
|
# Return all with original indices
|
|
return list(enumerate(data))
|
|
|
|
# Group by question type
|
|
type_groups: dict[str, list[tuple[int, dict]]] = {}
|
|
for i, entry in enumerate(data):
|
|
qtype = entry.get("question_type", "unknown")
|
|
if qtype not in type_groups:
|
|
type_groups[qtype] = []
|
|
type_groups[qtype].append((i, entry))
|
|
|
|
# Select samples from each type
|
|
selected = []
|
|
for qtype, entries in type_groups.items():
|
|
count = min(samples_per_type, len(entries))
|
|
selected.extend(entries[:count])
|
|
logger.info(f" {qtype}: selected {count}/{len(entries)} samples")
|
|
|
|
# Sort by original index to maintain order
|
|
selected.sort(key=lambda x: x[0])
|
|
return selected
|
|
|
|
|
|
class FileManager:
|
|
"""Manages file I/O operations."""
|
|
|
|
def __init__(self, base_dir: str):
|
|
self.base_dir = Path(base_dir)
|
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def save_question_result(self, idx: int, question_id: str, data: dict):
|
|
"""Save result for a single question."""
|
|
file_path = self.base_dir / f"question_{idx:04d}_{question_id}.json"
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
logger.info(f"✅ Saved question result to {file_path}")
|
|
|
|
def load_question_result(self, idx: int, question_id: str) -> Optional[dict]:
|
|
"""Load result for a single question if exists."""
|
|
file_path = self.base_dir / f"question_{idx:04d}_{question_id}.json"
|
|
if not file_path.exists():
|
|
return None
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
def save_summary(self, results: list[dict]):
|
|
"""Save summary of all results."""
|
|
file_path = self.base_dir / "summary.json"
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(results, f, indent=4, ensure_ascii=False)
|
|
logger.info(f"✅ Saved summary to {file_path}")
|
|
|
|
|
|
# ==================== Evaluation Functions ====================
|
|
|
|
|
|
async def answer_question_with_memories(
|
|
reme: ReMe,
|
|
question: str,
|
|
memories: str,
|
|
user_id: str = None,
|
|
model_name: str = "qwen3-max",
|
|
):
|
|
"""
|
|
Answer a question using retrieved memories with PROMPT_MEMZERO_JSON template.
|
|
|
|
Args:
|
|
reme: ReMe instance with default_llm and prompt_handler
|
|
question: The question to answer
|
|
memories: The retrieved memories (formatted as context)
|
|
user_id: Optional user ID for context formatting
|
|
model_name: Model name to use for LLM request
|
|
|
|
Returns:
|
|
dict with 'reasoning' and 'answer' fields
|
|
"""
|
|
# Format context with memories
|
|
if user_id:
|
|
context = reme.prompt_handler.prompt_format(
|
|
"TEMPLATE_MEMOS",
|
|
user_id=user_id,
|
|
memories=memories,
|
|
)
|
|
else:
|
|
context = f"Memories:\n{memories}"
|
|
|
|
# Use PROMPT_MEMZERO_JSON template for structured JSON response
|
|
prompt = reme.prompt_handler.prompt_format(
|
|
"PROMPT_MEMZERO_JSON",
|
|
context=context,
|
|
question=question,
|
|
)
|
|
|
|
result = await reme.get_llm(name=model_name).simple_request_for_json(
|
|
prompt=prompt,
|
|
model_name=None,
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
# ==================== Memory Operations ====================
|
|
|
|
|
|
class RetrieveProcessor:
|
|
"""Handles ReMe memory retrieve operations only."""
|
|
|
|
def __init__(
|
|
self,
|
|
reme: ReMe,
|
|
reme_model_name: str = "qwen-flash",
|
|
eval_model_name: str = "qwen3-max",
|
|
algo_version: str = "v1",
|
|
enable_thinking_params: bool = False,
|
|
):
|
|
self.reme = reme
|
|
self.reme_model_name = reme_model_name
|
|
self.eval_model_name = eval_model_name
|
|
self.algo_version = algo_version
|
|
self.enable_thinking_params = enable_thinking_params
|
|
|
|
async def search_memory(
|
|
self,
|
|
query: str,
|
|
user_id: str,
|
|
top_k: int = 20,
|
|
) -> tuple[dict, list, float]:
|
|
"""
|
|
Search memory using ReMe and return structured answer with reasoning.
|
|
|
|
Returns:
|
|
tuple: (answer_dict, agent_messages, duration_ms)
|
|
answer_dict contains: {"reasoning": str, "answer": str, "memories": str}
|
|
"""
|
|
start = time.time()
|
|
|
|
# Retrieve memories from ReMe using new API
|
|
result = await self.reme.retrieve_memory(
|
|
llm_config_name="qwen3-max",
|
|
query=query,
|
|
retrieve_top_k=top_k,
|
|
user_name=user_id,
|
|
version=self.algo_version,
|
|
return_dict=True,
|
|
enable_time_filter=True,
|
|
enable_thinking_params=True,
|
|
)
|
|
|
|
# Extract memories from response
|
|
memories = result["answer"]
|
|
agent_messages = [x.simple_dump(enable_argument_dict=True) for x in result["messages"]]
|
|
retrieved_nodes = [x.model_dump(exclude_none=True) for x in result["retrieved_nodes"]]
|
|
|
|
# Use LLM to generate structured answer from memories
|
|
answer_result = await answer_question_with_memories(
|
|
reme=self.reme,
|
|
question=query,
|
|
memories=memories,
|
|
user_id=user_id,
|
|
model_name=self.eval_model_name,
|
|
)
|
|
|
|
# Add original memories to the result
|
|
answer_result["memories"] = memories
|
|
answer_result["retrieved_nodes"] = retrieved_nodes
|
|
|
|
duration_ms = (time.time() - start) * 1000
|
|
return answer_result, agent_messages, duration_ms
|
|
|
|
|
|
# ==================== Answer Judge ====================
|
|
|
|
|
|
class LongMemEvalJudge:
|
|
"""LongMemEval answer judge using LLM."""
|
|
|
|
def __init__(self, reme: ReMe, model: str = "qwen3-max"):
|
|
self.reme = reme
|
|
self.model = model
|
|
|
|
async def judge_answer(
|
|
self,
|
|
question_type: str,
|
|
question: str,
|
|
answer: str,
|
|
response: str,
|
|
abstention: bool = False,
|
|
) -> dict:
|
|
"""
|
|
Judge if the model's response is correct.
|
|
|
|
Returns:
|
|
dict with is_correct, llm_response, and judge_prompt
|
|
"""
|
|
prompt = get_anscheck_prompt(question_type, question, answer, response, abstention)
|
|
|
|
try:
|
|
llm_response = await self.reme.get_llm("default").simple_request(
|
|
prompt=prompt,
|
|
model_name=self.model,
|
|
)
|
|
llm_response_lower = llm_response.strip().lower()
|
|
is_correct = llm_response_lower.startswith("yes")
|
|
|
|
return {
|
|
"is_correct": is_correct,
|
|
"llm_response": llm_response,
|
|
"judge_prompt": prompt,
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"is_correct": None,
|
|
"error": str(e),
|
|
"judge_prompt": prompt,
|
|
}
|
|
|
|
|
|
# ==================== Metrics ====================
|
|
|
|
|
|
class MetricsAggregator:
|
|
"""Aggregates evaluation metrics for LongMemEval."""
|
|
|
|
@staticmethod
|
|
def compute_metrics(results: list[dict]) -> dict[str, Any]:
|
|
"""Compute overall and per-type metrics."""
|
|
total = len(results)
|
|
correct = sum(1 for r in results if r.get("judgment", {}).get("is_correct") is True)
|
|
incorrect = sum(1 for r in results if r.get("judgment", {}).get("is_correct") is False)
|
|
error = total - correct - incorrect
|
|
|
|
metrics = {
|
|
"total": total,
|
|
"correct": correct,
|
|
"incorrect": incorrect,
|
|
"error": error,
|
|
"accuracy": correct / total if total > 0 else 0,
|
|
"accuracy_valid": correct / (correct + incorrect) if (correct + incorrect) > 0 else 0,
|
|
}
|
|
|
|
# Per question type statistics
|
|
type_stats = {}
|
|
for r in results:
|
|
qtype = r.get("question_type", "unknown")
|
|
if qtype not in type_stats:
|
|
type_stats[qtype] = {"total": 0, "correct": 0, "incorrect": 0}
|
|
type_stats[qtype]["total"] += 1
|
|
if r.get("judgment", {}).get("is_correct") is True:
|
|
type_stats[qtype]["correct"] += 1
|
|
elif r.get("judgment", {}).get("is_correct") is False:
|
|
type_stats[qtype]["incorrect"] += 1
|
|
|
|
metrics["by_question_type"] = {
|
|
qtype: {
|
|
**stats,
|
|
"accuracy": stats["correct"] / stats["total"] if stats["total"] > 0 else 0,
|
|
"accuracy_valid": (
|
|
stats["correct"] / (stats["correct"] + stats["incorrect"])
|
|
if (stats["correct"] + stats["incorrect"]) > 0
|
|
else 0
|
|
),
|
|
}
|
|
for qtype, stats in type_stats.items()
|
|
}
|
|
|
|
return metrics
|
|
|
|
@staticmethod
|
|
def compute_timing_stats(results: list[dict]) -> dict[str, Any]:
|
|
"""Compute timing statistics."""
|
|
retrieve_times = []
|
|
|
|
for r in results:
|
|
retrieve_ms = r.get("retrieve_duration_ms", 0)
|
|
if retrieve_ms > 0:
|
|
retrieve_times.append(retrieve_ms)
|
|
|
|
def compute_stats(times: list[float]) -> dict:
|
|
if not times:
|
|
return {"count": 0, "total_ms": 0, "avg_ms": 0, "min_ms": 0, "max_ms": 0}
|
|
|
|
return {
|
|
"count": len(times),
|
|
"total_ms": sum(times),
|
|
"total_min": sum(times) / 1000 / 60,
|
|
"avg_ms": sum(times) / len(times),
|
|
"min_ms": min(times),
|
|
"max_ms": max(times),
|
|
}
|
|
|
|
return {
|
|
"retrieve": compute_stats(retrieve_times),
|
|
"total_time_min": sum(retrieve_times) / 1000 / 60,
|
|
}
|
|
|
|
|
|
# ==================== Main Pipeline ====================
|
|
|
|
|
|
class LongMemEvalRetrieveEvaluator:
|
|
"""Retrieve-only evaluator for LongMemEval benchmark using ReMe."""
|
|
|
|
def __init__(self, config: RetrieveEvalConfig):
|
|
self.config = config
|
|
self.reme = ReMe(
|
|
default_llm_config={
|
|
"model_name": self.config.reme_model_name,
|
|
},
|
|
llms={
|
|
"qwen-plus-think": {
|
|
"backend": "openai",
|
|
"model_name": "qwen-plus",
|
|
"extra_body": {
|
|
"enable_thinking": True,
|
|
},
|
|
},
|
|
"qwen3-max-think": {
|
|
"backend": "openai",
|
|
"model_name": "qwen3-max",
|
|
"extra_body": {
|
|
"enable_thinking": True,
|
|
},
|
|
},
|
|
"qwen3-max": {
|
|
"backend": "openai",
|
|
"model_name": "qwen3-max",
|
|
"extra_body": {
|
|
"enable_thinking": False,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
# Load evaluation prompts into ReMe's prompt handler
|
|
prompts_yaml_path = Path(__file__).parent / "eval_reme.yaml"
|
|
self.reme.prompt_handler.load_prompt_by_file(prompts_yaml_path)
|
|
|
|
self.file_manager = FileManager(config.output_dir)
|
|
self.retrieve_processor = RetrieveProcessor(
|
|
self.reme,
|
|
config.reme_model_name,
|
|
config.eval_model_name,
|
|
config.algo_version,
|
|
config.enable_thinking_params,
|
|
)
|
|
self.judge = LongMemEvalJudge(self.reme, config.eval_model_name)
|
|
self.data_loader = DataLoader()
|
|
|
|
async def __aenter__(self):
|
|
"""Async context manager entry."""
|
|
await self.reme.start()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
"""Async context manager exit with cleanup."""
|
|
await self.reme.close()
|
|
return False
|
|
|
|
async def process_question_entry(self, entry: dict, idx: int) -> dict:
|
|
"""Process a single question entry (retrieve + judge only).
|
|
|
|
Args:
|
|
entry: A question entry from LongMemEval dataset
|
|
idx: Index of the question
|
|
|
|
Returns:
|
|
Result dictionary
|
|
"""
|
|
question_id = entry["question_id"]
|
|
question = entry["question"]
|
|
answer = entry["answer"]
|
|
question_type = entry["question_type"]
|
|
question_date = entry.get("question_date", "")
|
|
haystack_dates = entry["haystack_dates"]
|
|
haystack_session_ids = entry["haystack_session_ids"]
|
|
haystack_sessions = entry["haystack_sessions"]
|
|
|
|
# Use question_id as user_id for isolation (same as full eval)
|
|
user_id = f"longmemeval_{question_id}"
|
|
|
|
logger.info(f"\n{'='*60}")
|
|
logger.info(f"Question ID: {question_id}")
|
|
logger.info(f"Question Type: {question_type}")
|
|
logger.info(f"Question: {question}")
|
|
logger.info(f"Question_date: {question_date}")
|
|
logger.info(f"Answer: {answer}")
|
|
logger.info(f"Number of sessions: {len(haystack_sessions)}")
|
|
logger.info(f"{'='*60}")
|
|
|
|
# Skip summary phase - directly search memory and answer question
|
|
logger.info(" Retrieving and answering question using ReMe...")
|
|
answer_dict, retrieve_messages, retrieve_duration_ms = await self.retrieve_processor.search_memory(
|
|
query=f"[Question_date: {question_date} | Question_type: {question_type}] " + question,
|
|
user_id=user_id,
|
|
top_k=self.config.top_k,
|
|
)
|
|
|
|
# Extract answer and reasoning from the structured response
|
|
model_response = answer_dict.get("answer", "")
|
|
model_reasoning = answer_dict.get("reasoning", "")
|
|
retrieved_memories = answer_dict.get("memories", "")
|
|
retrieved_nodes = answer_dict.get("retrieved_nodes", [])
|
|
|
|
# Judge answer correctness
|
|
logger.info(" Judging answer correctness...")
|
|
judgment = await self.judge.judge_answer(
|
|
question_type=question_type,
|
|
question=question,
|
|
answer=answer,
|
|
response=model_response,
|
|
)
|
|
|
|
is_correct = judgment.get("is_correct")
|
|
logger.info(
|
|
f" → Answer judgment: {'Correct' if is_correct else 'Incorrect' if is_correct is False else 'Error'}",
|
|
)
|
|
|
|
result = {
|
|
"question_id": question_id,
|
|
"question_type": question_type,
|
|
"question": question,
|
|
"answer": answer,
|
|
"question_date": question_date,
|
|
"haystack_dates": haystack_dates,
|
|
"haystack_session_ids": haystack_session_ids,
|
|
"num_sessions": len(haystack_sessions),
|
|
"model_response": model_response,
|
|
"model_reasoning": model_reasoning,
|
|
"retrieved_memories": retrieved_memories,
|
|
"retrieved_nodes": retrieved_nodes,
|
|
"judgment": judgment,
|
|
"retrieve_duration_ms": retrieve_duration_ms,
|
|
"retrieve_messages": retrieve_messages,
|
|
}
|
|
|
|
# Save individual result
|
|
self.file_manager.save_question_result(idx, question_id, result)
|
|
|
|
logger.info(f" Question {question_id} - Completed")
|
|
|
|
return result
|
|
|
|
async def run_evaluation(self):
|
|
"""Run the retrieve-only evaluation pipeline with parallel processing."""
|
|
start_time = time.time()
|
|
|
|
# NOTE: Do NOT clear vector store - we assume memories are already there from previous summary run
|
|
|
|
# Load dataset
|
|
logger.info(f"Loading dataset from: {self.config.data_path}")
|
|
all_data = self.data_loader.load_json(self.config.data_path)
|
|
logger.info(f"Total questions in dataset: {len(all_data)}")
|
|
|
|
# Filter by question type
|
|
logger.info(f"Filtering by type (samples_per_type={self.config.samples_per_type}):")
|
|
filtered_data = self.data_loader.filter_by_type(all_data, self.config.samples_per_type)
|
|
logger.info(f"Selected {len(filtered_data)} questions after filtering")
|
|
|
|
# Apply start_index and end_index on filtered data
|
|
end_index = self.config.end_index or len(filtered_data)
|
|
start_index = self.config.start_index
|
|
end_index = min(end_index, len(filtered_data))
|
|
|
|
# Get the slice we want to process
|
|
data_to_process = filtered_data[start_index:end_index]
|
|
total_questions = len(data_to_process)
|
|
|
|
logger.info(f"Processing {total_questions} questions (index {start_index} to {end_index - 1})")
|
|
|
|
print("\n" + "=" * 80)
|
|
print("LONGMEMEVAL EVALUATION - REME (RETRIEVE ONLY)")
|
|
print(f"Samples per type: {self.config.samples_per_type} (-1 = all)")
|
|
print(f"Questions to process: {total_questions} | Top-K: {self.config.top_k}")
|
|
print(f"Max Concurrency: {self.config.max_concurrency}")
|
|
print(f"ReMe Model: {self.config.reme_model_name} | Eval Model: {self.config.eval_model_name}")
|
|
print(f"Algo Version: {self.config.algo_version}")
|
|
print("⚠️ NOTE: Assumes memories are already in vector store from previous summary run")
|
|
print("=" * 80 + "\n")
|
|
|
|
# Use semaphore to control concurrency
|
|
semaphore = asyncio.Semaphore(self.config.max_concurrency)
|
|
|
|
async def process_with_semaphore(idx: int, original_idx: int, entry: dict) -> Optional[dict]:
|
|
"""Process a question with semaphore for concurrency control."""
|
|
async with semaphore:
|
|
question_id = entry["question_id"]
|
|
|
|
# Check cache first (use original index for cache file naming)
|
|
cached_result = self.file_manager.load_question_result(original_idx, question_id)
|
|
if cached_result:
|
|
print(f"⚡ [{idx}/{total_questions}] Skipping question {original_idx} (cached)")
|
|
return cached_result
|
|
|
|
print(f"\n{'#'*60}")
|
|
print(f"### [{idx}/{total_questions}] Processing Question {original_idx} ###")
|
|
print(f"{'#'*60}")
|
|
|
|
try:
|
|
result = await self.process_question_entry(entry, original_idx)
|
|
print(f"✅ [{idx}/{total_questions}] Completed question {original_idx}")
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"❌ Error processing question {original_idx}: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return {
|
|
"question_id": question_id,
|
|
"error": str(e),
|
|
"question_type": entry.get("question_type", "unknown"),
|
|
"question": entry.get("question", ""),
|
|
"answer": entry.get("answer", ""),
|
|
"judgment": {"is_correct": None, "error": str(e)},
|
|
}
|
|
|
|
# Create all tasks from filtered data (each item is a tuple of (original_idx, entry))
|
|
tasks = [
|
|
process_with_semaphore(idx + 1, original_idx, entry)
|
|
for idx, (original_idx, entry) in enumerate(data_to_process)
|
|
]
|
|
|
|
# Execute in parallel with controlled concurrency
|
|
all_results = await asyncio.gather(*tasks, return_exceptions=False)
|
|
|
|
# Filter out None results if any
|
|
all_results = [r for r in all_results if r is not None]
|
|
|
|
# Save summary
|
|
self.file_manager.save_summary(all_results)
|
|
|
|
elapsed = time.time() - start_time
|
|
print(f"\n✅ Processing completed in {elapsed:.2f}s")
|
|
if total_questions > 0:
|
|
print(f" Average time per question: {elapsed / total_questions:.2f}s")
|
|
|
|
# Compute and report metrics
|
|
self._report_metrics(all_results)
|
|
|
|
return all_results
|
|
|
|
def _report_metrics(self, results: list[dict]):
|
|
"""Report evaluation metrics."""
|
|
metrics = MetricsAggregator.compute_metrics(results)
|
|
timing_stats = MetricsAggregator.compute_timing_stats(results)
|
|
|
|
print("\n" + "=" * 80)
|
|
print("EVALUATION SUMMARY - LONGMEMEVAL - REME (RETRIEVE ONLY)")
|
|
print("=" * 80 + "\n")
|
|
|
|
print("📊 Overall Results:")
|
|
print(f" ✅ Correct: {metrics['correct']}/{metrics['total']} ({100*metrics['accuracy']:.2f}%)")
|
|
print(
|
|
f" ❌ Incorrect: {metrics['incorrect']}/{metrics['total']}"
|
|
f" ({100*metrics['incorrect']/metrics['total'] if metrics['total'] > 0 else 0:.2f}%)",
|
|
)
|
|
if metrics["error"] > 0:
|
|
print(f" ⚠️ Error: {metrics['error']}/{metrics['total']} ({100*metrics['error']/metrics['total']:.2f}%)")
|
|
print(f" Accuracy (valid): {100*metrics['accuracy_valid']:.2f}%")
|
|
|
|
print("\n📊 Accuracy by Question Type:")
|
|
print("-" * 60)
|
|
print(f"{'Question Type':<30} {'Correct':<10} {'Total':<10} {'Accuracy':<10}")
|
|
print("-" * 60)
|
|
for qtype in sorted(metrics["by_question_type"].keys()):
|
|
stats = metrics["by_question_type"][qtype]
|
|
print(f"{qtype:<30} {stats['correct']:<10} {stats['total']:<10} {100*stats['accuracy']:.2f}%")
|
|
print("-" * 60)
|
|
|
|
print("\n⏱️ Timing Statistics (Retrieve Only):")
|
|
retrieve = timing_stats["retrieve"]
|
|
print(" Memory Retrieval:")
|
|
print(f" Total Time: {retrieve['total_min']:.2f} min")
|
|
print(f" Avg per Q: {retrieve['avg_ms']:.0f} ms")
|
|
print(f" Total Time: {timing_stats['total_time_min']:.2f} min")
|
|
|
|
# Save metrics
|
|
final_results = {
|
|
"accuracy": metrics,
|
|
"timing": timing_stats,
|
|
}
|
|
metrics_file = self.file_manager.base_dir / "eval_statistics.json"
|
|
with open(metrics_file, "w", encoding="utf-8") as f:
|
|
json.dump(final_results, f, indent=4, ensure_ascii=False)
|
|
print(f"\n📁 Statistics saved to: {metrics_file}")
|
|
|
|
print("\n" + "=" * 80)
|
|
|
|
|
|
# ==================== Entry Point ====================
|
|
|
|
|
|
async def main_async(
|
|
data_path: str,
|
|
top_k: int = 20,
|
|
start_index: int = 0,
|
|
end_index: Optional[int] = None,
|
|
max_concurrency: int = 1,
|
|
output_dir: str = "bench_results/longmemeval_reme_retrieve",
|
|
reme_model_name: str = "qwen-flash",
|
|
eval_model_name: str = "qwen3-max",
|
|
algo_version: str = "v1",
|
|
samples_per_type: int = -1,
|
|
enable_thinking_params: bool = False,
|
|
summary_results_dir: Optional[str] = None,
|
|
):
|
|
"""Main async entry point for LongMemEval retrieve-only evaluation with proper resource cleanup."""
|
|
config = RetrieveEvalConfig(
|
|
data_path=data_path,
|
|
top_k=top_k,
|
|
start_index=start_index,
|
|
end_index=end_index,
|
|
max_concurrency=max_concurrency,
|
|
output_dir=output_dir,
|
|
reme_model_name=reme_model_name,
|
|
eval_model_name=eval_model_name,
|
|
algo_version=algo_version,
|
|
samples_per_type=samples_per_type,
|
|
enable_thinking_params=enable_thinking_params,
|
|
summary_results_dir=summary_results_dir,
|
|
)
|
|
|
|
# Use async context manager for automatic cleanup
|
|
async with LongMemEvalRetrieveEvaluator(config) as evaluator:
|
|
await evaluator.run_evaluation()
|
|
|
|
|
|
def main(
|
|
data_path: str,
|
|
top_k: int = 20,
|
|
start_index: int = 0,
|
|
end_index: Optional[int] = None,
|
|
max_concurrency: int = 1,
|
|
output_dir: str = "bench_results/longmemeval_reme_retrieve",
|
|
reme_model_name: str = "qwen-flash",
|
|
eval_model_name: str = "qwen3-max",
|
|
algo_version: str = "v1",
|
|
samples_per_type: int = -1,
|
|
enable_thinking_params: bool = False,
|
|
summary_results_dir: Optional[str] = None,
|
|
):
|
|
"""Main entry point for LongMemEval retrieve-only evaluation."""
|
|
asyncio.run(
|
|
main_async(
|
|
data_path=data_path,
|
|
top_k=top_k,
|
|
start_index=start_index,
|
|
end_index=end_index,
|
|
max_concurrency=max_concurrency,
|
|
output_dir=output_dir,
|
|
reme_model_name=reme_model_name,
|
|
eval_model_name=eval_model_name,
|
|
algo_version=algo_version,
|
|
samples_per_type=samples_per_type,
|
|
enable_thinking_params=enable_thinking_params,
|
|
summary_results_dir=summary_results_dir,
|
|
),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Evaluate ReMe on LongMemEval benchmark (Retrieve Phase Only)",
|
|
)
|
|
parser.add_argument(
|
|
"--data_path",
|
|
type=str,
|
|
# default="/Users/zhouwk/PycharmProjects/MemAgent/dataset/longmemeval/longmemeval_s_cleaned.json",
|
|
default="/Users/zhouwk/PycharmProjects/MemAgent/dataset/longmemeval/longmemeval_oracle.json",
|
|
help="Path to LongMemEval JSON file",
|
|
)
|
|
parser.add_argument(
|
|
"--top_k",
|
|
type=int,
|
|
default=10,
|
|
help="Number of memories to retrieve (default: 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--start_index",
|
|
type=int,
|
|
default=0,
|
|
help="Start index for processing questions (default: 0)",
|
|
)
|
|
parser.add_argument(
|
|
"--end_index",
|
|
type=int,
|
|
default=None,
|
|
help="End index for processing questions (default: None, process all)",
|
|
)
|
|
parser.add_argument(
|
|
"--max_concurrency",
|
|
type=int,
|
|
default=8,
|
|
help="Maximum concurrent question processing (default: 1)",
|
|
)
|
|
parser.add_argument(
|
|
"--output_dir",
|
|
type=str,
|
|
default="bench_results/longmemeval_reme_retrieve_gpt4",
|
|
help="Output directory for results",
|
|
)
|
|
parser.add_argument(
|
|
"--reme_model_name",
|
|
type=str,
|
|
default="gpt-4o-mini-2024-07-18",
|
|
help="Model name for ReMe operations (default: gpt-4o-mini-2024-07-18)",
|
|
)
|
|
parser.add_argument(
|
|
"--eval_model_name",
|
|
type=str,
|
|
default="gpt-4o-mini-2024-07-18",
|
|
help="Model name for evaluation/judgment (default: gpt-4o-mini-2024-07-18)",
|
|
)
|
|
parser.add_argument(
|
|
"--algo_version",
|
|
type=str,
|
|
default="longmemeval",
|
|
help="Algorithm version for retrieval (default: longmemeval)",
|
|
)
|
|
parser.add_argument(
|
|
"--samples_per_type",
|
|
type=int,
|
|
default=4,
|
|
help="Number of samples per question type, -1 for all (default: 4)",
|
|
)
|
|
parser.add_argument(
|
|
"--enable_thinking_params",
|
|
action="store_true",
|
|
default=False,
|
|
help="Enable thinking parameters for retrieval (default: False)",
|
|
)
|
|
parser.add_argument(
|
|
"--summary_results_dir",
|
|
type=str,
|
|
default="/Users/zhouwk/PycharmProjects/ReMe/benchmark/longmemeval/bench_results",
|
|
help="Optional: path to previous summary results directory (for reference)",
|
|
)
|
|
parser.add_argument(
|
|
"--no_cache",
|
|
action="store_true",
|
|
default=False,
|
|
help="Ignore cached results and re-run all questions (default: False)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
print(f"args={args}!")
|
|
|
|
main(
|
|
data_path=args.data_path,
|
|
top_k=args.top_k,
|
|
start_index=args.start_index,
|
|
end_index=args.end_index,
|
|
max_concurrency=args.max_concurrency,
|
|
output_dir=args.output_dir,
|
|
reme_model_name=args.reme_model_name,
|
|
eval_model_name=args.eval_model_name,
|
|
algo_version=args.algo_version,
|
|
samples_per_type=args.samples_per_type,
|
|
enable_thinking_params=args.enable_thinking_params,
|
|
summary_results_dir=args.summary_results_dir,
|
|
)
|