claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
434 lines (373 loc) • 14.2 kB
text/typescript
/**
* SmartRetrieval — LongMemEval-derived retrieval pipeline (ADR-090)
*
* Wraps a raw HNSW `SearchFn` with the optimizations identified by the
* ADR-088 LongMemEval benchmark:
*
* 1. Query expansion (template-based, no LLM)
* 2. Multi-query fan-out + Reciprocal Rank Fusion
* 3. Recency boost from metadata timestamps
* 4. MMR diversity re-ranking (token-Jaccard proxy)
* 5. Session round-robin for multi-session coverage
*
* The module is pluggable: callers provide a `SearchFn` that hits whatever
* raw store they use (AgentDB HNSW, sql.js, a test fake, etc.). That keeps
* `@claude-flow/memory` free of a hard dependency on the CLI's memory-initializer
* and makes the pipeline easy to benchmark in isolation.
*/
// ── Types ──────────────────────────────────────────────────────
export interface SearchCandidate {
id: string;
key: string;
content: string;
score: number;
namespace: string;
/** Optional metadata pulled through from the underlying store. */
metadata?: Record<string, unknown>;
/** Optional unix-ms timestamp used by the recency booster. */
createdAt?: number;
/** Optional unix-ms timestamp; preferred over createdAt when present. */
updatedAt?: number;
}
export interface RawSearchRequest {
query: string;
namespace?: string;
limit?: number;
threshold?: number;
}
export interface RawSearchResponse {
results: SearchCandidate[];
}
/** Pluggable raw search function — typically wraps HNSW or a test fake. */
export type SearchFn = (req: RawSearchRequest) => Promise<RawSearchResponse>;
export interface SmartSearchOptions {
query: string;
namespace?: string;
/** Final number of results to return (default 10). */
limit?: number;
/** Similarity floor applied to the raw store (default 0.3). */
threshold?: number;
// ── Phase toggles ──
/** Fan out 2-3 expanded query variants and fuse with RRF. Default: true. */
multiQuery?: boolean;
/** Re-score with recency boost using entry timestamps. Default: true. */
recencyBoost?: boolean;
/** Apply MMR diversity re-ranking. Default: true. */
diversityMMR?: boolean;
/** Round-robin across distinct session_ids. Default: true. */
sessionDiversity?: boolean;
// ── Tunables ──
/** How many candidates to pull from the raw store per variant (default limit × 3). */
fanOutK?: number;
/** RRF constant; 60 is the standard default. */
rrfK?: number;
/** Recency half-life in days; older entries decay past this. Default 30. */
recencyHalfLifeDays?: number;
/** Max recency multiplier applied to top of the curve. Default 0.2. */
recencyWeight?: number;
/** MMR tradeoff λ — 1.0 = pure relevance, 0.0 = pure diversity. Default 0.7. */
mmrLambda?: number;
/** Metadata key that identifies a session for round-robin. Default 'session_id'. */
sessionKey?: string;
/** "Now" for recency decay — pass a fixed value in tests for determinism. */
now?: number;
/** Override the default template-based expansions with your own set. */
queryExpansions?: (query: string) => string[];
}
export interface SmartSearchStats {
variantCount: number;
variants: string[];
rawCandidateCount: number;
afterRrfCount: number;
afterRecencyCount: number;
afterMmrCount: number;
afterSessionCount: number;
durationMs: number;
}
export interface SmartSearchResult {
results: SearchCandidate[];
stats: SmartSearchStats;
}
// ── Query Expansion ────────────────────────────────────────────
const STOPWORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'did', 'do', 'does',
'for', 'from', 'has', 'have', 'how', 'i', 'if', 'in', 'is', 'it', 'its', 'me',
'my', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'was', 'were', 'what',
'when', 'where', 'which', 'who', 'why', 'will', 'with', 'you', 'your',
]);
/**
* Default query expansion set. Keeps variants cheap (≤3) so we only pay
* ~3× the HNSW cost on the hot path.
*/
export function defaultQueryExpansions(query: string): string[] {
const trimmed = query.trim();
if (!trimmed) return [];
const variants = new Set<string>();
variants.add(trimmed);
const keywords = keywordExtract(trimmed);
if (keywords && keywords !== trimmed.toLowerCase()) {
variants.add(keywords);
}
// Context-priming variant — helps when the query is short or imperative.
const words = trimmed.toLowerCase().replace(/[?.!]+$/, '');
if (words && !words.startsWith('tell me')) {
variants.add(`tell me about ${words}`);
}
return [...variants];
}
function keywordExtract(query: string): string {
return query
.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 2 && !STOPWORDS.has(w))
.join(' ');
}
// ── Reciprocal Rank Fusion ─────────────────────────────────────
interface Scored {
candidate: SearchCandidate;
score: number;
}
function reciprocalRankFusion(
rankedLists: SearchCandidate[][],
k: number
): Scored[] {
const fused = new Map<string, { candidate: SearchCandidate; score: number }>();
for (const list of rankedLists) {
for (let rank = 0; rank < list.length; rank++) {
const cand = list[rank];
const rrfContribution = 1 / (k + rank + 1);
const key = candidateKey(cand);
const existing = fused.get(key);
if (existing) {
existing.score += rrfContribution;
// Keep the highest raw score we've seen for display purposes.
if (cand.score > existing.candidate.score) {
existing.candidate = cand;
}
} else {
fused.set(key, { candidate: cand, score: rrfContribution });
}
}
}
return [...fused.values()].sort((a, b) => b.score - a.score);
}
function candidateKey(cand: SearchCandidate): string {
// Fall back through id → key → content hash so deduplication is robust
// even when the raw store omits ids.
return cand.id || cand.key || cand.content.slice(0, 128);
}
// ── Recency Boost ──────────────────────────────────────────────
function applyRecencyBoost(
scored: Scored[],
opts: Required<Pick<SmartSearchOptions, 'recencyHalfLifeDays' | 'recencyWeight'>> & { now: number }
): Scored[] {
const halfLifeMs = opts.recencyHalfLifeDays * 24 * 60 * 60 * 1000;
if (halfLifeMs <= 0) return scored;
return scored
.map(({ candidate, score }) => {
const ts = pickTimestamp(candidate);
if (!ts || !Number.isFinite(ts)) {
return { candidate, score };
}
const ageMs = Math.max(0, opts.now - ts);
// Exponential decay, normalized to [0,1].
const recency = Math.pow(0.5, ageMs / halfLifeMs);
const boosted = score * (1 + opts.recencyWeight * recency);
return { candidate, score: boosted };
})
.sort((a, b) => b.score - a.score);
}
function pickTimestamp(cand: SearchCandidate): number | undefined {
if (cand.updatedAt && Number.isFinite(cand.updatedAt)) return cand.updatedAt;
if (cand.createdAt && Number.isFinite(cand.createdAt)) return cand.createdAt;
const meta = cand.metadata;
if (meta) {
const candidates = ['timestamp', 'updatedAt', 'createdAt', 'time', 'ts'];
for (const k of candidates) {
const v = meta[k];
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const parsed = Date.parse(v);
if (Number.isFinite(parsed)) return parsed;
}
}
}
return undefined;
}
// ── MMR Diversity (token-Jaccard proxy) ────────────────────────
function mmrRerank(scored: Scored[], lambda: number, limit: number): Scored[] {
if (scored.length <= 1) return scored.slice(0, limit);
const selected: Scored[] = [];
const remaining = [...scored];
const selectedTokens: Set<string>[] = [];
// Seed with the top-scored candidate.
const first = remaining.shift()!;
selected.push(first);
selectedTokens.push(tokenize(first.candidate.content));
while (selected.length < limit && remaining.length > 0) {
let bestIdx = -1;
let bestMmr = -Infinity;
for (let i = 0; i < remaining.length; i++) {
const cand = remaining[i];
const candTokens = tokenize(cand.candidate.content);
let maxOverlap = 0;
for (const selTokens of selectedTokens) {
const sim = jaccard(candTokens, selTokens);
if (sim > maxOverlap) maxOverlap = sim;
}
const mmr = lambda * cand.score - (1 - lambda) * maxOverlap;
if (mmr > bestMmr) {
bestMmr = mmr;
bestIdx = i;
}
}
if (bestIdx < 0) break;
const [chosen] = remaining.splice(bestIdx, 1);
selected.push(chosen);
selectedTokens.push(tokenize(chosen.candidate.content));
}
return selected;
}
function tokenize(text: string): Set<string> {
return new Set(
text
.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter((t) => t.length > 2 && !STOPWORDS.has(t))
);
}
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 0;
let intersect = 0;
for (const t of a) if (b.has(t)) intersect++;
const union = a.size + b.size - intersect;
return union === 0 ? 0 : intersect / union;
}
// ── Session Round-Robin ────────────────────────────────────────
function sessionRoundRobin(
scored: Scored[],
sessionKey: string,
limit: number
): Scored[] {
if (scored.length === 0) return scored;
const bySession = new Map<string, Scored[]>();
for (const item of scored) {
const sid = getSessionId(item.candidate, sessionKey) ?? '__no_session__';
const bucket = bySession.get(sid);
if (bucket) bucket.push(item);
else bySession.set(sid, [item]);
}
// If every candidate falls in the same bucket we can't diversify — pass through.
if (bySession.size <= 1) return scored.slice(0, limit);
// Round-robin across session buckets, preferring each bucket's highest score.
const buckets = [...bySession.values()].map((b) =>
[...b].sort((a, b) => b.score - a.score)
);
const interleaved: Scored[] = [];
const seen = new Set<string>();
while (interleaved.length < limit) {
let progressed = false;
for (const bucket of buckets) {
while (bucket.length > 0) {
const next = bucket.shift()!;
const key = candidateKey(next.candidate);
if (seen.has(key)) continue;
seen.add(key);
interleaved.push(next);
progressed = true;
break;
}
if (interleaved.length >= limit) break;
}
if (!progressed) break;
}
return interleaved;
}
function getSessionId(cand: SearchCandidate, key: string): string | undefined {
const meta = cand.metadata;
if (!meta) return undefined;
const v = meta[key];
return typeof v === 'string' ? v : undefined;
}
// ── Public API ─────────────────────────────────────────────────
export async function smartSearch(
search: SearchFn,
opts: SmartSearchOptions
): Promise<SmartSearchResult> {
const start = Date.now();
const limit = opts.limit ?? 10;
const threshold = opts.threshold ?? 0.3;
const fanOutK = opts.fanOutK ?? Math.max(limit * 3, 20);
const rrfK = opts.rrfK ?? 60;
const recencyHalfLifeDays = opts.recencyHalfLifeDays ?? 30;
const recencyWeight = opts.recencyWeight ?? 0.2;
const mmrLambda = opts.mmrLambda ?? 0.7;
const sessionKey = opts.sessionKey ?? 'session_id';
const now = opts.now ?? Date.now();
const multiQuery = opts.multiQuery !== false;
const recencyBoost = opts.recencyBoost !== false;
const diversityMMR = opts.diversityMMR !== false;
const sessionDiversity = opts.sessionDiversity !== false;
const expander = opts.queryExpansions ?? defaultQueryExpansions;
// ── Phase 1: query expansion + fan-out ──
const variants = multiQuery ? expander(opts.query) : [opts.query];
if (variants.length === 0) variants.push(opts.query);
const ranked: SearchCandidate[][] = [];
let totalRaw = 0;
for (const v of variants) {
const resp = await search({
query: v,
namespace: opts.namespace,
limit: fanOutK,
threshold,
});
ranked.push(resp.results);
totalRaw += resp.results.length;
}
// ── Phase 2: RRF fusion ──
let scored: Scored[] =
ranked.length === 1
? ranked[0].map((c) => ({ candidate: c, score: c.score }))
: reciprocalRankFusion(ranked, rrfK);
const afterRrfCount = scored.length;
// ── Phase 3: recency boost ──
if (recencyBoost) {
scored = applyRecencyBoost(scored, { recencyHalfLifeDays, recencyWeight, now });
}
const afterRecencyCount = scored.length;
// Truncate before MMR so we don't re-rank thousands of items.
if (scored.length > fanOutK) scored = scored.slice(0, fanOutK);
// ── Phase 4: MMR diversity ──
if (diversityMMR) {
scored = mmrRerank(scored, mmrLambda, Math.min(limit * 2, scored.length));
} else {
scored = scored.slice(0, Math.min(limit * 2, scored.length));
}
const afterMmrCount = scored.length;
// ── Phase 5: session round-robin ──
let final: Scored[] = scored;
if (sessionDiversity) {
final = sessionRoundRobin(scored, sessionKey, limit);
} else {
final = scored.slice(0, limit);
}
const afterSessionCount = final.length;
return {
results: final.slice(0, limit).map(({ candidate, score }) => ({
...candidate,
score,
})),
stats: {
variantCount: variants.length,
variants,
rawCandidateCount: totalRaw,
afterRrfCount,
afterRecencyCount,
afterMmrCount,
afterSessionCount,
durationMs: Date.now() - start,
},
};
}