Exponential decay (half-life configurable, default 30 days) applied
before MMR re-ranking. Dated daily files (memory/YYYY-MM-DD.md) use
filename date; evergreen files (MEMORY.md, topic files) are not
decayed; other sources fall back to file mtime.
Config: memorySearch.query.hybrid.temporalDecay.{enabled, halfLifeDays}
Default: disabled (backwards compatible, opt-in).
150 lines
3.7 KiB
TypeScript
150 lines
3.7 KiB
TypeScript
import { applyMMRToHybridResults, type MMRConfig, DEFAULT_MMR_CONFIG } from "./mmr.js";
|
|
import {
|
|
applyTemporalDecayToHybridResults,
|
|
type TemporalDecayConfig,
|
|
DEFAULT_TEMPORAL_DECAY_CONFIG,
|
|
} from "./temporal-decay.js";
|
|
|
|
export type HybridSource = string;
|
|
|
|
export { type MMRConfig, DEFAULT_MMR_CONFIG };
|
|
export { type TemporalDecayConfig, DEFAULT_TEMPORAL_DECAY_CONFIG };
|
|
|
|
export type HybridVectorResult = {
|
|
id: string;
|
|
path: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
source: HybridSource;
|
|
snippet: string;
|
|
vectorScore: number;
|
|
};
|
|
|
|
export type HybridKeywordResult = {
|
|
id: string;
|
|
path: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
source: HybridSource;
|
|
snippet: string;
|
|
textScore: number;
|
|
};
|
|
|
|
export function buildFtsQuery(raw: string): string | null {
|
|
const tokens =
|
|
raw
|
|
.match(/[\p{L}\p{N}_]+/gu)
|
|
?.map((t) => t.trim())
|
|
.filter(Boolean) ?? [];
|
|
if (tokens.length === 0) {
|
|
return null;
|
|
}
|
|
const quoted = tokens.map((t) => `"${t.replaceAll('"', "")}"`);
|
|
return quoted.join(" AND ");
|
|
}
|
|
|
|
export function bm25RankToScore(rank: number): number {
|
|
const normalized = Number.isFinite(rank) ? Math.max(0, rank) : 999;
|
|
return 1 / (1 + normalized);
|
|
}
|
|
|
|
export async function mergeHybridResults(params: {
|
|
vector: HybridVectorResult[];
|
|
keyword: HybridKeywordResult[];
|
|
vectorWeight: number;
|
|
textWeight: number;
|
|
workspaceDir?: string;
|
|
/** MMR configuration for diversity-aware re-ranking */
|
|
mmr?: Partial<MMRConfig>;
|
|
/** Temporal decay configuration for recency-aware scoring */
|
|
temporalDecay?: Partial<TemporalDecayConfig>;
|
|
/** Test seam for deterministic time-dependent behavior */
|
|
nowMs?: number;
|
|
}): Promise<
|
|
Array<{
|
|
path: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
score: number;
|
|
snippet: string;
|
|
source: HybridSource;
|
|
}>
|
|
> {
|
|
const byId = new Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
path: string;
|
|
startLine: number;
|
|
endLine: number;
|
|
source: HybridSource;
|
|
snippet: string;
|
|
vectorScore: number;
|
|
textScore: number;
|
|
}
|
|
>();
|
|
|
|
for (const r of params.vector) {
|
|
byId.set(r.id, {
|
|
id: r.id,
|
|
path: r.path,
|
|
startLine: r.startLine,
|
|
endLine: r.endLine,
|
|
source: r.source,
|
|
snippet: r.snippet,
|
|
vectorScore: r.vectorScore,
|
|
textScore: 0,
|
|
});
|
|
}
|
|
|
|
for (const r of params.keyword) {
|
|
const existing = byId.get(r.id);
|
|
if (existing) {
|
|
existing.textScore = r.textScore;
|
|
if (r.snippet && r.snippet.length > 0) {
|
|
existing.snippet = r.snippet;
|
|
}
|
|
} else {
|
|
byId.set(r.id, {
|
|
id: r.id,
|
|
path: r.path,
|
|
startLine: r.startLine,
|
|
endLine: r.endLine,
|
|
source: r.source,
|
|
snippet: r.snippet,
|
|
vectorScore: 0,
|
|
textScore: r.textScore,
|
|
});
|
|
}
|
|
}
|
|
|
|
const merged = Array.from(byId.values()).map((entry) => {
|
|
const score = params.vectorWeight * entry.vectorScore + params.textWeight * entry.textScore;
|
|
return {
|
|
path: entry.path,
|
|
startLine: entry.startLine,
|
|
endLine: entry.endLine,
|
|
score,
|
|
snippet: entry.snippet,
|
|
source: entry.source,
|
|
};
|
|
});
|
|
|
|
const temporalDecayConfig = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay };
|
|
const decayed = await applyTemporalDecayToHybridResults({
|
|
results: merged,
|
|
temporalDecay: temporalDecayConfig,
|
|
workspaceDir: params.workspaceDir,
|
|
nowMs: params.nowMs,
|
|
});
|
|
const sorted = decayed.toSorted((a, b) => b.score - a.score);
|
|
|
|
// Apply MMR re-ranking if enabled
|
|
const mmrConfig = { ...DEFAULT_MMR_CONFIG, ...params.mmr };
|
|
if (mmrConfig.enabled) {
|
|
return applyMMRToHybridResults(sorted, mmrConfig);
|
|
}
|
|
|
|
return sorted;
|
|
}
|