Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

318 lines
9.6 KiB
TypeScript

import { SearchIndex } from "../src/state/search-index.js";
import { VectorIndex } from "../src/state/vector-index.js";
import { HybridSearch } from "../src/state/hybrid-search.js";
import type {
CompressedObservation,
EmbeddingProvider,
} from "../src/types.js";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
interface LongMemEvalEntry {
question_id: string;
question_type: string;
question: string;
question_date: string;
answer: string;
answer_session_ids: string[];
haystack_dates: string[];
haystack_session_ids: string[];
haystack_sessions: Array<Array<{ role: string; content: string; has_answer?: boolean }>>;
}
interface SessionChunk {
sessionId: string;
text: string;
turnCount: number;
}
interface BenchResult {
question_id: string;
question_type: string;
recall_any_at_5: number;
recall_any_at_10: number;
recall_any_at_20: number;
ndcg_at_10: number;
mrr: number;
retrieved_session_ids: string[];
gold_session_ids: string[];
}
function chunkSessionToText(
turns: Array<{ role: string; content: string }>,
): string {
return turns
.map((t) => `${t.role}: ${t.content}`)
.join("\n");
}
function recallAny(
retrievedSessionIds: string[],
goldSessionIds: string[],
k: number,
): number {
const topK = new Set(retrievedSessionIds.slice(0, k));
return goldSessionIds.some((gid) => topK.has(gid)) ? 1.0 : 0.0;
}
function dcg(relevances: boolean[], k: number): number {
let sum = 0;
for (let i = 0; i < Math.min(k, relevances.length); i++) {
sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2);
}
return sum;
}
function ndcg(
retrievedSessionIds: string[],
goldSessionIds: Set<string>,
k: number,
): number {
const rels = retrievedSessionIds
.slice(0, k)
.map((id) => goldSessionIds.has(id));
const idealRels = Array.from(
{ length: Math.min(k, goldSessionIds.size) },
() => true,
);
const idealDCG = dcg(idealRels, k);
if (idealDCG === 0) return 0;
return dcg(rels, k) / idealDCG;
}
function mrr(
retrievedSessionIds: string[],
goldSessionIds: Set<string>,
): number {
for (let i = 0; i < retrievedSessionIds.length; i++) {
if (goldSessionIds.has(retrievedSessionIds[i])) return 1 / (i + 1);
}
return 0;
}
class MockKV {
private store = new Map<string, Map<string, unknown>>();
async get<T>(scope: string, key: string): Promise<T> {
const m = this.store.get(scope);
if (!m || !m.has(key)) throw new Error(`Not found: ${scope}/${key}`);
return m.get(key) as T;
}
async set(scope: string, key: string, value: unknown): Promise<void> {
if (!this.store.has(scope)) this.store.set(scope, new Map());
this.store.get(scope)!.set(key, value);
}
async list<T>(scope: string): Promise<T[]> {
const m = this.store.get(scope);
if (!m) return [];
return Array.from(m.values()) as T[];
}
async delete(scope: string, key: string): Promise<void> {
this.store.get(scope)?.delete(key);
}
}
async function runBenchmark(
mode: "bm25" | "vector" | "hybrid",
embeddingProvider?: EmbeddingProvider,
) {
const dataPath = new URL("./data/longmemeval_s_cleaned.json", import.meta.url).pathname;
if (!existsSync(dataPath)) {
console.error(`Dataset not found at ${dataPath}`);
console.error("Download from: https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned");
process.exit(1);
}
console.log(`Loading LongMemEval-S dataset...`);
const raw = JSON.parse(readFileSync(dataPath, "utf-8")) as LongMemEvalEntry[];
const abstentionTypes = new Set([
"single-session-user_abs",
"multi-session_abs",
"knowledge-update_abs",
"temporal-reasoning_abs",
]);
const entries = raw.filter((e) => !abstentionTypes.has(e.question_type));
console.log(
`Loaded ${entries.length} questions (${raw.length - entries.length} abstention excluded)`,
);
const results: BenchResult[] = [];
let processed = 0;
for (const entry of entries) {
const sessionChunks: SessionChunk[] = [];
for (let i = 0; i < entry.haystack_sessions.length; i++) {
const sessionId = entry.haystack_session_ids[i];
const turns = entry.haystack_sessions[i];
const text = chunkSessionToText(turns);
sessionChunks.push({ sessionId, text, turnCount: turns.length });
}
const bm25 = new SearchIndex();
const vector = mode !== "bm25" ? new VectorIndex() : null;
const kv = new MockKV();
const observations: CompressedObservation[] = [];
for (const chunk of sessionChunks) {
const obs: CompressedObservation = {
id: `obs_${chunk.sessionId}`,
sessionId: chunk.sessionId,
timestamp: new Date().toISOString(),
type: "conversation",
title: chunk.text.slice(0, 80),
facts: [],
narrative: chunk.text,
concepts: [],
files: [],
importance: 5,
};
observations.push(obs);
bm25.add(obs);
if (vector && embeddingProvider) {
try {
const embedding = await embeddingProvider.embed(
chunk.text.slice(0, 512),
);
vector.add(obs.id, chunk.sessionId, embedding);
} catch {}
}
await kv.set(`mem:obs:${chunk.sessionId}`, obs.id, obs);
}
let retrievedObsIds: string[];
if (mode === "bm25") {
const bm25Results = bm25.search(entry.question, 20);
retrievedObsIds = bm25Results.map((r) => r.obsId);
} else {
const hybridSearch = new HybridSearch(
bm25,
vector,
embeddingProvider || null,
kv as any,
0.4,
0.6,
0.0,
false,
);
const hybridResults = await hybridSearch.search(entry.question, 20);
retrievedObsIds = hybridResults.map((r) => r.observation.id);
}
const retrievedSessionIds = retrievedObsIds.map((oid) =>
oid.replace(/^obs_/, ""),
);
const goldSet = new Set(entry.answer_session_ids);
const result: BenchResult = {
question_id: entry.question_id,
question_type: entry.question_type,
recall_any_at_5: recallAny(retrievedSessionIds, entry.answer_session_ids, 5),
recall_any_at_10: recallAny(retrievedSessionIds, entry.answer_session_ids, 10),
recall_any_at_20: recallAny(retrievedSessionIds, entry.answer_session_ids, 20),
ndcg_at_10: ndcg(retrievedSessionIds, goldSet, 10),
mrr: mrr(retrievedSessionIds, goldSet),
retrieved_session_ids: retrievedSessionIds.slice(0, 10),
gold_session_ids: entry.answer_session_ids,
};
results.push(result);
processed++;
if (processed % 50 === 0) {
const avgRecall5 =
results.reduce((s, r) => s + r.recall_any_at_5, 0) / results.length;
console.log(
` [${processed}/${entries.length}] running recall_any@5: ${(avgRecall5 * 100).toFixed(1)}%`,
);
}
}
const avgRecallAny5 =
results.reduce((s, r) => s + r.recall_any_at_5, 0) / results.length;
const avgRecallAny10 =
results.reduce((s, r) => s + r.recall_any_at_10, 0) / results.length;
const avgRecallAny20 =
results.reduce((s, r) => s + r.recall_any_at_20, 0) / results.length;
const avgNdcg10 =
results.reduce((s, r) => s + r.ndcg_at_10, 0) / results.length;
const avgMrr =
results.reduce((s, r) => s + r.mrr, 0) / results.length;
const byType = new Map<string, BenchResult[]>();
for (const r of results) {
if (!byType.has(r.question_type)) byType.set(r.question_type, []);
byType.get(r.question_type)!.push(r);
}
console.log(`\n=== LongMemEval-S Results (${mode}) ===`);
console.log(`Questions: ${results.length} (excl. abstention)`);
console.log(`recall_any@5: ${(avgRecallAny5 * 100).toFixed(1)}%`);
console.log(`recall_any@10: ${(avgRecallAny10 * 100).toFixed(1)}%`);
console.log(`recall_any@20: ${(avgRecallAny20 * 100).toFixed(1)}%`);
console.log(`NDCG@10: ${(avgNdcg10 * 100).toFixed(1)}%`);
console.log(`MRR: ${(avgMrr * 100).toFixed(1)}%`);
console.log(`\nBy question type:`);
for (const [type, typeResults] of byType) {
const r5 =
typeResults.reduce((s, r) => s + r.recall_any_at_5, 0) /
typeResults.length;
const r10 =
typeResults.reduce((s, r) => s + r.recall_any_at_10, 0) /
typeResults.length;
console.log(
` ${type.padEnd(30)} R@5: ${(r5 * 100).toFixed(1)}% R@10: ${(r10 * 100).toFixed(1)}% (n=${typeResults.length})`,
);
}
const outPath = new URL(
`./data/longmemeval_results_${mode}.json`,
import.meta.url,
).pathname;
writeFileSync(
outPath,
JSON.stringify(
{
mode,
questions: results.length,
recall_any_at_5: avgRecallAny5,
recall_any_at_10: avgRecallAny10,
recall_any_at_20: avgRecallAny20,
ndcg_at_10: avgNdcg10,
mrr: avgMrr,
per_type: Object.fromEntries(
Array.from(byType).map(([type, tr]) => [
type,
{
count: tr.length,
recall_any_at_5:
tr.reduce((s, r) => s + r.recall_any_at_5, 0) / tr.length,
recall_any_at_10:
tr.reduce((s, r) => s + r.recall_any_at_10, 0) / tr.length,
},
]),
),
per_question: results,
},
null,
2,
),
);
console.log(`\nResults saved to ${outPath}`);
}
const mode = (process.argv[2] || "bm25") as "bm25" | "vector" | "hybrid";
console.log(`Running LongMemEval-S benchmark in ${mode} mode...`);
if (mode === "bm25") {
runBenchmark("bm25").catch(console.error);
} else {
import("../src/providers/embedding/local.js")
.then(({ LocalEmbeddingProvider }) => {
const provider = new LocalEmbeddingProvider();
return runBenchmark(mode, provider);
})
.catch(console.error);
}