recallnest 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/CLAUDE.md +29 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.env.example +3 -0
- package/.github/workflows/ci.yml +28 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +51 -0
- package/CLAUDE.md +42 -0
- package/DEMO.md +94 -0
- package/EVAL.md +89 -0
- package/FAILURES.md +75 -0
- package/LICENSE +21 -0
- package/OPERATIONS.md +59 -0
- package/README.md +427 -0
- package/README_CN.md +425 -0
- package/ROADMAP.md +198 -0
- package/assets/recallnest-workbench.png +0 -0
- package/assets/ui/app.js +807 -0
- package/assets/ui/index.html +154 -0
- package/assets/ui/styles.css +605 -0
- package/assets/wechat-qr.jpg +0 -0
- package/config.json.example +38 -0
- package/daily-recall.sh +87 -0
- package/daily-reflections/2026-03-05.md +15 -0
- package/daily-reflections/2026-03-06.md +15 -0
- package/daily-reflections/2026-03-07.md +7 -0
- package/daily-reflections/2026-03-08.md +34 -0
- package/daily-reflections/2026-03-09.md +35 -0
- package/daily-reflections/2026-03-10.md +36 -0
- package/daily-reflections/2026-03-11.md +35 -0
- package/daily-reflections/2026-03-12.md +36 -0
- package/daily-reflections/2026-03-13.md +35 -0
- package/daily-reflections/2026-03-14.md +35 -0
- package/daily-reflections/2026-03-15.md +35 -0
- package/daily-reflections/2026-03-16.md +41 -0
- package/daily-reflections/2026-03-17.md +10 -0
- package/daily-reflections/2026-03-18.md +10 -0
- package/daily-reflections/2026-03-19.md +10 -0
- package/daily-reflections/2026-03-20.md +10 -0
- package/daily-reflections/2026-03-21.md +10 -0
- package/daily-reflections/2026-03-22.md +10 -0
- package/daily-reflections/2026-03-23.md +10 -0
- package/daily-reflections/2026-03-24.md +10 -0
- package/daily-reflections/2026-03-25.md +32 -0
- package/daily-reflections/2026-03-26.md +37 -0
- package/daily-reflections/2026-03-27.md +35 -0
- package/daily-reflections/2026-03-28.md +35 -0
- package/daily-reflections/2026-03-29.md +35 -0
- package/daily-reflections/2026-03-30.md +36 -0
- package/daily-reflections/2026-03-31.md +35 -0
- package/daily-reflections/2026-04-01.md +35 -0
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/memory-boundary-contract.md +226 -0
- package/docs/memory-categories.md +119 -0
- package/docs/pipeline-audit-ultramemory.md +177 -0
- package/docs/plans/2026-03-08-query-expansion.md +270 -0
- package/docs/plans/2026-03-16-continuity-layer.md +196 -0
- package/docs/plans/mcp-tiering-plan.md +126 -0
- package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
- package/docs/self-evolution.md +198 -0
- package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
- package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
- package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
- package/eval/cases.json +85 -0
- package/eval/continuity/case-seeds.json +90 -0
- package/eval/continuity/cases.json +302 -0
- package/eval/continuity/memory-seeds.json +26 -0
- package/eval/continuity/pattern-seeds.json +100 -0
- package/eval/continuity/results-2026-03-16.md +117 -0
- package/eval/continuity/results-2026-03-19.md +453 -0
- package/eval/continuity/runbook.md +110 -0
- package/eval/continuity/seed-cases.md +54 -0
- package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
- package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
- package/eval/reports/2026-03-06-baseline.md +82 -0
- package/eval/reports/query-expansion.md +138 -0
- package/integrations/README.md +57 -0
- package/integrations/claude-code/README.md +133 -0
- package/integrations/claude-code/claude-md-snippet.md +31 -0
- package/integrations/claude-code/setup.sh +66 -0
- package/integrations/claude-code/smoke-continuity.sh +452 -0
- package/integrations/codex/README.md +49 -0
- package/integrations/codex/agents-md-snippet.md +31 -0
- package/integrations/codex/setup.sh +58 -0
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
- package/integrations/examples/langchain/memory-chain.py +111 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
- package/integrations/gemini-cli/README.md +57 -0
- package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
- package/integrations/gemini-cli/setup.sh +64 -0
- package/integrations/shared/install-managed-block.sh +51 -0
- package/lm +19 -0
- package/package.json +50 -0
- package/plan-v1.2-distributable.md +105 -0
- package/scripts/archive-distilled-junk.ts +116 -0
- package/scripts/backfill-l0l1l2.ts +98 -0
- package/scripts/blind-scan.ts +588 -0
- package/scripts/cleanup-step1-exact-dups.ts +117 -0
- package/scripts/cleanup-step2-template-dups.ts +113 -0
- package/scripts/cleanup-step3-stale-content.ts +149 -0
- package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
- package/scripts/delete-distilled-junk.ts +88 -0
- package/scripts/distill-facts.ts +305 -0
- package/scripts/health-check.ts +343 -0
- package/scripts/incremental-ingest.sh +71 -0
- package/scripts/mlp-health-check.ts +382 -0
- package/scripts/pull-claude-desktop.ts +515 -0
- package/scripts/re-ingest-cc.sh +66 -0
- package/scripts/rebalance-memory-health.ts +181 -0
- package/scripts/run-desktop-ingest.sh +11 -0
- package/scripts/start-server.sh +12 -0
- package/scripts/viz-dashboard.html +423 -0
- package/scripts/viz-export.ts +703 -0
- package/src/__tests__/access-tracker-novelty.test.ts +228 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/capture-engine.test.ts +1142 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +33 -0
- package/src/__tests__/confidence-tracker.test.ts +196 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +374 -0
- package/src/__tests__/consolidation-engine.test.ts +181 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/doctor.test.ts +303 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/hotness-blend.test.ts +76 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/kg-extractor.test.ts +184 -0
- package/src/__tests__/kg-store.test.ts +198 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/memory-boundaries.test.ts +237 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +230 -0
- package/src/__tests__/preference-slots.test.ts +106 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4743 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/session-checkpoint.test.ts +259 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-output-compressor.test.ts +133 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation.test.ts +400 -0
- package/src/access-tracker.ts +316 -0
- package/src/adaptive-retrieval.ts +64 -0
- package/src/api-server.ts +667 -0
- package/src/asset-sync.ts +79 -0
- package/src/auto-consolidation.ts +133 -0
- package/src/auto-gc.ts +124 -0
- package/src/auto-recall.ts +95 -0
- package/src/capture-engine.ts +931 -0
- package/src/capture-heuristic.ts +177 -0
- package/src/cascade-forget.ts +135 -0
- package/src/chunker.ts +253 -0
- package/src/cli.ts +1917 -0
- package/src/confidence-tracker.ts +166 -0
- package/src/conflict-advisor.ts +433 -0
- package/src/conflict-engine.ts +181 -0
- package/src/conflict-escalation.ts +138 -0
- package/src/conflict-lifecycle.ts +67 -0
- package/src/conflict-output.ts +218 -0
- package/src/conflict-schema.ts +130 -0
- package/src/conflict-store.ts +127 -0
- package/src/consolidation-engine.ts +256 -0
- package/src/context-composer-pins.ts +183 -0
- package/src/context-composer-queries.ts +121 -0
- package/src/context-composer-scope.ts +56 -0
- package/src/context-composer-stable-selection.ts +200 -0
- package/src/context-composer-stable.ts +217 -0
- package/src/context-composer-task-fallbacks.ts +36 -0
- package/src/context-composer-task-ranking.ts +197 -0
- package/src/context-composer-task-results.ts +137 -0
- package/src/context-composer-task-selection.ts +342 -0
- package/src/context-composer-text.ts +57 -0
- package/src/context-composer.ts +300 -0
- package/src/context-renderer.ts +179 -0
- package/src/decay-engine.ts +189 -0
- package/src/doctor.ts +543 -0
- package/src/embedder.ts +557 -0
- package/src/entity-resolver.ts +131 -0
- package/src/eval.ts +632 -0
- package/src/event-segmenter.ts +216 -0
- package/src/ingest.ts +1822 -0
- package/src/kg-extractor.ts +217 -0
- package/src/kg-store.ts +353 -0
- package/src/legacy-scope-review.ts +53 -0
- package/src/llm-client.ts +629 -0
- package/src/llm-consolidation.ts +168 -0
- package/src/mcp-server.ts +1337 -0
- package/src/memory-assets.ts +475 -0
- package/src/memory-boundaries.ts +354 -0
- package/src/memory-health-rebalance.ts +220 -0
- package/src/memory-output.ts +474 -0
- package/src/memory-schema.ts +184 -0
- package/src/multi-vector.ts +234 -0
- package/src/noise-filter.ts +155 -0
- package/src/ppr-traversal.ts +279 -0
- package/src/preference-matcher.ts +174 -0
- package/src/preference-slots.ts +259 -0
- package/src/prospective-memory.ts +198 -0
- package/src/query-entity-detector.ts +207 -0
- package/src/query-expander.ts +98 -0
- package/src/result-synthesizer.ts +114 -0
- package/src/retrieval-profiles.ts +101 -0
- package/src/retrieval-trace.ts +94 -0
- package/src/retriever.ts +1338 -0
- package/src/retroactive-boost.ts +154 -0
- package/src/rif.ts +73 -0
- package/src/runtime-config.ts +172 -0
- package/src/schema-utils.ts +56 -0
- package/src/scope-inventory.ts +310 -0
- package/src/scope-policy.ts +142 -0
- package/src/session-engine.ts +168 -0
- package/src/session-output.ts +96 -0
- package/src/session-schema.ts +60 -0
- package/src/session-store.ts +293 -0
- package/src/stderr-log.ts +16 -0
- package/src/store.ts +903 -0
- package/src/temporal-parser.ts +318 -0
- package/src/term-registry.ts +791 -0
- package/src/tool-output-compressor.ts +115 -0
- package/src/tracker.ts +57 -0
- package/src/ui-server.ts +430 -0
- package/src/version-manager.ts +172 -0
- package/src/workflow-observation-engine.ts +316 -0
- package/src/workflow-observation-managed.ts +83 -0
- package/src/workflow-observation-output.ts +86 -0
- package/src/workflow-observation-schema.ts +87 -0
- package/src/workflow-observation-scope-review.ts +409 -0
- package/src/workflow-observation-store.ts +69 -0
package/src/retriever.ts
ADDED
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Retrieval System
|
|
3
|
+
* Combines vector search + BM25 full-text search with RRF fusion
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { MemoryStore, MemorySearchResult } from "./store.js";
|
|
7
|
+
import type { Embedder } from "./embedder.js";
|
|
8
|
+
import { filterNoise } from "./noise-filter.js";
|
|
9
|
+
import { shouldSkipRetrieval } from "./adaptive-retrieval.js";
|
|
10
|
+
import { expandQuery } from "./query-expander.js";
|
|
11
|
+
import { type AccessTracker, computeHotnessScore, parseAccessMetadata } from "./access-tracker.js";
|
|
12
|
+
import { weibullDecay, resolveTier } from "./decay-engine.js";
|
|
13
|
+
import { logWarn } from "./stderr-log.js";
|
|
14
|
+
import { extractBoundaryMetadata, isDurableMemoryScope, isTranscriptScope } from "./memory-boundaries.js";
|
|
15
|
+
import type { TraceCollector } from "./retrieval-trace.js";
|
|
16
|
+
import { filterInterference } from "./rif.js";
|
|
17
|
+
import { applyConfidenceWeight } from "./confidence-tracker.js";
|
|
18
|
+
import { deduplicateByVersionGroup } from "./version-manager.js";
|
|
19
|
+
import {
|
|
20
|
+
isMultiVectorEnabled,
|
|
21
|
+
extractMultiVectorText,
|
|
22
|
+
textOverlapScore,
|
|
23
|
+
blendMultiVectorScores,
|
|
24
|
+
adaptiveBlendConfig,
|
|
25
|
+
tokenize,
|
|
26
|
+
} from "./multi-vector.js";
|
|
27
|
+
import { parseTemporalQuery, matchesTemporalConstraint, type TemporalConstraint } from "./temporal-parser.js";
|
|
28
|
+
import type { KGStore } from "./kg-store.js";
|
|
29
|
+
import { isKGModeEnabled } from "./kg-extractor.js";
|
|
30
|
+
import { detectEntities } from "./query-entity-detector.js";
|
|
31
|
+
import { buildGraph, pprTraverse } from "./ppr-traversal.js";
|
|
32
|
+
import { logInfo } from "./stderr-log.js";
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Types & Configuration
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
export interface RetrievalConfig {
|
|
39
|
+
mode: "hybrid" | "vector";
|
|
40
|
+
vectorWeight: number;
|
|
41
|
+
bm25Weight: number;
|
|
42
|
+
minScore: number;
|
|
43
|
+
rerank: "cross-encoder" | "lightweight" | "none";
|
|
44
|
+
candidatePoolSize: number;
|
|
45
|
+
/** Recency boost half-life in days (default: 14). Set 0 to disable. */
|
|
46
|
+
recencyHalfLifeDays: number;
|
|
47
|
+
/** Max recency boost factor (default: 0.10) */
|
|
48
|
+
recencyWeight: number;
|
|
49
|
+
/** Filter noise from results (default: true) */
|
|
50
|
+
filterNoise: boolean;
|
|
51
|
+
/** Reranker API key (enables cross-encoder reranking) */
|
|
52
|
+
rerankApiKey?: string;
|
|
53
|
+
/** Reranker model (default: jina-reranker-v3) */
|
|
54
|
+
rerankModel?: string;
|
|
55
|
+
/** Reranker API endpoint (default: https://api.jina.ai/v1/rerank). */
|
|
56
|
+
rerankEndpoint?: string;
|
|
57
|
+
/** Reranker provider format. Determines request/response shape and auth header.
|
|
58
|
+
* - "jina" (default): Authorization: Bearer, string[] documents, results[].relevance_score
|
|
59
|
+
* - "siliconflow": same format as jina (alias, for clarity)
|
|
60
|
+
* - "voyage": Authorization: Bearer, string[] documents, data[].relevance_score
|
|
61
|
+
* - "pinecone": Api-Key header, {text}[] documents, data[].score */
|
|
62
|
+
rerankProvider?: "jina" | "siliconflow" | "voyage" | "pinecone" | "vllm";
|
|
63
|
+
/**
|
|
64
|
+
* Length normalization: penalize long entries that dominate via sheer keyword
|
|
65
|
+
* density. Formula: score *= 1 / (1 + log2(charLen / anchor)).
|
|
66
|
+
* anchor = reference length (default: 500 chars). Entries shorter than anchor
|
|
67
|
+
* get a slight boost; longer entries get penalized progressively.
|
|
68
|
+
* Set 0 to disable. (default: 300)
|
|
69
|
+
*/
|
|
70
|
+
lengthNormAnchor: number;
|
|
71
|
+
/**
|
|
72
|
+
* Hard cutoff after rerank: discard results below this score.
|
|
73
|
+
* Applied after all scoring stages (rerank, recency, importance, length norm).
|
|
74
|
+
* Higher = fewer but more relevant results. (default: 0.35)
|
|
75
|
+
*/
|
|
76
|
+
hardMinScore: number;
|
|
77
|
+
/**
|
|
78
|
+
* Time decay half-life in days. Entries older than this lose score.
|
|
79
|
+
* Different from recencyBoost (additive bonus for new entries):
|
|
80
|
+
* this is a multiplicative penalty for old entries.
|
|
81
|
+
* Formula: score *= 0.5 + 0.5 * exp(-ageDays / halfLife)
|
|
82
|
+
* At halfLife days: ~0.68x. At 2*halfLife: ~0.59x. At 4*halfLife: ~0.52x.
|
|
83
|
+
* Set 0 to disable. (default: 60)
|
|
84
|
+
*/
|
|
85
|
+
timeDecayHalfLifeDays: number;
|
|
86
|
+
/**
|
|
87
|
+
* Hotness blend weight. Blends access-frequency hotness into final score.
|
|
88
|
+
* Formula: final = score * (1-alpha) + hotness * alpha.
|
|
89
|
+
* 0 = disabled (default), 0.15 = recommended.
|
|
90
|
+
*/
|
|
91
|
+
hotnessWeight: number;
|
|
92
|
+
/**
|
|
93
|
+
* Per-category score thresholds. When a result's category matches a key,
|
|
94
|
+
* that threshold is used instead of hardMinScore. Categories not listed
|
|
95
|
+
* fall back to hardMinScore. (default: see DEFAULT_CATEGORY_MIN_SCORES)
|
|
96
|
+
*/
|
|
97
|
+
categoryMinScores?: Record<string, number>;
|
|
98
|
+
/** Enable Retrieval Interference Filter (RIF) — demotes near-duplicate weak results. Default: false. */
|
|
99
|
+
enableRIF?: boolean;
|
|
100
|
+
/** RIF cosine similarity threshold for "near-duplicate" (default: 0.85). */
|
|
101
|
+
rifThreshold?: number;
|
|
102
|
+
/** RIF score ratio: demote if score < ratio * stronger result's score (default: 0.80). */
|
|
103
|
+
rifScoreRatio?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RetrievalContext {
|
|
107
|
+
query: string;
|
|
108
|
+
limit: number;
|
|
109
|
+
scopeFilter?: string[];
|
|
110
|
+
category?: string;
|
|
111
|
+
/** Origin of retrieval call. Access reinforcement is only applied for "manual"
|
|
112
|
+
* calls to prevent auto-recall from strengthening noise memories.
|
|
113
|
+
* Defaults to "manual" when unset (backward-compatible). */
|
|
114
|
+
source?: "manual" | "auto-recall" | "cli";
|
|
115
|
+
/** When false (default), archived records are excluded from results. */
|
|
116
|
+
includeArchived?: boolean;
|
|
117
|
+
/** Optional trace collector for per-stage pipeline observability. */
|
|
118
|
+
trace?: TraceCollector;
|
|
119
|
+
/** Enable KG graph traversal (PPR) as an additional retrieval signal. */
|
|
120
|
+
graph?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RetrievalResult extends MemorySearchResult {
|
|
124
|
+
sources: {
|
|
125
|
+
vector?: { score: number; rank: number };
|
|
126
|
+
bm25?: { score: number; rank: number };
|
|
127
|
+
graph?: { score: number; rank: number };
|
|
128
|
+
fused?: { score: number };
|
|
129
|
+
reranked?: { score: number };
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Default Configuration
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Per-category minimum scores. Lower threshold = easier to recall.
|
|
139
|
+
* profile/preferences: low bar because identity & preferences are almost always relevant.
|
|
140
|
+
* cases/patterns: higher bar because they are more specific and noisy matches are costly.
|
|
141
|
+
*/
|
|
142
|
+
export const DEFAULT_CATEGORY_MIN_SCORES: Record<string, number> = {
|
|
143
|
+
profile: 0.25,
|
|
144
|
+
preferences: 0.25,
|
|
145
|
+
entities: 0.30,
|
|
146
|
+
events: 0.35,
|
|
147
|
+
cases: 0.40,
|
|
148
|
+
patterns: 0.45,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
|
|
152
|
+
mode: "hybrid",
|
|
153
|
+
vectorWeight: 0.7,
|
|
154
|
+
bm25Weight: 0.3,
|
|
155
|
+
minScore: 0.3,
|
|
156
|
+
rerank: "cross-encoder",
|
|
157
|
+
candidatePoolSize: 20,
|
|
158
|
+
recencyHalfLifeDays: 14,
|
|
159
|
+
recencyWeight: 0.10,
|
|
160
|
+
filterNoise: true,
|
|
161
|
+
rerankModel: "jina-reranker-v3",
|
|
162
|
+
rerankEndpoint: "https://api.jina.ai/v1/rerank",
|
|
163
|
+
lengthNormAnchor: 500,
|
|
164
|
+
hardMinScore: 0.35,
|
|
165
|
+
timeDecayHalfLifeDays: 60,
|
|
166
|
+
hotnessWeight: 0,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// Utility Functions
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
function clampInt(value: number, min: number, max: number): number {
|
|
174
|
+
if (!Number.isFinite(value)) return min;
|
|
175
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clamp01(value: number, fallback: number): number {
|
|
179
|
+
if (!Number.isFinite(value)) return Number.isFinite(fallback) ? fallback : 0;
|
|
180
|
+
return Math.min(1, Math.max(0, value));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseMetadata(raw?: string): Record<string, unknown> {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(raw || "{}") as Record<string, unknown>;
|
|
186
|
+
} catch {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ============================================================================
|
|
192
|
+
// Rerank Provider Adapters
|
|
193
|
+
// ============================================================================
|
|
194
|
+
|
|
195
|
+
type RerankProvider = "jina" | "siliconflow" | "voyage" | "pinecone" | "vllm";
|
|
196
|
+
|
|
197
|
+
interface RerankItem { index: number; score: number }
|
|
198
|
+
|
|
199
|
+
/** Build provider-specific request headers and body */
|
|
200
|
+
function buildRerankRequest(
|
|
201
|
+
provider: RerankProvider,
|
|
202
|
+
apiKey: string,
|
|
203
|
+
model: string,
|
|
204
|
+
query: string,
|
|
205
|
+
documents: string[],
|
|
206
|
+
topN: number,
|
|
207
|
+
): { headers: Record<string, string>; body: Record<string, unknown> } {
|
|
208
|
+
switch (provider) {
|
|
209
|
+
case "pinecone":
|
|
210
|
+
return {
|
|
211
|
+
headers: {
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
"Api-Key": apiKey,
|
|
214
|
+
"X-Pinecone-API-Version": "2024-10",
|
|
215
|
+
},
|
|
216
|
+
body: {
|
|
217
|
+
model,
|
|
218
|
+
query,
|
|
219
|
+
documents: documents.map(text => ({ text })),
|
|
220
|
+
top_n: topN,
|
|
221
|
+
rank_fields: ["text"],
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
case "voyage":
|
|
225
|
+
return {
|
|
226
|
+
headers: {
|
|
227
|
+
"Content-Type": "application/json",
|
|
228
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
229
|
+
},
|
|
230
|
+
body: {
|
|
231
|
+
model,
|
|
232
|
+
query,
|
|
233
|
+
documents,
|
|
234
|
+
// Voyage uses top_k (not top_n) to limit reranked outputs.
|
|
235
|
+
top_k: topN,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
case "vllm":
|
|
239
|
+
// Docker Model Runner / vLLM: no auth required, runs locally
|
|
240
|
+
return {
|
|
241
|
+
headers: {
|
|
242
|
+
"Content-Type": "application/json",
|
|
243
|
+
},
|
|
244
|
+
body: {
|
|
245
|
+
model,
|
|
246
|
+
query,
|
|
247
|
+
documents,
|
|
248
|
+
top_n: topN,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
case "siliconflow":
|
|
252
|
+
case "jina":
|
|
253
|
+
default:
|
|
254
|
+
return {
|
|
255
|
+
headers: {
|
|
256
|
+
"Content-Type": "application/json",
|
|
257
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
258
|
+
},
|
|
259
|
+
body: {
|
|
260
|
+
model,
|
|
261
|
+
query,
|
|
262
|
+
documents,
|
|
263
|
+
top_n: topN,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Parse provider-specific response into unified format */
|
|
270
|
+
function parseRerankResponse(
|
|
271
|
+
provider: RerankProvider,
|
|
272
|
+
data: Record<string, unknown>,
|
|
273
|
+
): RerankItem[] | null {
|
|
274
|
+
const parseItems = (
|
|
275
|
+
items: unknown,
|
|
276
|
+
scoreKeys: Array<"score" | "relevance_score">,
|
|
277
|
+
): RerankItem[] | null => {
|
|
278
|
+
if (!Array.isArray(items)) return null;
|
|
279
|
+
const parsed: RerankItem[] = [];
|
|
280
|
+
for (const raw of items as Array<Record<string, unknown>>) {
|
|
281
|
+
const index = typeof raw?.index === "number" ? raw.index : Number(raw?.index);
|
|
282
|
+
if (!Number.isFinite(index)) continue;
|
|
283
|
+
let score: number | null = null;
|
|
284
|
+
for (const key of scoreKeys) {
|
|
285
|
+
const value = raw?.[key];
|
|
286
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
287
|
+
if (Number.isFinite(n)) {
|
|
288
|
+
score = n;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (score === null) continue;
|
|
293
|
+
parsed.push({ index, score });
|
|
294
|
+
}
|
|
295
|
+
return parsed.length > 0 ? parsed : null;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
switch (provider) {
|
|
299
|
+
case "pinecone": {
|
|
300
|
+
// Pinecone: usually { data: [{ index, score, ... }] }
|
|
301
|
+
// Also tolerate results[] with score/relevance_score for robustness.
|
|
302
|
+
return (
|
|
303
|
+
parseItems(data.data, ["score", "relevance_score"]) ??
|
|
304
|
+
parseItems(data.results, ["score", "relevance_score"])
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
case "voyage": {
|
|
308
|
+
// Voyage: usually { data: [{ index, relevance_score }] }
|
|
309
|
+
// Also tolerate results[] for compatibility across gateways.
|
|
310
|
+
return (
|
|
311
|
+
parseItems(data.data, ["relevance_score", "score"]) ??
|
|
312
|
+
parseItems(data.results, ["relevance_score", "score"])
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
case "vllm":
|
|
316
|
+
case "siliconflow":
|
|
317
|
+
case "jina":
|
|
318
|
+
default: {
|
|
319
|
+
// Jina / SiliconFlow / vLLM: usually { results: [{ index, relevance_score }] }
|
|
320
|
+
// Also tolerate data[] for compatibility across gateways.
|
|
321
|
+
return (
|
|
322
|
+
parseItems(data.results, ["relevance_score", "score"]) ??
|
|
323
|
+
parseItems(data.data, ["relevance_score", "score"])
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Cosine similarity for reranking fallback
|
|
330
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
331
|
+
if (a.length !== b.length) {
|
|
332
|
+
throw new Error("Vector dimensions must match for cosine similarity");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let dotProduct = 0;
|
|
336
|
+
let normA = 0;
|
|
337
|
+
let normB = 0;
|
|
338
|
+
|
|
339
|
+
for (let i = 0; i < a.length; i++) {
|
|
340
|
+
dotProduct += a[i] * b[i];
|
|
341
|
+
normA += a[i] * a[i];
|
|
342
|
+
normB += b[i] * b[i];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const norm = Math.sqrt(normA) * Math.sqrt(normB);
|
|
346
|
+
return norm === 0 ? 0 : dotProduct / norm;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ============================================================================
|
|
350
|
+
// Memory Retriever
|
|
351
|
+
// ============================================================================
|
|
352
|
+
|
|
353
|
+
export class MemoryRetriever {
|
|
354
|
+
private accessTracker?: AccessTracker;
|
|
355
|
+
private kgStore?: KGStore;
|
|
356
|
+
/**
|
|
357
|
+
* Session-scoped suppression list (dopamine-inspired "do not disturb").
|
|
358
|
+
* IDs in this set get a score penalty during retrieval but are NOT deleted.
|
|
359
|
+
* Call clearSessionSuppression() to reset (e.g. at session end).
|
|
360
|
+
*/
|
|
361
|
+
private sessionSuppressed = new Set<string>();
|
|
362
|
+
|
|
363
|
+
constructor(
|
|
364
|
+
private store: MemoryStore,
|
|
365
|
+
private embedder: Embedder,
|
|
366
|
+
private config: RetrievalConfig = DEFAULT_RETRIEVAL_CONFIG
|
|
367
|
+
) {}
|
|
368
|
+
|
|
369
|
+
/** Attach an AccessTracker to enable reinforcement-based decay. */
|
|
370
|
+
setAccessTracker(tracker: AccessTracker): void {
|
|
371
|
+
this.accessTracker = tracker;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Attach a KGStore to enable graph-based retrieval (PPR). */
|
|
375
|
+
setKGStore(kgStore: KGStore): void {
|
|
376
|
+
this.kgStore = kgStore;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Suppress a memory ID for the current session (score × 0.3). */
|
|
380
|
+
suppressForSession(id: string): void {
|
|
381
|
+
this.sessionSuppressed.add(id);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Clear all session suppressions. */
|
|
385
|
+
clearSessionSuppression(): void {
|
|
386
|
+
this.sessionSuppressed.clear();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Resolve the minimum score for a given category, falling back to hardMinScore. */
|
|
390
|
+
private minScoreFor(category: string): number {
|
|
391
|
+
const map = this.config.categoryMinScores ?? DEFAULT_CATEGORY_MIN_SCORES;
|
|
392
|
+
return map[category] ?? this.config.hardMinScore;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async retrieve(context: RetrievalContext): Promise<RetrievalResult[]> {
|
|
396
|
+
const { query, limit, scopeFilter, category, includeArchived, trace, graph } = context;
|
|
397
|
+
const safeLimit = clampInt(limit, 1, 20);
|
|
398
|
+
|
|
399
|
+
// Adaptive retrieval: skip trivial queries to save embedding API calls
|
|
400
|
+
if (shouldSkipRetrieval(query)) {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let results: RetrievalResult[];
|
|
405
|
+
|
|
406
|
+
// For vector-only mode, use legacy behavior
|
|
407
|
+
if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
|
|
408
|
+
results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace);
|
|
409
|
+
} else {
|
|
410
|
+
// Hybrid retrieval with vector + BM25 + RRF fusion (+ optional PPR graph)
|
|
411
|
+
results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Record access for returned results (async, non-blocking).
|
|
415
|
+
// Only reinforce on manual retrieval — auto-recall must not strengthen noise.
|
|
416
|
+
// Tier 3.2: pass similarity scores for novelty gating — only novel retrievals
|
|
417
|
+
// (high query-result distance) trigger access reinforcement.
|
|
418
|
+
if (this.accessTracker && results.length > 0 && context.source !== "auto-recall") {
|
|
419
|
+
this.accessTracker.recordAccess(
|
|
420
|
+
results.map(r => r.entry.id),
|
|
421
|
+
results.map(r => r.score),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return results;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async vectorOnlyRetrieval(
|
|
429
|
+
query: string,
|
|
430
|
+
limit: number,
|
|
431
|
+
scopeFilter?: string[],
|
|
432
|
+
category?: string,
|
|
433
|
+
includeArchived?: boolean,
|
|
434
|
+
trace?: TraceCollector,
|
|
435
|
+
): Promise<RetrievalResult[]> {
|
|
436
|
+
// Temporal reasoning for vector-only mode
|
|
437
|
+
const temporal = parseTemporalQuery(query);
|
|
438
|
+
const searchQuery = temporal.constraint ? temporal.cleanedQuery : query;
|
|
439
|
+
|
|
440
|
+
trace?.startStage("vector_search", 0);
|
|
441
|
+
const queryVector = await this.embedder.embedQuery(searchQuery);
|
|
442
|
+
const results = await this.store.vectorSearch(queryVector, limit, this.config.minScore, scopeFilter);
|
|
443
|
+
trace?.endStage(results.length, results.map(r => r.score));
|
|
444
|
+
|
|
445
|
+
// Filter by category if specified
|
|
446
|
+
const afterCategory = category
|
|
447
|
+
? results.filter(r => r.entry.category === category)
|
|
448
|
+
: results;
|
|
449
|
+
|
|
450
|
+
// Filter archived records (default: exclude)
|
|
451
|
+
const afterArchive = includeArchived
|
|
452
|
+
? afterCategory
|
|
453
|
+
: afterCategory.filter(r => parseMetadata(r.entry.metadata).archived !== true);
|
|
454
|
+
|
|
455
|
+
// Temporal filter
|
|
456
|
+
const filtered = temporal.constraint
|
|
457
|
+
? afterArchive.filter(r => matchesTemporalConstraint(r.entry.timestamp, temporal.constraint!))
|
|
458
|
+
: afterArchive;
|
|
459
|
+
|
|
460
|
+
const mapped = filtered.map((result, index) => ({
|
|
461
|
+
...result,
|
|
462
|
+
sources: {
|
|
463
|
+
vector: { score: result.score, rank: index + 1 },
|
|
464
|
+
},
|
|
465
|
+
} as RetrievalResult));
|
|
466
|
+
|
|
467
|
+
trace?.startStage("recency_boost", mapped.length);
|
|
468
|
+
const boosted = this.applyRecencyBoost(mapped);
|
|
469
|
+
trace?.endStage(boosted.length, boosted.map(r => r.score));
|
|
470
|
+
|
|
471
|
+
trace?.startStage("importance_weight", boosted.length);
|
|
472
|
+
const weighted = this.applyImportanceWeight(boosted);
|
|
473
|
+
trace?.endStage(weighted.length, weighted.map(r => r.score));
|
|
474
|
+
|
|
475
|
+
trace?.startStage("confidence_weight", weighted.length);
|
|
476
|
+
const confidenceWeighted = this.applyConfidence(weighted);
|
|
477
|
+
trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
|
|
478
|
+
|
|
479
|
+
trace?.startStage("boundary_weight", confidenceWeighted.length);
|
|
480
|
+
const boundaryWeighted = this.applyBoundaryWeight(confidenceWeighted);
|
|
481
|
+
trace?.endStage(boundaryWeighted.length, boundaryWeighted.map(r => r.score));
|
|
482
|
+
|
|
483
|
+
trace?.startStage("asset_type_weight", boundaryWeighted.length);
|
|
484
|
+
const assetWeighted = this.applyAssetTypeWeight(boundaryWeighted);
|
|
485
|
+
trace?.endStage(assetWeighted.length, assetWeighted.map(r => r.score));
|
|
486
|
+
|
|
487
|
+
trace?.startStage("length_norm", assetWeighted.length);
|
|
488
|
+
const lengthNormalized = this.applyLengthNormalization(assetWeighted);
|
|
489
|
+
trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
|
|
490
|
+
|
|
491
|
+
trace?.startStage("time_decay", lengthNormalized.length);
|
|
492
|
+
const timeDecayed = this.applyTimeDecay(lengthNormalized);
|
|
493
|
+
trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
|
|
494
|
+
|
|
495
|
+
// Hotness blend: boost frequently + recently accessed memories
|
|
496
|
+
trace?.startStage("hotness_blend", timeDecayed.length);
|
|
497
|
+
const hotnessBlended = this.applyHotnessBlend(timeDecayed);
|
|
498
|
+
trace?.endStage(hotnessBlended.length, hotnessBlended.map(r => r.score));
|
|
499
|
+
|
|
500
|
+
// Session suppression: temporarily penalize "do not disturb" memories
|
|
501
|
+
const afterSuppression = this.applySessionSuppression(hotnessBlended);
|
|
502
|
+
|
|
503
|
+
trace?.startStage("hard_min_score", afterSuppression.length);
|
|
504
|
+
const hardFiltered = afterSuppression.filter(r => r.score >= this.minScoreFor(r.entry.category));
|
|
505
|
+
trace?.endStage(hardFiltered.length, hardFiltered.map(r => r.score));
|
|
506
|
+
|
|
507
|
+
trace?.startStage("noise_filter", hardFiltered.length);
|
|
508
|
+
const denoised = this.config.filterNoise
|
|
509
|
+
? filterNoise(hardFiltered, r => r.entry.text)
|
|
510
|
+
: hardFiltered;
|
|
511
|
+
trace?.endStage(denoised.length, denoised.map(r => r.score));
|
|
512
|
+
|
|
513
|
+
// RIF: demote near-duplicate weak results to improve diversity
|
|
514
|
+
const afterRif = this.config.enableRIF
|
|
515
|
+
? filterInterference(denoised, this.config.rifThreshold ?? 0.85, this.config.rifScoreRatio ?? 0.80)
|
|
516
|
+
: denoised;
|
|
517
|
+
|
|
518
|
+
// MMR deduplication: avoid top-k filled with near-identical memories
|
|
519
|
+
trace?.startStage("mmr_diversity", afterRif.length);
|
|
520
|
+
const deduplicated = this.applyMMRDiversity(afterRif);
|
|
521
|
+
trace?.endStage(deduplicated.length, deduplicated.map(r => r.score));
|
|
522
|
+
|
|
523
|
+
// Tier 3.3: Version group dedup — keep only the top-ranked version per group
|
|
524
|
+
const versionDeduped = deduplicateByVersionGroup(deduplicated);
|
|
525
|
+
|
|
526
|
+
trace?.startStage("final_limit", versionDeduped.length);
|
|
527
|
+
const final = versionDeduped.slice(0, limit);
|
|
528
|
+
trace?.endStage(final.length, final.map(r => r.score));
|
|
529
|
+
|
|
530
|
+
return final;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async hybridRetrieval(
|
|
534
|
+
query: string,
|
|
535
|
+
limit: number,
|
|
536
|
+
scopeFilter?: string[],
|
|
537
|
+
category?: string,
|
|
538
|
+
includeArchived?: boolean,
|
|
539
|
+
trace?: TraceCollector,
|
|
540
|
+
graph?: boolean,
|
|
541
|
+
): Promise<RetrievalResult[]> {
|
|
542
|
+
const candidatePoolSize = Math.max(this.config.candidatePoolSize, limit * 2);
|
|
543
|
+
|
|
544
|
+
// Temporal reasoning: extract time constraint and clean query for semantic search
|
|
545
|
+
const temporal = parseTemporalQuery(query);
|
|
546
|
+
const searchQuery = temporal.constraint ? temporal.cleanedQuery : query;
|
|
547
|
+
|
|
548
|
+
// Compute query embedding once, reuse for vector search + reranking
|
|
549
|
+
const queryVector = await this.embedder.embedQuery(searchQuery);
|
|
550
|
+
|
|
551
|
+
// Run vector, BM25, and optionally PPR searches in parallel
|
|
552
|
+
const useGraph = graph && isKGModeEnabled() && this.kgStore;
|
|
553
|
+
const pprPromise = useGraph
|
|
554
|
+
? this.runPPRSearch(query, scopeFilter?.[0], trace)
|
|
555
|
+
: Promise.resolve([] as RetrievalResult[]);
|
|
556
|
+
|
|
557
|
+
trace?.startStage("vector_search", 0);
|
|
558
|
+
const [vectorResults, bm25Results, pprResults] = await Promise.all([
|
|
559
|
+
this.runVectorSearch(queryVector, candidatePoolSize, scopeFilter, category, includeArchived),
|
|
560
|
+
this.runBM25Search(expandQuery(searchQuery), candidatePoolSize, scopeFilter, category, includeArchived),
|
|
561
|
+
pprPromise,
|
|
562
|
+
]);
|
|
563
|
+
trace?.endStage(vectorResults.length, vectorResults.map(r => r.score));
|
|
564
|
+
|
|
565
|
+
trace?.startStage("bm25_search", 0);
|
|
566
|
+
trace?.endStage(bm25Results.length, bm25Results.map(r => r.score));
|
|
567
|
+
|
|
568
|
+
// Fuse results using RRF (async: validates BM25-only entries exist in store)
|
|
569
|
+
trace?.startStage("rrf_fusion", vectorResults.length + bm25Results.length + pprResults.length);
|
|
570
|
+
const fusedResults = await this.fuseResults(vectorResults, bm25Results, pprResults);
|
|
571
|
+
trace?.endStage(fusedResults.length, fusedResults.map(r => r.score));
|
|
572
|
+
|
|
573
|
+
// Temporal filter: when query has a time constraint, remove non-matching candidates
|
|
574
|
+
trace?.startStage("temporal_filter", fusedResults.length);
|
|
575
|
+
const temporalFiltered = temporal.constraint
|
|
576
|
+
? fusedResults.filter(r => matchesTemporalConstraint(r.entry.timestamp, temporal.constraint!))
|
|
577
|
+
: fusedResults;
|
|
578
|
+
trace?.endStage(temporalFiltered.length, temporalFiltered.map(r => r.score));
|
|
579
|
+
|
|
580
|
+
// Multi-vector L0/L1 blend: re-score candidates using metadata abstracts/overviews
|
|
581
|
+
trace?.startStage("multi_vector_blend", temporalFiltered.length);
|
|
582
|
+
const multiVecBlended = this.applyMultiVectorBlend(temporalFiltered, searchQuery);
|
|
583
|
+
trace?.endStage(multiVecBlended.length, multiVecBlended.map(r => r.score));
|
|
584
|
+
|
|
585
|
+
// Apply minimum score threshold
|
|
586
|
+
trace?.startStage("min_score_filter", multiVecBlended.length);
|
|
587
|
+
const filtered = multiVecBlended.filter(r => r.score >= this.config.minScore);
|
|
588
|
+
trace?.endStage(filtered.length, filtered.map(r => r.score));
|
|
589
|
+
|
|
590
|
+
// Rerank if enabled
|
|
591
|
+
trace?.startStage("rerank", filtered.length);
|
|
592
|
+
const reranked = this.config.rerank !== "none"
|
|
593
|
+
? await this.rerankResults(query, queryVector, filtered.slice(0, limit * 2))
|
|
594
|
+
: filtered;
|
|
595
|
+
trace?.endStage(reranked.length, reranked.map(r => r.score));
|
|
596
|
+
|
|
597
|
+
// Apply temporal re-ranking (recency boost)
|
|
598
|
+
trace?.startStage("recency_boost", reranked.length);
|
|
599
|
+
const temporalReranked = this.applyRecencyBoost(reranked);
|
|
600
|
+
trace?.endStage(temporalReranked.length, temporalReranked.map(r => r.score));
|
|
601
|
+
|
|
602
|
+
// Apply importance weighting
|
|
603
|
+
trace?.startStage("importance_weight", temporalReranked.length);
|
|
604
|
+
const importanceWeighted = this.applyImportanceWeight(temporalReranked);
|
|
605
|
+
trace?.endStage(importanceWeighted.length, importanceWeighted.map(r => r.score));
|
|
606
|
+
|
|
607
|
+
// Viewpoint confidence: penalize corrected/contradicted memories
|
|
608
|
+
trace?.startStage("confidence_weight", importanceWeighted.length);
|
|
609
|
+
const confidenceWeighted = this.applyConfidence(importanceWeighted);
|
|
610
|
+
trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
|
|
611
|
+
|
|
612
|
+
// Prefer higher-authority durable memories over raw evidence when scores are close.
|
|
613
|
+
trace?.startStage("boundary_weight", confidenceWeighted.length);
|
|
614
|
+
const boundaryWeighted = this.applyBoundaryWeight(confidenceWeighted);
|
|
615
|
+
trace?.endStage(boundaryWeighted.length, boundaryWeighted.map(r => r.score));
|
|
616
|
+
|
|
617
|
+
// Separate pinned assets from synthesized briefs.
|
|
618
|
+
trace?.startStage("asset_type_weight", boundaryWeighted.length);
|
|
619
|
+
const assetWeighted = this.applyAssetTypeWeight(boundaryWeighted);
|
|
620
|
+
trace?.endStage(assetWeighted.length, assetWeighted.map(r => r.score));
|
|
621
|
+
|
|
622
|
+
// Apply length normalization (penalize long entries dominating via keyword density)
|
|
623
|
+
trace?.startStage("length_norm", assetWeighted.length);
|
|
624
|
+
const lengthNormalized = this.applyLengthNormalization(assetWeighted);
|
|
625
|
+
trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
|
|
626
|
+
|
|
627
|
+
// Apply time decay (penalize stale entries)
|
|
628
|
+
trace?.startStage("time_decay", lengthNormalized.length);
|
|
629
|
+
const timeDecayed = this.applyTimeDecay(lengthNormalized);
|
|
630
|
+
trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
|
|
631
|
+
|
|
632
|
+
// Hotness blend: boost frequently + recently accessed memories
|
|
633
|
+
trace?.startStage("hotness_blend", timeDecayed.length);
|
|
634
|
+
const hotnessBlended = this.applyHotnessBlend(timeDecayed);
|
|
635
|
+
trace?.endStage(hotnessBlended.length, hotnessBlended.map(r => r.score));
|
|
636
|
+
|
|
637
|
+
// Hard minimum score cutoff (post all scoring stages)
|
|
638
|
+
trace?.startStage("hard_min_score", hotnessBlended.length);
|
|
639
|
+
const hardFiltered = hotnessBlended.filter(r => r.score >= this.minScoreFor(r.entry.category));
|
|
640
|
+
trace?.endStage(hardFiltered.length, hardFiltered.map(r => r.score));
|
|
641
|
+
|
|
642
|
+
// Filter noise
|
|
643
|
+
trace?.startStage("noise_filter", hardFiltered.length);
|
|
644
|
+
const denoised = this.config.filterNoise
|
|
645
|
+
? filterNoise(hardFiltered, r => r.entry.text)
|
|
646
|
+
: hardFiltered;
|
|
647
|
+
trace?.endStage(denoised.length, denoised.map(r => r.score));
|
|
648
|
+
|
|
649
|
+
// RIF: demote near-duplicate weak results to improve diversity
|
|
650
|
+
const afterRif = this.config.enableRIF
|
|
651
|
+
? filterInterference(denoised, this.config.rifThreshold ?? 0.85, this.config.rifScoreRatio ?? 0.80)
|
|
652
|
+
: denoised;
|
|
653
|
+
|
|
654
|
+
// MMR deduplication: avoid top-k filled with near-identical memories
|
|
655
|
+
trace?.startStage("mmr_diversity", afterRif.length);
|
|
656
|
+
const deduplicated = this.applyMMRDiversity(afterRif);
|
|
657
|
+
trace?.endStage(deduplicated.length, deduplicated.map(r => r.score));
|
|
658
|
+
|
|
659
|
+
// Tier 3.3: Version group dedup — keep only the top-ranked version per group
|
|
660
|
+
const versionDeduped = deduplicateByVersionGroup(deduplicated);
|
|
661
|
+
|
|
662
|
+
trace?.startStage("final_limit", versionDeduped.length);
|
|
663
|
+
const final = versionDeduped.slice(0, limit);
|
|
664
|
+
trace?.endStage(final.length, final.map(r => r.score));
|
|
665
|
+
|
|
666
|
+
return final;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private async runVectorSearch(
|
|
670
|
+
queryVector: number[],
|
|
671
|
+
limit: number,
|
|
672
|
+
scopeFilter?: string[],
|
|
673
|
+
category?: string,
|
|
674
|
+
includeArchived?: boolean,
|
|
675
|
+
): Promise<Array<MemorySearchResult & { rank: number }>> {
|
|
676
|
+
let results: MemorySearchResult[];
|
|
677
|
+
try {
|
|
678
|
+
results = await this.store.vectorSearch(queryVector, limit, 0.1, scopeFilter);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
// Fail-open: log warning and continue with empty results (backport from v1.0.30)
|
|
681
|
+
logWarn("vectorSearch failed, continuing with empty results:", err);
|
|
682
|
+
results = [];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Filter by category if specified
|
|
686
|
+
const afterCategory = category
|
|
687
|
+
? results.filter(r => r.entry.category === category)
|
|
688
|
+
: results;
|
|
689
|
+
|
|
690
|
+
// Filter archived records (default: exclude)
|
|
691
|
+
const filtered = includeArchived
|
|
692
|
+
? afterCategory
|
|
693
|
+
: afterCategory.filter(r => parseMetadata(r.entry.metadata).archived !== true);
|
|
694
|
+
|
|
695
|
+
return filtered.map((result, index) => ({
|
|
696
|
+
...result,
|
|
697
|
+
rank: index + 1,
|
|
698
|
+
}));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private async runBM25Search(
|
|
702
|
+
query: string,
|
|
703
|
+
limit: number,
|
|
704
|
+
scopeFilter?: string[],
|
|
705
|
+
category?: string,
|
|
706
|
+
includeArchived?: boolean,
|
|
707
|
+
): Promise<Array<MemorySearchResult & { rank: number }>> {
|
|
708
|
+
const results = await this.store.bm25Search(query, limit, scopeFilter);
|
|
709
|
+
|
|
710
|
+
// Filter by category if specified
|
|
711
|
+
const afterCategory = category
|
|
712
|
+
? results.filter(r => r.entry.category === category)
|
|
713
|
+
: results;
|
|
714
|
+
|
|
715
|
+
// Filter archived records (default: exclude)
|
|
716
|
+
const filtered = includeArchived
|
|
717
|
+
? afterCategory
|
|
718
|
+
: afterCategory.filter(r => parseMetadata(r.entry.metadata).archived !== true);
|
|
719
|
+
|
|
720
|
+
return filtered.map((result, index) => ({
|
|
721
|
+
...result,
|
|
722
|
+
rank: index + 1,
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Run PPR graph traversal: detect entities -> BFS neighborhood -> PPR -> map to MemoryEntry.
|
|
728
|
+
* Returns RetrievalResult[] with graph source scores.
|
|
729
|
+
*/
|
|
730
|
+
private async runPPRSearch(
|
|
731
|
+
query: string,
|
|
732
|
+
scope?: string,
|
|
733
|
+
trace?: TraceCollector,
|
|
734
|
+
): Promise<RetrievalResult[]> {
|
|
735
|
+
if (!this.kgStore) return [];
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
trace?.startStage("ppr_entity_detect", 0);
|
|
739
|
+
const detected = await detectEntities(query, this.kgStore, scope);
|
|
740
|
+
trace?.endStage(detected.entities.length, []);
|
|
741
|
+
|
|
742
|
+
if (detected.entities.length === 0) return [];
|
|
743
|
+
|
|
744
|
+
// BFS neighborhood from KG
|
|
745
|
+
trace?.startStage("ppr_neighborhood", detected.entities.length);
|
|
746
|
+
const hopLimit = detected.isMultiHop ? 3 : 2;
|
|
747
|
+
const neighborhood = await this.kgStore.getNeighborhood(detected.entities, hopLimit, scope);
|
|
748
|
+
trace?.endStage(neighborhood.length, []);
|
|
749
|
+
|
|
750
|
+
if (neighborhood.length === 0) return [];
|
|
751
|
+
|
|
752
|
+
// Run PPR
|
|
753
|
+
trace?.startStage("ppr_traverse", neighborhood.length);
|
|
754
|
+
const graph = buildGraph(neighborhood);
|
|
755
|
+
const pprResults = pprTraverse(graph, detected.entities, { hopLimit, topK: 20 });
|
|
756
|
+
trace?.endStage(pprResults.length, pprResults.map(r => r.score));
|
|
757
|
+
|
|
758
|
+
if (pprResults.length === 0) return [];
|
|
759
|
+
|
|
760
|
+
// Collect all source_memory_ids from triples related to PPR-scored entities
|
|
761
|
+
const entityScoreMap = new Map(pprResults.map(r => [r.entity, r.score]));
|
|
762
|
+
const memoryScoreMap = new Map<string, number>();
|
|
763
|
+
|
|
764
|
+
for (const nr of neighborhood) {
|
|
765
|
+
for (const triple of nr.triples) {
|
|
766
|
+
const subjectScore = entityScoreMap.get(triple.subject) ?? 0;
|
|
767
|
+
const objectScore = entityScoreMap.get(triple.object) ?? 0;
|
|
768
|
+
const tripleScore = Math.max(subjectScore, objectScore) * triple.confidence;
|
|
769
|
+
|
|
770
|
+
if (tripleScore > 0 && triple.source_memory_id) {
|
|
771
|
+
const existing = memoryScoreMap.get(triple.source_memory_id) ?? 0;
|
|
772
|
+
memoryScoreMap.set(triple.source_memory_id, Math.max(existing, tripleScore));
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Fetch memory entries for the top scored memories
|
|
778
|
+
const sortedMemories = [...memoryScoreMap.entries()]
|
|
779
|
+
.sort((a, b) => b[1] - a[1])
|
|
780
|
+
.slice(0, 20);
|
|
781
|
+
|
|
782
|
+
const results: RetrievalResult[] = [];
|
|
783
|
+
let rank = 0;
|
|
784
|
+
for (const [memId, score] of sortedMemories) {
|
|
785
|
+
const entry = await this.store.getById(memId);
|
|
786
|
+
if (!entry) continue;
|
|
787
|
+
rank++;
|
|
788
|
+
results.push({
|
|
789
|
+
entry,
|
|
790
|
+
score,
|
|
791
|
+
sources: {
|
|
792
|
+
graph: { score, rank },
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
logInfo(`[PPR] ${detected.entities.length} seed entities -> ${pprResults.length} PPR nodes -> ${results.length} memories`);
|
|
798
|
+
return results;
|
|
799
|
+
} catch (err) {
|
|
800
|
+
logWarn(`[PPR] graph traversal failed, continuing without graph results: ${String(err)}`);
|
|
801
|
+
return [];
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async fuseResults(
|
|
806
|
+
vectorResults: Array<MemorySearchResult & { rank: number }>,
|
|
807
|
+
bm25Results: Array<MemorySearchResult & { rank: number }>,
|
|
808
|
+
graphResults: RetrievalResult[] = [],
|
|
809
|
+
): Promise<RetrievalResult[]> {
|
|
810
|
+
// Create maps for quick lookup
|
|
811
|
+
const vectorMap = new Map<string, MemorySearchResult & { rank: number }>();
|
|
812
|
+
const bm25Map = new Map<string, MemorySearchResult & { rank: number }>();
|
|
813
|
+
const graphMap = new Map<string, RetrievalResult>();
|
|
814
|
+
|
|
815
|
+
vectorResults.forEach(result => {
|
|
816
|
+
vectorMap.set(result.entry.id, result);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
bm25Results.forEach(result => {
|
|
820
|
+
bm25Map.set(result.entry.id, result);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
graphResults.forEach(result => {
|
|
824
|
+
graphMap.set(result.entry.id, result);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Get all unique document IDs
|
|
828
|
+
const allIds = new Set([...vectorMap.keys(), ...bm25Map.keys(), ...graphMap.keys()]);
|
|
829
|
+
|
|
830
|
+
// Calculate RRF scores
|
|
831
|
+
const fusedResults: RetrievalResult[] = [];
|
|
832
|
+
|
|
833
|
+
for (const id of allIds) {
|
|
834
|
+
const vectorResult = vectorMap.get(id);
|
|
835
|
+
const bm25Result = bm25Map.get(id);
|
|
836
|
+
const graphResult = graphMap.get(id);
|
|
837
|
+
|
|
838
|
+
// FIX(#15): BM25-only results may be "ghost" entries whose vector data was
|
|
839
|
+
// deleted but whose FTS index entry lingers until the next index rebuild.
|
|
840
|
+
// Validate that the entry actually exists in the store before including it.
|
|
841
|
+
if (!vectorResult && !graphResult && bm25Result) {
|
|
842
|
+
try {
|
|
843
|
+
const exists = await this.store.hasId(id);
|
|
844
|
+
if (!exists) continue; // Skip ghost entry
|
|
845
|
+
} catch {
|
|
846
|
+
// If hasId fails, keep the result (fail-open)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Use the result with more complete data (prefer vector > graph > BM25)
|
|
851
|
+
const baseResult = vectorResult || graphResult || bm25Result!;
|
|
852
|
+
|
|
853
|
+
// Use vector similarity as the base score.
|
|
854
|
+
// BM25 hit acts as a bonus (keyword match confirms relevance).
|
|
855
|
+
// Graph hit acts as a bonus (entity relationship confirms relevance).
|
|
856
|
+
const vectorScore = vectorResult ? vectorResult.score : 0;
|
|
857
|
+
const bm25Hit = bm25Result ? 1 : 0;
|
|
858
|
+
const graphScore = graphResult?.sources.graph?.score ?? 0;
|
|
859
|
+
|
|
860
|
+
// Base = vector score; BM25 hit boosts by up to 15%; graph boosts by up to 20%
|
|
861
|
+
// Graph-only results use their PPR score so entity-connected memories surface.
|
|
862
|
+
let fusedScore: number;
|
|
863
|
+
if (vectorResult) {
|
|
864
|
+
fusedScore = clamp01(
|
|
865
|
+
vectorScore + (bm25Hit * 0.15 * vectorScore) + (graphScore * 0.20),
|
|
866
|
+
0.1,
|
|
867
|
+
);
|
|
868
|
+
} else if (graphResult) {
|
|
869
|
+
// Graph-only: use graph score as base, BM25 boosts
|
|
870
|
+
fusedScore = clamp01(graphScore + (bm25Hit * 0.10), 0.1);
|
|
871
|
+
} else {
|
|
872
|
+
fusedScore = clamp01(bm25Result!.score, 0.1);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
fusedResults.push({
|
|
876
|
+
entry: baseResult.entry,
|
|
877
|
+
score: fusedScore,
|
|
878
|
+
sources: {
|
|
879
|
+
vector: vectorResult ? { score: vectorResult.score, rank: vectorResult.rank } : undefined,
|
|
880
|
+
bm25: bm25Result ? { score: bm25Result.score, rank: bm25Result.rank } : undefined,
|
|
881
|
+
graph: graphResult?.sources.graph,
|
|
882
|
+
fused: { score: fusedScore },
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Sort by fused score descending
|
|
888
|
+
return fusedResults.sort((a, b) => b.score - a.score);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Rerank results using cross-encoder API (Jina, Pinecone, or compatible).
|
|
893
|
+
* Falls back to cosine similarity if API is unavailable or fails.
|
|
894
|
+
*/
|
|
895
|
+
private async rerankResults(query: string, queryVector: number[], results: RetrievalResult[]): Promise<RetrievalResult[]> {
|
|
896
|
+
if (results.length === 0) {
|
|
897
|
+
return results;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Try cross-encoder rerank via configured provider API
|
|
901
|
+
const provider = this.config.rerankProvider || "jina";
|
|
902
|
+
const needsApiKey = provider !== "vllm";
|
|
903
|
+
if (this.config.rerank === "cross-encoder" && (!needsApiKey || this.config.rerankApiKey)) {
|
|
904
|
+
try {
|
|
905
|
+
const model = this.config.rerankModel || "jina-reranker-v3";
|
|
906
|
+
const endpoint = this.config.rerankEndpoint || "https://api.jina.ai/v1/rerank";
|
|
907
|
+
const documents = results.map(r => r.entry.text);
|
|
908
|
+
|
|
909
|
+
// Build provider-specific request
|
|
910
|
+
const { headers, body } = buildRerankRequest(provider, this.config.rerankApiKey || "", model, query, documents, results.length);
|
|
911
|
+
|
|
912
|
+
// Timeout: 5 seconds to prevent stalling retrieval pipeline
|
|
913
|
+
const controller = new AbortController();
|
|
914
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
915
|
+
|
|
916
|
+
let response: Response;
|
|
917
|
+
try {
|
|
918
|
+
response = await fetch(endpoint, {
|
|
919
|
+
method: "POST",
|
|
920
|
+
headers,
|
|
921
|
+
body: JSON.stringify(body),
|
|
922
|
+
signal: controller.signal,
|
|
923
|
+
});
|
|
924
|
+
} finally {
|
|
925
|
+
clearTimeout(timeout);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (response.ok) {
|
|
929
|
+
const data = await response.json() as Record<string, unknown>;
|
|
930
|
+
|
|
931
|
+
// Parse provider-specific response into unified format
|
|
932
|
+
const parsed = parseRerankResponse(provider, data);
|
|
933
|
+
|
|
934
|
+
if (!parsed) {
|
|
935
|
+
logWarn("Rerank API: invalid response shape, falling back to cosine");
|
|
936
|
+
} else {
|
|
937
|
+
// Build a Set of returned indices to identify unreturned candidates
|
|
938
|
+
const returnedIndices = new Set(parsed.map(r => r.index));
|
|
939
|
+
|
|
940
|
+
const reranked = parsed
|
|
941
|
+
.filter(item => item.index >= 0 && item.index < results.length)
|
|
942
|
+
.map(item => {
|
|
943
|
+
const original = results[item.index];
|
|
944
|
+
// Blend: 60% cross-encoder score + 40% original fused score
|
|
945
|
+
const blendedScore = clamp01(
|
|
946
|
+
item.score * 0.6 + original.score * 0.4,
|
|
947
|
+
original.score * 0.5,
|
|
948
|
+
);
|
|
949
|
+
return {
|
|
950
|
+
...original,
|
|
951
|
+
score: blendedScore,
|
|
952
|
+
sources: {
|
|
953
|
+
...original.sources,
|
|
954
|
+
reranked: { score: item.score },
|
|
955
|
+
},
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// Keep unreturned candidates with their original scores (slightly penalized)
|
|
960
|
+
const unreturned = results
|
|
961
|
+
.filter((_, idx) => !returnedIndices.has(idx))
|
|
962
|
+
.map(r => ({ ...r, score: r.score * 0.8 }));
|
|
963
|
+
|
|
964
|
+
return [...reranked, ...unreturned].sort((a, b) => b.score - a.score);
|
|
965
|
+
}
|
|
966
|
+
} else {
|
|
967
|
+
const errText = await response.text().catch(() => "");
|
|
968
|
+
logWarn(`Rerank API returned ${response.status}: ${errText.slice(0, 200)}, falling back to cosine`);
|
|
969
|
+
}
|
|
970
|
+
} catch (error) {
|
|
971
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
972
|
+
logWarn("Rerank API timed out (5s), falling back to cosine");
|
|
973
|
+
} else {
|
|
974
|
+
logWarn("Rerank API failed, falling back to cosine:", error);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Fallback: lightweight cosine similarity rerank
|
|
980
|
+
try {
|
|
981
|
+
const reranked = results.map(result => {
|
|
982
|
+
const cosineScore = cosineSimilarity(queryVector, result.entry.vector);
|
|
983
|
+
const combinedScore = (result.score * 0.7) + (cosineScore * 0.3);
|
|
984
|
+
|
|
985
|
+
return {
|
|
986
|
+
...result,
|
|
987
|
+
score: clamp01(combinedScore, result.score),
|
|
988
|
+
sources: {
|
|
989
|
+
...result.sources,
|
|
990
|
+
reranked: { score: cosineScore },
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
return reranked.sort((a, b) => b.score - a.score);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
logWarn("Reranking failed, returning original results:", error);
|
|
998
|
+
return results;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Multi-vector L0/L1 blend: re-score candidates using text overlap
|
|
1004
|
+
* between query and L0 abstract / L1 overview stored in metadata.
|
|
1005
|
+
*
|
|
1006
|
+
* Short/conceptual queries get higher L0/L1 weight (topic matching).
|
|
1007
|
+
* Detailed queries stay dominated by L2 main vector score.
|
|
1008
|
+
* Feature-gated: RECALLNEST_MULTI_VECTOR=true.
|
|
1009
|
+
*/
|
|
1010
|
+
private applyMultiVectorBlend(results: RetrievalResult[], query: string): RetrievalResult[] {
|
|
1011
|
+
if (!isMultiVectorEnabled() || results.length === 0) return results;
|
|
1012
|
+
|
|
1013
|
+
const qTokenCount = tokenize(query).length;
|
|
1014
|
+
const blendConfig = adaptiveBlendConfig(qTokenCount);
|
|
1015
|
+
|
|
1016
|
+
return results.map(r => {
|
|
1017
|
+
const { l0, l1 } = extractMultiVectorText(r.entry.metadata);
|
|
1018
|
+
|
|
1019
|
+
const l0Score = l0 ? textOverlapScore(query, l0) : null;
|
|
1020
|
+
const l1Score = l1 ? textOverlapScore(query, l1) : null;
|
|
1021
|
+
|
|
1022
|
+
// Skip blending if no L0/L1 available
|
|
1023
|
+
if (l0Score === null && l1Score === null) return r;
|
|
1024
|
+
|
|
1025
|
+
const blendedScore = blendMultiVectorScores(r.score, l0Score, l1Score, blendConfig);
|
|
1026
|
+
return { ...r, score: blendedScore };
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Apply recency boost: newer memories get a small score bonus.
|
|
1032
|
+
* This ensures corrections/updates naturally outrank older entries
|
|
1033
|
+
* when semantic similarity is close.
|
|
1034
|
+
* Formula: boost = exp(-ageDays / halfLife) * weight
|
|
1035
|
+
*/
|
|
1036
|
+
private applyRecencyBoost(results: RetrievalResult[]): RetrievalResult[] {
|
|
1037
|
+
const { recencyHalfLifeDays, recencyWeight } = this.config;
|
|
1038
|
+
if (!recencyHalfLifeDays || recencyHalfLifeDays <= 0 || !recencyWeight) {
|
|
1039
|
+
return results;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const now = Date.now();
|
|
1043
|
+
const boosted = results.map(r => {
|
|
1044
|
+
const ts = (r.entry.timestamp && r.entry.timestamp > 0) ? r.entry.timestamp : now;
|
|
1045
|
+
const ageDays = (now - ts) / 86_400_000;
|
|
1046
|
+
const boost = Math.exp(-ageDays / recencyHalfLifeDays) * recencyWeight;
|
|
1047
|
+
return {
|
|
1048
|
+
...r,
|
|
1049
|
+
score: clamp01(r.score + boost, r.score),
|
|
1050
|
+
};
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
return boosted.sort((a, b) => b.score - a.score);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Apply importance weighting: memories with higher importance get a score boost.
|
|
1058
|
+
* This ensures critical memories (importance=1.0) outrank casual ones (importance=0.5)
|
|
1059
|
+
* when semantic similarity is close.
|
|
1060
|
+
* Formula: score *= (baseWeight + (1 - baseWeight) * importance)
|
|
1061
|
+
* With baseWeight=0.5: importance=1.0 → ×1.0, importance=0.7 → ×0.85, importance=0.5 → ×0.75, importance=0.0 → ×0.5
|
|
1062
|
+
*/
|
|
1063
|
+
private applyImportanceWeight(results: RetrievalResult[]): RetrievalResult[] {
|
|
1064
|
+
const baseWeight = 0.5;
|
|
1065
|
+
const weighted = results.map(r => {
|
|
1066
|
+
const importance = r.entry.importance ?? 0.5;
|
|
1067
|
+
const factor = baseWeight + (1 - baseWeight) * importance;
|
|
1068
|
+
return {
|
|
1069
|
+
...r,
|
|
1070
|
+
score: clamp01(r.score * factor, r.score * baseWeight),
|
|
1071
|
+
};
|
|
1072
|
+
});
|
|
1073
|
+
return weighted.sort((a, b) => b.score - a.score);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Viewpoint confidence: corrected/contradicted memories are penalized,
|
|
1078
|
+
* confirmed memories are untouched. Same multiplicative pattern as importance.
|
|
1079
|
+
* Formula: score *= (0.5 + 0.5 * confidence)
|
|
1080
|
+
*/
|
|
1081
|
+
private applyConfidence(results: RetrievalResult[]): RetrievalResult[] {
|
|
1082
|
+
const weighted = results.map(r => ({
|
|
1083
|
+
...r,
|
|
1084
|
+
score: applyConfidenceWeight(r.score, r.entry),
|
|
1085
|
+
}));
|
|
1086
|
+
return weighted.sort((a, b) => b.score - a.score);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
private applyAssetTypeWeight(results: RetrievalResult[]): RetrievalResult[] {
|
|
1090
|
+
const weighted = results.map(r => {
|
|
1091
|
+
const metadata = parseMetadata(r.entry.metadata);
|
|
1092
|
+
const isAsset = metadata.source === "asset" || r.entry.scope.startsWith("asset:");
|
|
1093
|
+
if (!isAsset) return r;
|
|
1094
|
+
|
|
1095
|
+
const assetType = String(metadata.assetType || (r.entry.scope.startsWith("asset:brief:") ? "memory-brief" : "pinned-memory"));
|
|
1096
|
+
const factor = assetType === "memory-brief"
|
|
1097
|
+
? 0.88
|
|
1098
|
+
: assetType === "pinned-memory"
|
|
1099
|
+
? 1.04
|
|
1100
|
+
: 1.0;
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
...r,
|
|
1104
|
+
score: clamp01(r.score * factor, r.score * 0.75),
|
|
1105
|
+
};
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
return weighted.sort((a, b) => b.score - a.score);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
private applyBoundaryWeight(results: RetrievalResult[]): RetrievalResult[] {
|
|
1112
|
+
const weighted = results.map(r => {
|
|
1113
|
+
const boundary = extractBoundaryMetadata(r.entry.metadata);
|
|
1114
|
+
let factor = 1.0;
|
|
1115
|
+
|
|
1116
|
+
if (boundary?.layer === "durable") {
|
|
1117
|
+
factor = boundary.authority === "structured-memory" ? 1.03 : 1.01;
|
|
1118
|
+
} else if (boundary?.layer === "evidence") {
|
|
1119
|
+
factor = boundary.authority === "transcript-ingest" ? 0.97 : 0.99;
|
|
1120
|
+
} else if (!boundary) {
|
|
1121
|
+
if (isDurableMemoryScope(r.entry.scope)) factor = 1.01;
|
|
1122
|
+
if (isTranscriptScope(r.entry.scope)) factor = 0.99;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
...r,
|
|
1127
|
+
score: clamp01(r.score * factor, r.score * 0.85),
|
|
1128
|
+
};
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
return weighted.sort((a, b) => b.score - a.score);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Length normalization: penalize long entries that dominate search results
|
|
1136
|
+
* via sheer keyword density and broad semantic coverage.
|
|
1137
|
+
* Short, focused entries (< anchor) get a slight boost.
|
|
1138
|
+
* Long, sprawling entries (> anchor) get penalized.
|
|
1139
|
+
* Formula: score *= 1 / (1 + log2(charLen / anchor))
|
|
1140
|
+
*/
|
|
1141
|
+
private applyLengthNormalization(results: RetrievalResult[]): RetrievalResult[] {
|
|
1142
|
+
const anchor = this.config.lengthNormAnchor;
|
|
1143
|
+
if (!anchor || anchor <= 0) return results;
|
|
1144
|
+
|
|
1145
|
+
const normalized = results.map(r => {
|
|
1146
|
+
const charLen = r.entry.text.length;
|
|
1147
|
+
const ratio = charLen / anchor;
|
|
1148
|
+
// No penalty for entries at or below anchor length.
|
|
1149
|
+
// Gentle logarithmic decay for longer entries:
|
|
1150
|
+
// anchor (500) → 1.0, 800 → 0.75, 1000 → 0.67, 1500 → 0.56, 2000 → 0.50
|
|
1151
|
+
// This prevents long, keyword-rich entries from dominating top-k
|
|
1152
|
+
// while keeping their scores reasonable.
|
|
1153
|
+
const logRatio = Math.log2(Math.max(ratio, 1)); // no boost for short entries
|
|
1154
|
+
const factor = 1 / (1 + 0.5 * logRatio);
|
|
1155
|
+
return {
|
|
1156
|
+
...r,
|
|
1157
|
+
score: clamp01(r.score * factor, r.score * 0.3),
|
|
1158
|
+
};
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
return normalized.sort((a, b) => b.score - a.score);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Time decay: Weibull stretched-exponential penalty for old entries.
|
|
1166
|
+
* Borrowed from memory-lancedb-pro v1.1.0 decay engine.
|
|
1167
|
+
*
|
|
1168
|
+
* Tier-aware: Core memories decay slower (β=0.8, floor=0.85),
|
|
1169
|
+
* Working memories at standard rate (β=1.0, floor=0.65),
|
|
1170
|
+
* Peripheral memories decay faster (β=1.3, floor=0.45).
|
|
1171
|
+
*
|
|
1172
|
+
* Combined with access-tracker reinforcement: frequently recalled
|
|
1173
|
+
* memories get extended half-life regardless of tier.
|
|
1174
|
+
*/
|
|
1175
|
+
private applyTimeDecay(results: RetrievalResult[]): RetrievalResult[] {
|
|
1176
|
+
const baseHalfLife = this.config.timeDecayHalfLifeDays;
|
|
1177
|
+
if (!baseHalfLife || baseHalfLife <= 0) return results;
|
|
1178
|
+
|
|
1179
|
+
const now = Date.now();
|
|
1180
|
+
const decayed = results.map(r => {
|
|
1181
|
+
const ts = (r.entry.timestamp && r.entry.timestamp > 0) ? r.entry.timestamp : now;
|
|
1182
|
+
const ageDays = (now - ts) / 86_400_000;
|
|
1183
|
+
|
|
1184
|
+
// Use access-reinforced half-life if tracker is attached
|
|
1185
|
+
const halfLife = this.accessTracker
|
|
1186
|
+
? this.accessTracker.computeEffectiveHalfLife(baseHalfLife, r.entry.metadata)
|
|
1187
|
+
: baseHalfLife;
|
|
1188
|
+
|
|
1189
|
+
// Resolve tier from metadata (defaults to peripheral)
|
|
1190
|
+
const tier = resolveTier(r.entry.metadata);
|
|
1191
|
+
|
|
1192
|
+
// Weibull decay with tier-specific shape and floor
|
|
1193
|
+
const factor = weibullDecay(ageDays, halfLife, tier);
|
|
1194
|
+
return {
|
|
1195
|
+
...r,
|
|
1196
|
+
score: clamp01(r.score * factor, r.score * 0.3),
|
|
1197
|
+
};
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
return decayed.sort((a, b) => b.score - a.score);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Blend access-frequency hotness into retrieval scores.
|
|
1205
|
+
*
|
|
1206
|
+
* Formula: final = score * (1 - alpha) + hotness * alpha
|
|
1207
|
+
* where hotness = sigmoid(log1p(accessCount)) * exp(-decayRate * ageDays)
|
|
1208
|
+
*
|
|
1209
|
+
* No-op when hotnessWeight is 0 or AccessTracker is absent.
|
|
1210
|
+
*/
|
|
1211
|
+
private applyHotnessBlend(results: RetrievalResult[]): RetrievalResult[] {
|
|
1212
|
+
const raw = this.config.hotnessWeight;
|
|
1213
|
+
const alpha = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
|
|
1214
|
+
if (alpha <= 0 || !this.accessTracker) return results;
|
|
1215
|
+
|
|
1216
|
+
const blended = results.map((r) => {
|
|
1217
|
+
const { accessCount, lastAccessedAt } = parseAccessMetadata(r.entry.metadata);
|
|
1218
|
+
const hotness = computeHotnessScore(
|
|
1219
|
+
accessCount,
|
|
1220
|
+
lastAccessedAt || r.entry.timestamp,
|
|
1221
|
+
);
|
|
1222
|
+
return {
|
|
1223
|
+
...r,
|
|
1224
|
+
score: clamp01(r.score * (1 - alpha) + hotness * alpha, 0),
|
|
1225
|
+
};
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
return blended.sort((a, b) => b.score - a.score);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* MMR-inspired diversity filter: greedily select results that are both
|
|
1233
|
+
* relevant (high score) and diverse (low similarity to already-selected).
|
|
1234
|
+
*
|
|
1235
|
+
* Uses cosine similarity between memory vectors. If two memories have
|
|
1236
|
+
* cosine similarity > threshold (default 0.92), the lower-scored one
|
|
1237
|
+
* is demoted to the end rather than removed entirely.
|
|
1238
|
+
*
|
|
1239
|
+
* This prevents top-k from being filled with near-identical entries
|
|
1240
|
+
* (e.g. 3 similar "SVG style" memories) while keeping them available
|
|
1241
|
+
* if the pool is small.
|
|
1242
|
+
*/
|
|
1243
|
+
/**
|
|
1244
|
+
* Session-level "do not disturb": penalize suppressed memories (score × 0.3).
|
|
1245
|
+
* Inspired by dopamine-mediated transient inhibition — memory isn't deleted,
|
|
1246
|
+
* just temporarily quieted so it doesn't dominate irrelevant sessions.
|
|
1247
|
+
*/
|
|
1248
|
+
private applySessionSuppression(results: RetrievalResult[]): RetrievalResult[] {
|
|
1249
|
+
if (this.sessionSuppressed.size === 0) return results;
|
|
1250
|
+
return results.map(r =>
|
|
1251
|
+
this.sessionSuppressed.has(r.entry.id)
|
|
1252
|
+
? { ...r, score: r.score * 0.3 }
|
|
1253
|
+
: r,
|
|
1254
|
+
).sort((a, b) => b.score - a.score);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private applyMMRDiversity(results: RetrievalResult[], similarityThreshold = 0.85): RetrievalResult[] {
|
|
1258
|
+
if (results.length <= 1) return results;
|
|
1259
|
+
|
|
1260
|
+
const selected: RetrievalResult[] = [];
|
|
1261
|
+
const deferred: RetrievalResult[] = [];
|
|
1262
|
+
|
|
1263
|
+
for (const candidate of results) {
|
|
1264
|
+
// Check if this candidate is too similar to any already-selected result
|
|
1265
|
+
const tooSimilar = selected.some(s => {
|
|
1266
|
+
// Both must have vectors to compare.
|
|
1267
|
+
// LanceDB returns Arrow Vector objects (not plain arrays),
|
|
1268
|
+
// so use .length directly and Array.from() for conversion.
|
|
1269
|
+
const sVec = s.entry.vector;
|
|
1270
|
+
const cVec = candidate.entry.vector;
|
|
1271
|
+
if (!sVec?.length || !cVec?.length) return false;
|
|
1272
|
+
const sArr = Array.from(sVec as Iterable<number>);
|
|
1273
|
+
const cArr = Array.from(cVec as Iterable<number>);
|
|
1274
|
+
const sim = cosineSimilarity(sArr, cArr);
|
|
1275
|
+
return sim > similarityThreshold;
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
if (tooSimilar) {
|
|
1279
|
+
deferred.push(candidate);
|
|
1280
|
+
} else {
|
|
1281
|
+
selected.push(candidate);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
// Append deferred results at the end (available but deprioritized)
|
|
1285
|
+
return [...selected, ...deferred];
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Update configuration
|
|
1289
|
+
updateConfig(newConfig: Partial<RetrievalConfig>): void {
|
|
1290
|
+
this.config = { ...this.config, ...newConfig };
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Get current configuration
|
|
1294
|
+
getConfig(): RetrievalConfig {
|
|
1295
|
+
return { ...this.config };
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Test retrieval system
|
|
1299
|
+
async test(query = "test query"): Promise<{
|
|
1300
|
+
success: boolean;
|
|
1301
|
+
mode: string;
|
|
1302
|
+
hasFtsSupport: boolean;
|
|
1303
|
+
error?: string;
|
|
1304
|
+
}> {
|
|
1305
|
+
try {
|
|
1306
|
+
const results = await this.retrieve({
|
|
1307
|
+
query,
|
|
1308
|
+
limit: 1,
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
return {
|
|
1312
|
+
success: true,
|
|
1313
|
+
mode: this.config.mode,
|
|
1314
|
+
hasFtsSupport: this.store.hasFtsSupport,
|
|
1315
|
+
};
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
return {
|
|
1318
|
+
success: false,
|
|
1319
|
+
mode: this.config.mode,
|
|
1320
|
+
hasFtsSupport: this.store.hasFtsSupport,
|
|
1321
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// ============================================================================
|
|
1328
|
+
// Factory Function
|
|
1329
|
+
// ============================================================================
|
|
1330
|
+
|
|
1331
|
+
export function createRetriever(
|
|
1332
|
+
store: MemoryStore,
|
|
1333
|
+
embedder: Embedder,
|
|
1334
|
+
config?: Partial<RetrievalConfig>
|
|
1335
|
+
): MemoryRetriever {
|
|
1336
|
+
const fullConfig = { ...DEFAULT_RETRIEVAL_CONFIG, ...config };
|
|
1337
|
+
return new MemoryRetriever(store, embedder, fullConfig);
|
|
1338
|
+
}
|