mem-os-python 1.6.27__py3-none-any.whl
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.
- mem_os_python-1.6.27.dist-info/METADATA +1032 -0
- mem_os_python-1.6.27.dist-info/RECORD +253 -0
- mem_os_python-1.6.27.dist-info/WHEEL +4 -0
- mem_os_python-1.6.27.dist-info/entry_points.txt +2 -0
- mem_os_python-1.6.27.dist-info/licenses/LICENSE +21 -0
- memos/__init__.py +16 -0
- memos/_js/adapters/anthropic.d.ts +279 -0
- memos/_js/adapters/anthropic.d.ts.map +1 -0
- memos/_js/adapters/anthropic.js +348 -0
- memos/_js/adapters/anthropic.js.map +1 -0
- memos/_js/adapters/base.d.ts +10 -0
- memos/_js/adapters/base.d.ts.map +1 -0
- memos/_js/adapters/base.js +2 -0
- memos/_js/adapters/base.js.map +1 -0
- memos/_js/adapters/openai.d.ts +264 -0
- memos/_js/adapters/openai.d.ts.map +1 -0
- memos/_js/adapters/openai.js +342 -0
- memos/_js/adapters/openai.js.map +1 -0
- memos/_js/apps/explorer.d.ts +244 -0
- memos/_js/apps/explorer.d.ts.map +1 -0
- memos/_js/apps/explorer.js +738 -0
- memos/_js/apps/explorer.js.map +1 -0
- memos/_js/citations.d.ts +65 -0
- memos/_js/citations.d.ts.map +1 -0
- memos/_js/citations.js +143 -0
- memos/_js/citations.js.map +1 -0
- memos/_js/cli-browse.d.ts +3 -0
- memos/_js/cli-browse.d.ts.map +1 -0
- memos/_js/cli-browse.js +129 -0
- memos/_js/cli-browse.js.map +1 -0
- memos/_js/cli-completion.d.ts +31 -0
- memos/_js/cli-completion.d.ts.map +1 -0
- memos/_js/cli-completion.js +340 -0
- memos/_js/cli-completion.js.map +1 -0
- memos/_js/cli-help.d.ts +18 -0
- memos/_js/cli-help.d.ts.map +1 -0
- memos/_js/cli-help.js +357 -0
- memos/_js/cli-help.js.map +1 -0
- memos/_js/cli.d.ts +28 -0
- memos/_js/cli.d.ts.map +1 -0
- memos/_js/cli.js +2880 -0
- memos/_js/cli.js.map +1 -0
- memos/_js/compression.d.ts +25 -0
- memos/_js/compression.d.ts.map +1 -0
- memos/_js/compression.js +319 -0
- memos/_js/compression.js.map +1 -0
- memos/_js/confidence-machine.d.ts +99 -0
- memos/_js/confidence-machine.d.ts.map +1 -0
- memos/_js/confidence-machine.js +222 -0
- memos/_js/confidence-machine.js.map +1 -0
- memos/_js/context-pack.d.ts +506 -0
- memos/_js/context-pack.d.ts.map +1 -0
- memos/_js/context-pack.js +1435 -0
- memos/_js/context-pack.js.map +1 -0
- memos/_js/contradictions.d.ts +160 -0
- memos/_js/contradictions.d.ts.map +1 -0
- memos/_js/contradictions.js +255 -0
- memos/_js/contradictions.js.map +1 -0
- memos/_js/dashboard.d.ts +30 -0
- memos/_js/dashboard.d.ts.map +1 -0
- memos/_js/dashboard.js +302 -0
- memos/_js/dashboard.js.map +1 -0
- memos/_js/ebbinghaus.d.ts +29 -0
- memos/_js/ebbinghaus.d.ts.map +1 -0
- memos/_js/ebbinghaus.js +32 -0
- memos/_js/ebbinghaus.js.map +1 -0
- memos/_js/embedding-queue.d.ts +190 -0
- memos/_js/embedding-queue.d.ts.map +1 -0
- memos/_js/embedding-queue.js +316 -0
- memos/_js/embedding-queue.js.map +1 -0
- memos/_js/embeddings.d.ts +187 -0
- memos/_js/embeddings.d.ts.map +1 -0
- memos/_js/embeddings.js +633 -0
- memos/_js/embeddings.js.map +1 -0
- memos/_js/entity-extraction.d.ts +54 -0
- memos/_js/entity-extraction.d.ts.map +1 -0
- memos/_js/entity-extraction.js +325 -0
- memos/_js/entity-extraction.js.map +1 -0
- memos/_js/entity-index.d.ts +93 -0
- memos/_js/entity-index.d.ts.map +1 -0
- memos/_js/entity-index.js +165 -0
- memos/_js/entity-index.js.map +1 -0
- memos/_js/event-memory.d.ts +147 -0
- memos/_js/event-memory.d.ts.map +1 -0
- memos/_js/event-memory.js +290 -0
- memos/_js/event-memory.js.map +1 -0
- memos/_js/external-import.d.ts +98 -0
- memos/_js/external-import.d.ts.map +1 -0
- memos/_js/external-import.js +669 -0
- memos/_js/external-import.js.map +1 -0
- memos/_js/fidelity.d.ts +367 -0
- memos/_js/fidelity.d.ts.map +1 -0
- memos/_js/fidelity.js +911 -0
- memos/_js/fidelity.js.map +1 -0
- memos/_js/graph-expansion.d.ts +119 -0
- memos/_js/graph-expansion.d.ts.map +1 -0
- memos/_js/graph-expansion.js +213 -0
- memos/_js/graph-expansion.js.map +1 -0
- memos/_js/graph-mermaid.d.ts +13 -0
- memos/_js/graph-mermaid.d.ts.map +1 -0
- memos/_js/graph-mermaid.js +32 -0
- memos/_js/graph-mermaid.js.map +1 -0
- memos/_js/graph.d.ts +170 -0
- memos/_js/graph.d.ts.map +1 -0
- memos/_js/graph.js +482 -0
- memos/_js/graph.js.map +1 -0
- memos/_js/harness.d.ts +63 -0
- memos/_js/harness.d.ts.map +1 -0
- memos/_js/harness.js +93 -0
- memos/_js/harness.js.map +1 -0
- memos/_js/importance.d.ts +118 -0
- memos/_js/importance.d.ts.map +1 -0
- memos/_js/importance.js +144 -0
- memos/_js/importance.js.map +1 -0
- memos/_js/index.d.ts +51 -0
- memos/_js/index.d.ts.map +1 -0
- memos/_js/index.js +53 -0
- memos/_js/index.js.map +1 -0
- memos/_js/mcp.d.ts +58 -0
- memos/_js/mcp.d.ts.map +1 -0
- memos/_js/mcp.js +1853 -0
- memos/_js/mcp.js.map +1 -0
- memos/_js/memory.d.ts +1762 -0
- memos/_js/memory.d.ts.map +1 -0
- memos/_js/memory.js +6283 -0
- memos/_js/memory.js.map +1 -0
- memos/_js/procedural.d.ts +74 -0
- memos/_js/procedural.d.ts.map +1 -0
- memos/_js/procedural.js +125 -0
- memos/_js/procedural.js.map +1 -0
- memos/_js/project-config.d.ts +68 -0
- memos/_js/project-config.d.ts.map +1 -0
- memos/_js/project-config.js +59 -0
- memos/_js/project-config.js.map +1 -0
- memos/_js/proof-of-forgetting.d.ts +57 -0
- memos/_js/proof-of-forgetting.d.ts.map +1 -0
- memos/_js/proof-of-forgetting.js +80 -0
- memos/_js/proof-of-forgetting.js.map +1 -0
- memos/_js/provenance.d.ts +122 -0
- memos/_js/provenance.d.ts.map +1 -0
- memos/_js/provenance.js +182 -0
- memos/_js/provenance.js.map +1 -0
- memos/_js/quarantine-commands.d.ts +23 -0
- memos/_js/quarantine-commands.d.ts.map +1 -0
- memos/_js/quarantine-commands.js +42 -0
- memos/_js/quarantine-commands.js.map +1 -0
- memos/_js/quarantine.d.ts +120 -0
- memos/_js/quarantine.d.ts.map +1 -0
- memos/_js/quarantine.js +873 -0
- memos/_js/quarantine.js.map +1 -0
- memos/_js/redact.d.ts +12 -0
- memos/_js/redact.d.ts.map +1 -0
- memos/_js/redact.js +26 -0
- memos/_js/redact.js.map +1 -0
- memos/_js/repl.d.ts +57 -0
- memos/_js/repl.d.ts.map +1 -0
- memos/_js/repl.js +295 -0
- memos/_js/repl.js.map +1 -0
- memos/_js/rerank-local.d.ts +61 -0
- memos/_js/rerank-local.d.ts.map +1 -0
- memos/_js/rerank-local.js +210 -0
- memos/_js/rerank-local.js.map +1 -0
- memos/_js/retain-filter.d.ts +98 -0
- memos/_js/retain-filter.d.ts.map +1 -0
- memos/_js/retain-filter.js +232 -0
- memos/_js/retain-filter.js.map +1 -0
- memos/_js/retrieval.d.ts +265 -0
- memos/_js/retrieval.d.ts.map +1 -0
- memos/_js/retrieval.js +1018 -0
- memos/_js/retrieval.js.map +1 -0
- memos/_js/revert.d.ts +77 -0
- memos/_js/revert.d.ts.map +1 -0
- memos/_js/revert.js +252 -0
- memos/_js/revert.js.map +1 -0
- memos/_js/scope.d.ts +30 -0
- memos/_js/scope.d.ts.map +1 -0
- memos/_js/scope.js +71 -0
- memos/_js/scope.js.map +1 -0
- memos/_js/semantic-screen.d.ts +152 -0
- memos/_js/semantic-screen.d.ts.map +1 -0
- memos/_js/semantic-screen.js +290 -0
- memos/_js/semantic-screen.js.map +1 -0
- memos/_js/session-hooks.d.ts +127 -0
- memos/_js/session-hooks.d.ts.map +1 -0
- memos/_js/session-hooks.js +482 -0
- memos/_js/session-hooks.js.map +1 -0
- memos/_js/storage/sqlite.d.ts +427 -0
- memos/_js/storage/sqlite.d.ts.map +1 -0
- memos/_js/storage/sqlite.js +2428 -0
- memos/_js/storage/sqlite.js.map +1 -0
- memos/_js/sync.d.ts +213 -0
- memos/_js/sync.d.ts.map +1 -0
- memos/_js/sync.js +389 -0
- memos/_js/sync.js.map +1 -0
- memos/_js/tamper-log.d.ts +98 -0
- memos/_js/tamper-log.d.ts.map +1 -0
- memos/_js/tamper-log.js +327 -0
- memos/_js/tamper-log.js.map +1 -0
- memos/_js/temporal.d.ts +92 -0
- memos/_js/temporal.d.ts.map +1 -0
- memos/_js/temporal.js +536 -0
- memos/_js/temporal.js.map +1 -0
- memos/_js/time-travel.d.ts +38 -0
- memos/_js/time-travel.d.ts.map +1 -0
- memos/_js/time-travel.js +46 -0
- memos/_js/time-travel.js.map +1 -0
- memos/_js/timestamp-format.d.ts +18 -0
- memos/_js/timestamp-format.d.ts.map +1 -0
- memos/_js/timestamp-format.js +52 -0
- memos/_js/timestamp-format.js.map +1 -0
- memos/_js/triple-extraction.d.ts +31 -0
- memos/_js/triple-extraction.d.ts.map +1 -0
- memos/_js/triple-extraction.js +116 -0
- memos/_js/triple-extraction.js.map +1 -0
- memos/_js/types.d.ts +2474 -0
- memos/_js/types.d.ts.map +1 -0
- memos/_js/types.js +20 -0
- memos/_js/types.js.map +1 -0
- memos/_js/updater.d.ts +95 -0
- memos/_js/updater.d.ts.map +1 -0
- memos/_js/updater.js +268 -0
- memos/_js/updater.js.map +1 -0
- memos/_js/version.d.ts +17 -0
- memos/_js/version.d.ts.map +1 -0
- memos/_js/version.js +34 -0
- memos/_js/version.js.map +1 -0
- memos/_js/vision/clip-query-cache.d.ts +58 -0
- memos/_js/vision/clip-query-cache.d.ts.map +1 -0
- memos/_js/vision/clip-query-cache.js +79 -0
- memos/_js/vision/clip-query-cache.js.map +1 -0
- memos/_js/vision/clip.d.ts +113 -0
- memos/_js/vision/clip.d.ts.map +1 -0
- memos/_js/vision/clip.js +224 -0
- memos/_js/vision/clip.js.map +1 -0
- memos/_js/vision/fake-clip.d.ts +55 -0
- memos/_js/vision/fake-clip.d.ts.map +1 -0
- memos/_js/vision/fake-clip.js +122 -0
- memos/_js/vision/fake-clip.js.map +1 -0
- memos/_js/vision/index.d.ts +15 -0
- memos/_js/vision/index.d.ts.map +1 -0
- memos/_js/vision/index.js +13 -0
- memos/_js/vision/index.js.map +1 -0
- memos/_js/vision/store.d.ts +137 -0
- memos/_js/vision/store.d.ts.map +1 -0
- memos/_js/vision/store.js +383 -0
- memos/_js/vision/store.js.map +1 -0
- memos/adapters/__init__.py +0 -0
- memos/adapters/crewai.py +385 -0
- memos/adapters/langchain.py +301 -0
- memos/adapters/ollama.py +396 -0
- memos/server/__init__.py +0 -0
- memos/server/main.py +547 -0
- memos/server/routes.py +360 -0
memos/_js/retrieval.js
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval fusion — hybrid search result merging.
|
|
3
|
+
*
|
|
4
|
+
* Two fusion algorithms (see `FusionOptions.fusionMode`):
|
|
5
|
+
*
|
|
6
|
+
* - RRF (weighted Reciprocal Rank Fusion): each leg votes by RANK
|
|
7
|
+
* (`weight / (K + rank)`), robust to score outliers that would
|
|
8
|
+
* otherwise dominate a weighted sum. K=60 is the standard constant
|
|
9
|
+
* from the RRF paper (Cormack et al., 2009).
|
|
10
|
+
* - Convex combination (default since round 4): min-max normalize each
|
|
11
|
+
* leg's native scores to [0,1], then take the weighted sum. A dominant
|
|
12
|
+
* absolute score (near-exact embedding match, runaway bm25 hit) keeps
|
|
13
|
+
* its magnitude instead of being flattened to a rank vote. A Sept 2026
|
|
14
|
+
* TOIS analysis found convex combination beats RRF in- and
|
|
15
|
+
* out-of-domain; Weaviate made the same switch.
|
|
16
|
+
*
|
|
17
|
+
* The keyword leg carries the larger weight by default (0.8 vs 0.2):
|
|
18
|
+
* exact term matches are highly reliable for factoid memory queries,
|
|
19
|
+
* while embeddings rescue paraphrases that share no vocabulary.
|
|
20
|
+
*/
|
|
21
|
+
import { confidenceWeight } from "./confidence-machine.js";
|
|
22
|
+
import { entityOverlap } from "./entity-extraction.js";
|
|
23
|
+
import { DEFAULT_PROVENANCE_WEIGHT_STRENGTH, isProvenanceTier, provenanceMultiplier, } from "./provenance.js";
|
|
24
|
+
/** Default RRF constant (Cormack et al., 2009). */
|
|
25
|
+
export const DEFAULT_RRF_K = 60;
|
|
26
|
+
/**
|
|
27
|
+
* Default fusion algorithm (round 4). `"convex"` min-max normalizes each
|
|
28
|
+
* leg's native scores and takes the weighted sum; `"rrf"` restores the
|
|
29
|
+
* legacy rank-vote behavior.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_FUSION_MODE = "convex";
|
|
32
|
+
/**
|
|
33
|
+
* Temporal markers that switch on the recency multiplier for a query
|
|
34
|
+
* (round 4 adaptive weighting). Deliberately narrow: generic nouns like
|
|
35
|
+
* "week" alone don't trigger it, only explicit temporal constructions.
|
|
36
|
+
*/
|
|
37
|
+
export const TEMPORAL_QUERY_RE = /\b(today|yesterday|tomorrow|tonight|recent|recently|latest|last\s+(night|week|month|year)|next\s+(week|month|year)|\d+\s+(days?|weeks?|months?)\s+ago|monday|tuesday|wednesday|thursday|friday|saturday|sunday|january|february|march|april|may|june|july|august|september|october|november|december)\b/i;
|
|
38
|
+
/** Default strength of the temporal-query recency multiplier (≤5%). */
|
|
39
|
+
export const DEFAULT_TEMPORAL_RECENCY_STRENGTH = 0.05;
|
|
40
|
+
/**
|
|
41
|
+
* Stopwords for RM3-lite term harvesting — small, generic, English.
|
|
42
|
+
* Kept local (not shared with the entity extractor) so the PRF
|
|
43
|
+
* vocabulary stays independent of entity-extraction tuning.
|
|
44
|
+
*/
|
|
45
|
+
const PRF_STOPWORDS = new Set([
|
|
46
|
+
"the",
|
|
47
|
+
"a",
|
|
48
|
+
"an",
|
|
49
|
+
"and",
|
|
50
|
+
"or",
|
|
51
|
+
"but",
|
|
52
|
+
"of",
|
|
53
|
+
"to",
|
|
54
|
+
"in",
|
|
55
|
+
"on",
|
|
56
|
+
"for",
|
|
57
|
+
"with",
|
|
58
|
+
"is",
|
|
59
|
+
"are",
|
|
60
|
+
"was",
|
|
61
|
+
"were",
|
|
62
|
+
"be",
|
|
63
|
+
"been",
|
|
64
|
+
"being",
|
|
65
|
+
"it",
|
|
66
|
+
"its",
|
|
67
|
+
"this",
|
|
68
|
+
"that",
|
|
69
|
+
"these",
|
|
70
|
+
"those",
|
|
71
|
+
"i",
|
|
72
|
+
"you",
|
|
73
|
+
"he",
|
|
74
|
+
"she",
|
|
75
|
+
"we",
|
|
76
|
+
"they",
|
|
77
|
+
"me",
|
|
78
|
+
"him",
|
|
79
|
+
"her",
|
|
80
|
+
"us",
|
|
81
|
+
"them",
|
|
82
|
+
"my",
|
|
83
|
+
"your",
|
|
84
|
+
"his",
|
|
85
|
+
"our",
|
|
86
|
+
"their",
|
|
87
|
+
"as",
|
|
88
|
+
"at",
|
|
89
|
+
"by",
|
|
90
|
+
"from",
|
|
91
|
+
"into",
|
|
92
|
+
"about",
|
|
93
|
+
"what",
|
|
94
|
+
"which",
|
|
95
|
+
"who",
|
|
96
|
+
"whom",
|
|
97
|
+
"whose",
|
|
98
|
+
"when",
|
|
99
|
+
"where",
|
|
100
|
+
"why",
|
|
101
|
+
"how",
|
|
102
|
+
"does",
|
|
103
|
+
"did",
|
|
104
|
+
"do",
|
|
105
|
+
"have",
|
|
106
|
+
"has",
|
|
107
|
+
"had",
|
|
108
|
+
"not",
|
|
109
|
+
"no",
|
|
110
|
+
"yes",
|
|
111
|
+
"can",
|
|
112
|
+
"could",
|
|
113
|
+
"should",
|
|
114
|
+
"would",
|
|
115
|
+
"will",
|
|
116
|
+
"just",
|
|
117
|
+
"than",
|
|
118
|
+
"then",
|
|
119
|
+
"there",
|
|
120
|
+
"here",
|
|
121
|
+
"all",
|
|
122
|
+
"any",
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* Tokenize for PRF: lowercase alphanumeric tokens, stopwords and
|
|
126
|
+
* short tokens dropped.
|
|
127
|
+
*/
|
|
128
|
+
export function prfTokenize(text) {
|
|
129
|
+
return text
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.split(/[^a-z0-9]+/)
|
|
132
|
+
.filter((t) => t.length >= 3 && !PRF_STOPWORDS.has(t));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* RM3-lite pseudo-relevance feedback term harvesting (round 4).
|
|
136
|
+
*
|
|
137
|
+
* Pure function: given the top semantic-leg hits for a query, harvest
|
|
138
|
+
* up to `maxTerms` recurring content terms that are NOT already in the
|
|
139
|
+
* query, ranked by frequency across the hits. The caller re-probes FTS5
|
|
140
|
+
* with these terms as a second keyword pass whose results are UNIONED
|
|
141
|
+
* into the keyword leg — expansion can only add recall, never remove
|
|
142
|
+
* candidates.
|
|
143
|
+
*
|
|
144
|
+
* Deterministic: no model, no sampling — same hits always yield the
|
|
145
|
+
* same terms. Gated by the caller to short queries (≤3 terms), where
|
|
146
|
+
* vocabulary mismatch hurts most.
|
|
147
|
+
*
|
|
148
|
+
* @param hits — Top semantic-leg results (best first); callers pass ~5.
|
|
149
|
+
* @param queryTerms — Tokenized query terms (from `prfTokenize`).
|
|
150
|
+
* @param maxTerms — Cap on harvested terms. Default 3.
|
|
151
|
+
*/
|
|
152
|
+
export function harvestPrfTerms(hits, queryTerms, maxTerms = 3) {
|
|
153
|
+
const querySet = new Set(queryTerms);
|
|
154
|
+
const freq = new Map();
|
|
155
|
+
for (const hit of hits) {
|
|
156
|
+
const text = `${hit.node.summary ?? ""} ${hit.node.content}`;
|
|
157
|
+
for (const tok of prfTokenize(text)) {
|
|
158
|
+
if (querySet.has(tok))
|
|
159
|
+
continue;
|
|
160
|
+
freq.set(tok, (freq.get(tok) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return [...freq.entries()]
|
|
164
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
165
|
+
.slice(0, Math.max(0, maxTerms))
|
|
166
|
+
.map(([term]) => term);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Merge an expansion keyword pass into the primary keyword leg (union
|
|
170
|
+
* semantics). Candidates already in the primary leg keep their original
|
|
171
|
+
* score; expansion-only candidates append after.
|
|
172
|
+
*
|
|
173
|
+
* The expansion query is a different query (harvested terms only), so its
|
|
174
|
+
* bm25 scores are NOT comparable to the primary query's: an expansion hit
|
|
175
|
+
* matching three harvested terms will outscore a primary hit on raw bm25
|
|
176
|
+
* even though the primary query is the user's actual intent. To keep PRF
|
|
177
|
+
* a pure recall booster (never a re-ranker), expansion-only candidates are
|
|
178
|
+
* demoted below every primary hit (FTS5 bm25: lower/more-negative is
|
|
179
|
+
* better, so they take worst-primary + 1); they tie among themselves and
|
|
180
|
+
* order deterministically by node id thereafter.
|
|
181
|
+
*/
|
|
182
|
+
export function mergeKeywordLegs(primary, expansion) {
|
|
183
|
+
if (expansion.length === 0)
|
|
184
|
+
return primary;
|
|
185
|
+
const seen = new Set(primary.map((r) => r.node.id));
|
|
186
|
+
// FTS5 bm25 rank: lower (more negative) is better. Expansion-only hits
|
|
187
|
+
// take worst-primary + 1 so no expansion score can outrank a primary hit.
|
|
188
|
+
const worstPrimary = primary.length > 0
|
|
189
|
+
? Math.max(...primary.map((r) => r.score))
|
|
190
|
+
: Number.NEGATIVE_INFINITY;
|
|
191
|
+
const merged = [...primary];
|
|
192
|
+
for (const r of expansion) {
|
|
193
|
+
if (!seen.has(r.node.id)) {
|
|
194
|
+
seen.add(r.node.id);
|
|
195
|
+
merged.push(primary.length > 0
|
|
196
|
+
? { node: r.node, score: worstPrimary + 1.0, scores: r.scores }
|
|
197
|
+
: r);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return merged;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Stable ordering for scored memories: descending score, node id as the
|
|
204
|
+
* final tiebreak. The id tiebreak keeps serialized output byte-identical
|
|
205
|
+
* for identical result sets — without it, equal scores surface
|
|
206
|
+
* input/iteration-order noise, which breaks LLM prompt-cache prefixes
|
|
207
|
+
* (providers only discount cached input when the prefix is byte-stable).
|
|
208
|
+
*/
|
|
209
|
+
export function compareScoredMemories(a, b) {
|
|
210
|
+
if (b.score !== a.score)
|
|
211
|
+
return b.score - a.score;
|
|
212
|
+
return a.node.id < b.node.id ? -1 : a.node.id > b.node.id ? 1 : 0;
|
|
213
|
+
}
|
|
214
|
+
/** Default weight for the keyword (FTS5) leg. */
|
|
215
|
+
export const DEFAULT_KEYWORD_WEIGHT = 0.8;
|
|
216
|
+
/** Default weight for the semantic (embedding) leg. */
|
|
217
|
+
export const DEFAULT_SEMANTIC_WEIGHT = 0.2;
|
|
218
|
+
/**
|
|
219
|
+
* Default strength of the entity-overlap boost — the third retrieval
|
|
220
|
+
* signal (semantic + keyword + entity match). Applied as a post-fusion
|
|
221
|
+
* multiplier `score *= 1 + entityWeight * overlap`, so it nudges rather
|
|
222
|
+
* than dominates; only active when `queryEntities` is non-empty.
|
|
223
|
+
*/
|
|
224
|
+
export const DEFAULT_ENTITY_WEIGHT = 0.15;
|
|
225
|
+
/**
|
|
226
|
+
* ── item2: entity leg ── default weight of the entity-inverted-index RRF
|
|
227
|
+
* leg (`entityResults`). Deliberately subordinate to the semantic leg:
|
|
228
|
+
* rank-1 in the entity leg scores `0.1 / (rrfK + 1)`, below a
|
|
229
|
+
* semantic-only rank-1 (`0.2 / (rrfK + 1)`). The extractor is lexical,
|
|
230
|
+
* so on dialogue-heavy corpora a 0.5 weight let entity-matched
|
|
231
|
+
* near-duplicates outrank genuine semantic hits — measured as a LoCoMo
|
|
232
|
+
* nDCG regression at 0.5, neutral at 0.1. The leg's job is recall
|
|
233
|
+
* (surfacing entity-matched memories the other legs missed), not
|
|
234
|
+
* re-ranking.
|
|
235
|
+
*/
|
|
236
|
+
export const DEFAULT_ENTITY_LEG_WEIGHT = 0.1;
|
|
237
|
+
/**
|
|
238
|
+
* Default weight of the CLIP image leg (Track B of image memory).
|
|
239
|
+
*
|
|
240
|
+
* Same role as the entity leg — a RECALL leg that surfaces candidates
|
|
241
|
+
* the text legs could not match, not a re-ranking signal — so it sits
|
|
242
|
+
* just below the semantic leg's 0.2, at the entity leg's level. The
|
|
243
|
+
* leg is inert whenever it has no results (no CLIP configured, no image
|
|
244
|
+
* embeddings stored), so this default never moves a text-only store's
|
|
245
|
+
* ranking by a single bit.
|
|
246
|
+
*/
|
|
247
|
+
export const DEFAULT_IMAGE_LEG_WEIGHT = 0.1;
|
|
248
|
+
/**
|
|
249
|
+
* Lower bound of the trust multiplier. A memory with trustScore 0 is
|
|
250
|
+
* multiplied by `trustFloor`; trustScore 1 by 1.0. Kept gentle (0.7)
|
|
251
|
+
* so trust nudges ranking without dominating pure relevance.
|
|
252
|
+
*/
|
|
253
|
+
export const DEFAULT_TRUST_FLOOR = 0.7;
|
|
254
|
+
/**
|
|
255
|
+
* Weight of the confidence multiplier applied AFTER fusion. The
|
|
256
|
+
* confidence state machine (`src/confidence-machine.ts`) tracks how
|
|
257
|
+
* consistently a memory has been reinforced vs contradicted over time;
|
|
258
|
+
* `confidenceWeight()` combines that with trust via a geometric mean
|
|
259
|
+
* and heavily suppresses memories below the confidence floor (e.g.
|
|
260
|
+
* superseded or contradicted facts). Applied as
|
|
261
|
+
* `score *= floor + weight * (combinedWeight - floor)` — gentle by
|
|
262
|
+
* default so relevance ordering dominates unless evidence says
|
|
263
|
+
* otherwise. Set to `0` to disable confidence-aware ranking.
|
|
264
|
+
*/
|
|
265
|
+
export const DEFAULT_CONFIDENCE_WEIGHT = 0.35;
|
|
266
|
+
/**
|
|
267
|
+
* Half-life for the recency tie-break, in milliseconds. Among fused
|
|
268
|
+
* candidates whose scores are within `RECENCY_EPSILON` of each other,
|
|
269
|
+
* the newer memory wins; the boost decays with an exponential half-life
|
|
270
|
+
* (default 30 days) so it only ever re-orders near-ties, never overrides
|
|
271
|
+
* a clear relevance gap. This lifts "Knowledge Update" / "Temporal
|
|
272
|
+
* Reasoning" style queries where two versions of a fact both match but
|
|
273
|
+
* only the current one should surface first. Set to `0` to disable.
|
|
274
|
+
*/
|
|
275
|
+
export const DEFAULT_RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
276
|
+
/**
|
|
277
|
+
* Score window in which candidates are considered TIED for recency
|
|
278
|
+
* tie-breaking. This is a float-noise window (~1e-9), NOT a fuzzy band:
|
|
279
|
+
* adjacent RRF ranks differ by ~w/(K+i)² ≈ 2e-4, three orders of
|
|
280
|
+
* magnitude above this threshold, so genuine rank separation always
|
|
281
|
+
* holds and recency only arbitrates candidates whose fused contributions
|
|
282
|
+
* are mathematically indistinguishable (e.g. rank-1 in the keyword leg
|
|
283
|
+
* vs rank-1 in the semantic leg under equal weights, or duplicate
|
|
284
|
+
* facts ingested twice).
|
|
285
|
+
*/
|
|
286
|
+
export const RECENCY_EPSILON = 1e-9;
|
|
287
|
+
/**
|
|
288
|
+
* Legacy rank-vote fusion: weighted Reciprocal Rank Fusion over the
|
|
289
|
+
* three legs. Extracted verbatim from the pre-round-4 `fuseResults`
|
|
290
|
+
* body; selected via `fusionMode: "rrf"`.
|
|
291
|
+
*/
|
|
292
|
+
function rrfFuse(keywordResults, semanticResults, entityResults, options, imageResults = [], imageLegWeight = 0) {
|
|
293
|
+
const rrfK = options.rrfK ?? DEFAULT_RRF_K;
|
|
294
|
+
const keywordWeight = options.keywordWeight ?? DEFAULT_KEYWORD_WEIGHT;
|
|
295
|
+
const semanticWeight = options.semanticWeight ?? DEFAULT_SEMANTIC_WEIGHT;
|
|
296
|
+
const merged = new Map();
|
|
297
|
+
for (const [index, result] of keywordResults.entries()) {
|
|
298
|
+
const keywordRrf = keywordWeight / (rrfK + index + 1);
|
|
299
|
+
merged.set(result.node.id, {
|
|
300
|
+
node: result.node,
|
|
301
|
+
score: keywordRrf,
|
|
302
|
+
scores: { keyword: 1 - index / Math.max(keywordResults.length, 1) },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
for (const [index, result] of semanticResults.entries()) {
|
|
306
|
+
const semanticRrf = semanticWeight / (rrfK + index + 1);
|
|
307
|
+
const existing = merged.get(result.node.id);
|
|
308
|
+
if (existing) {
|
|
309
|
+
existing.score += semanticRrf;
|
|
310
|
+
existing.scores.semantic = Math.max(0, result.score);
|
|
311
|
+
// RRF stores the RAW cosine in `semantic` (ranks, not magnitudes,
|
|
312
|
+
// drive the vote here). Mirror it into `semanticRaw` so both fusion
|
|
313
|
+
// modes expose the same score shape and consumers need one branch.
|
|
314
|
+
existing.scores.semanticRaw = Math.max(0, result.score);
|
|
315
|
+
existing.scores.hybrid = existing.score;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
merged.set(result.node.id, {
|
|
319
|
+
node: result.node,
|
|
320
|
+
score: semanticRrf,
|
|
321
|
+
scores: {
|
|
322
|
+
keyword: 0,
|
|
323
|
+
semantic: Math.max(0, result.score),
|
|
324
|
+
semanticRaw: Math.max(0, result.score),
|
|
325
|
+
hybrid: semanticRrf,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// ── item2: entity leg ── third RRF leg from the entity→memories
|
|
331
|
+
// inverted index (`src/entity-index.ts`). Unlike the multiplicative
|
|
332
|
+
// entity-overlap boost below — which only re-scores candidates the
|
|
333
|
+
// keyword/semantic legs already retrieved — this leg ADDS RECALL:
|
|
334
|
+
// entity-matched memories that FTS and embeddings both missed enter
|
|
335
|
+
// the candidate pool here.
|
|
336
|
+
//
|
|
337
|
+
// Score-breakdown naming: this leg records `scores.entityLeg` (the
|
|
338
|
+
// per-candidate matched-entity count feeding the RRF vote), while the
|
|
339
|
+
// boost below records `scores.entity` (the [0,1] query/candidate
|
|
340
|
+
// entity-overlap multiplier). Same signal family, different roles —
|
|
341
|
+
// recall vs scoring.
|
|
342
|
+
const entityLegWeight = options.entityLegWeight ?? DEFAULT_ENTITY_LEG_WEIGHT;
|
|
343
|
+
if (entityLegWeight > 0 && entityResults.length > 0) {
|
|
344
|
+
for (const [index, result] of entityResults.entries()) {
|
|
345
|
+
const entityRrf = entityLegWeight / (rrfK + index + 1);
|
|
346
|
+
const existing = merged.get(result.node.id);
|
|
347
|
+
if (existing) {
|
|
348
|
+
existing.score += entityRrf;
|
|
349
|
+
existing.scores.entityLeg = Math.max(0, result.score);
|
|
350
|
+
existing.scores.hybrid = existing.score;
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
merged.set(result.node.id, {
|
|
354
|
+
node: result.node,
|
|
355
|
+
score: entityRrf,
|
|
356
|
+
scores: {
|
|
357
|
+
keyword: 0,
|
|
358
|
+
semantic: 0,
|
|
359
|
+
entityLeg: Math.max(0, result.score),
|
|
360
|
+
hybrid: entityRrf,
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// ── image leg (Track B, image memory) ── fourth RRF leg from the
|
|
367
|
+
// CLIP image→text space (`image_embeddings`, scanned by
|
|
368
|
+
// `querySimilarImages`). It ADDS RECALL exactly like the entity leg:
|
|
369
|
+
// an image whose caption shares no vocabulary with the query still
|
|
370
|
+
// enters the candidate pool because its PIXELS match. Records
|
|
371
|
+
// `scores.imageLeg` (the raw CLIP cosine) so a consumer can tell
|
|
372
|
+
// which leg surfaced an image.
|
|
373
|
+
//
|
|
374
|
+
// Inert by construction: an empty leg (no CLIP configured, no image
|
|
375
|
+
// embeddings stored, or Track B disabled) contributes nothing and
|
|
376
|
+
// leaves the merged map byte-identical to the pre-image pipeline.
|
|
377
|
+
if (imageLegWeight > 0 && imageResults.length > 0) {
|
|
378
|
+
for (const [index, result] of imageResults.entries()) {
|
|
379
|
+
const imageRrf = imageLegWeight / (rrfK + index + 1);
|
|
380
|
+
const existing = merged.get(result.node.id);
|
|
381
|
+
if (existing) {
|
|
382
|
+
existing.score += imageRrf;
|
|
383
|
+
existing.scores.imageLeg = Math.max(0, result.score);
|
|
384
|
+
existing.scores.hybrid = existing.score;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
merged.set(result.node.id, {
|
|
388
|
+
node: result.node,
|
|
389
|
+
score: imageRrf,
|
|
390
|
+
scores: {
|
|
391
|
+
keyword: 0,
|
|
392
|
+
semantic: 0,
|
|
393
|
+
imageLeg: Math.max(0, result.score),
|
|
394
|
+
hybrid: imageRrf,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return merged;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Min-max normalize one leg's native scores to [0,1].
|
|
404
|
+
*
|
|
405
|
+
* `higherIsBetter=false` inverts the scale (FTS5 bm25 rank: the more
|
|
406
|
+
* negative, the better the match); true for the semantic (cosine) and
|
|
407
|
+
* entity (match-count) legs. A degenerate leg (empty, or all scores
|
|
408
|
+
* identical) maps its candidates to 1.0 — the leg's top pick still
|
|
409
|
+
* counts as a full vote rather than vanishing. Duplicate ids within a
|
|
410
|
+
* leg keep the best normalized score.
|
|
411
|
+
*/
|
|
412
|
+
function minMaxNormalizeLeg(results, higherIsBetter) {
|
|
413
|
+
const out = new Map();
|
|
414
|
+
if (results.length === 0)
|
|
415
|
+
return out;
|
|
416
|
+
let min = Infinity;
|
|
417
|
+
let max = -Infinity;
|
|
418
|
+
for (const r of results) {
|
|
419
|
+
if (r.score < min)
|
|
420
|
+
min = r.score;
|
|
421
|
+
if (r.score > max)
|
|
422
|
+
max = r.score;
|
|
423
|
+
}
|
|
424
|
+
for (const r of results) {
|
|
425
|
+
let n;
|
|
426
|
+
if (!(max > min)) {
|
|
427
|
+
n = 1;
|
|
428
|
+
}
|
|
429
|
+
else if (higherIsBetter) {
|
|
430
|
+
n = (r.score - min) / (max - min);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
n = (max - r.score) / (max - min);
|
|
434
|
+
}
|
|
435
|
+
const prev = out.get(r.node.id);
|
|
436
|
+
if (prev === undefined || n > prev)
|
|
437
|
+
out.set(r.node.id, n);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Deterministic query-type adaptive leg weighting (round 4).
|
|
443
|
+
*
|
|
444
|
+
* Tiny rule-based router — no model, no learned parameters, fully
|
|
445
|
+
* explainable:
|
|
446
|
+
* - Short queries (≤3 tokens): BM25 is the reliable leg (embeddings are
|
|
447
|
+
* noisier on keyword-style lookups), so the keyword weight rises.
|
|
448
|
+
* - Entity-dense queries (≥2 extracted entities): the entity leg is
|
|
449
|
+
* high-precision here, so its weight doubles (capped).
|
|
450
|
+
* - Temporal markers (today, yesterday, last week, …): flags the query
|
|
451
|
+
* so `convexFuse` applies the bounded recency multiplier.
|
|
452
|
+
*
|
|
453
|
+
* Weights are renormalized to sum to 1 afterwards. All adjustments are
|
|
454
|
+
* gentle (≤1.25× / ≤2×) so a misfire can only nudge, never dominate.
|
|
455
|
+
* Disabled wholesale via `adaptiveWeighting: false`.
|
|
456
|
+
*/
|
|
457
|
+
function adaptLegWeights(query, queryEntities, base) {
|
|
458
|
+
let { keyword, semantic, entityLeg } = base;
|
|
459
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
460
|
+
// Short queries (≤3 tokens) are keyword-style lookups; the keyword
|
|
461
|
+
// leg's magnitude gaps are meaningful here, so it gets more weight.
|
|
462
|
+
if (tokens.length > 0 && tokens.length <= 3) {
|
|
463
|
+
keyword *= 1.25;
|
|
464
|
+
}
|
|
465
|
+
if (queryEntities.length >= 2) {
|
|
466
|
+
entityLeg = Math.min(0.4, entityLeg * 2);
|
|
467
|
+
}
|
|
468
|
+
const temporal = TEMPORAL_QUERY_RE.test(query);
|
|
469
|
+
const sum = keyword + semantic + entityLeg;
|
|
470
|
+
if (sum > 0) {
|
|
471
|
+
keyword /= sum;
|
|
472
|
+
semantic /= sum;
|
|
473
|
+
entityLeg /= sum;
|
|
474
|
+
}
|
|
475
|
+
return { keyword, semantic, entityLeg, temporal };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Convex-combination fusion (round 4 default).
|
|
479
|
+
*
|
|
480
|
+
* Each leg's native scores are min-max normalized to [0,1], then
|
|
481
|
+
* combined as `Σ w_leg · norm_leg` with the (adaptively adjusted,
|
|
482
|
+
* renormalized) leg weights. Unlike RRF's rank votes, a dominant
|
|
483
|
+
* absolute score keeps its magnitude — a near-exact embedding match or
|
|
484
|
+
* a runaway bm25 hit outranks a candidate that merely placed well in a
|
|
485
|
+
* weak leg.
|
|
486
|
+
*
|
|
487
|
+
* The entity leg still ADDS RECALL exactly like the RRF path: normalized
|
|
488
|
+
* entity matches enter the pool even when FTS and embeddings missed
|
|
489
|
+
* them. Temporal queries additionally get the bounded recency
|
|
490
|
+
* multiplier (`1 + strength · 0.5^(age/halfLife)`).
|
|
491
|
+
*/
|
|
492
|
+
function convexFuse(keywordResults, semanticResults, entityResults, options, imageResults = [], imageLegWeight = 0) {
|
|
493
|
+
const queryEntities = options.queryEntities ?? [];
|
|
494
|
+
const base = {
|
|
495
|
+
keyword: options.keywordWeight ?? DEFAULT_KEYWORD_WEIGHT,
|
|
496
|
+
semantic: options.semanticWeight ?? DEFAULT_SEMANTIC_WEIGHT,
|
|
497
|
+
entityLeg: options.entityLegWeight ?? DEFAULT_ENTITY_LEG_WEIGHT,
|
|
498
|
+
};
|
|
499
|
+
const adaptive = options.adaptiveWeighting ?? true;
|
|
500
|
+
const weights = adaptive && options.query
|
|
501
|
+
? adaptLegWeights(options.query, queryEntities, base)
|
|
502
|
+
: {
|
|
503
|
+
keyword: base.keyword,
|
|
504
|
+
semantic: base.semantic,
|
|
505
|
+
entityLeg: base.entityLeg,
|
|
506
|
+
temporal: false,
|
|
507
|
+
};
|
|
508
|
+
// Renormalize even the non-adaptive path: convex combination needs
|
|
509
|
+
// weights on a common scale, unlike RRF's independent rank votes.
|
|
510
|
+
// The image leg joins the same renormalization ONLY when it is active,
|
|
511
|
+
// so a text-only store's wk/ws/we are unchanged to the bit.
|
|
512
|
+
const imageActive = imageResults.length > 0 && imageLegWeight > 0;
|
|
513
|
+
const wSum = weights.keyword +
|
|
514
|
+
weights.semantic +
|
|
515
|
+
weights.entityLeg +
|
|
516
|
+
(imageActive ? imageLegWeight : 0);
|
|
517
|
+
const wk = wSum > 0 ? weights.keyword / wSum : 0;
|
|
518
|
+
const ws = wSum > 0 ? weights.semantic / wSum : 0;
|
|
519
|
+
const we = wSum > 0 ? weights.entityLeg / wSum : 0;
|
|
520
|
+
const wi = wSum > 0 && imageActive ? imageLegWeight / wSum : 0;
|
|
521
|
+
// All legs: min-max on native scores. Proven on the deterministic eval
|
|
522
|
+
// (MRR 0.9688 vs 0.875 for rank-discount keyword): with weak local-hash
|
|
523
|
+
// embeddings the keyword leg's magnitude gaps carry the signal, and
|
|
524
|
+
// min-max preserves them. (bm25 magnitudes are query-dependent, but the
|
|
525
|
+
// eval shows the trade-off favors magnitude-awareness here.)
|
|
526
|
+
const keywordNorm = minMaxNormalizeLeg(keywordResults, false);
|
|
527
|
+
const semanticNorm = minMaxNormalizeLeg(semanticResults, true);
|
|
528
|
+
const entityNorm = minMaxNormalizeLeg(entityResults, true);
|
|
529
|
+
const imageNorm = imageActive
|
|
530
|
+
? minMaxNormalizeLeg(imageResults, true)
|
|
531
|
+
: new Map();
|
|
532
|
+
const byId = new Map();
|
|
533
|
+
for (const r of keywordResults)
|
|
534
|
+
byId.set(r.node.id, r);
|
|
535
|
+
for (const r of semanticResults)
|
|
536
|
+
byId.set(r.node.id, r);
|
|
537
|
+
for (const r of entityResults)
|
|
538
|
+
byId.set(r.node.id, r);
|
|
539
|
+
for (const r of imageResults)
|
|
540
|
+
byId.set(r.node.id, r);
|
|
541
|
+
const merged = new Map();
|
|
542
|
+
const addLeg = (norm, weight, legName) => {
|
|
543
|
+
if (weight <= 0)
|
|
544
|
+
return;
|
|
545
|
+
for (const [id, n] of norm) {
|
|
546
|
+
const r = byId.get(id);
|
|
547
|
+
if (!r)
|
|
548
|
+
continue;
|
|
549
|
+
const contrib = weight * n;
|
|
550
|
+
const existing = merged.get(id);
|
|
551
|
+
if (existing) {
|
|
552
|
+
existing.score += contrib;
|
|
553
|
+
existing.scores[legName] = n;
|
|
554
|
+
// Preserve the raw leg score alongside the normalized fusion
|
|
555
|
+
// weight: `n` is min-max normalized (top candidate == 1), so it
|
|
556
|
+
// cannot answer "how similar was this actually?". The raw cosine
|
|
557
|
+
// is what `shouldAbstain`'s absolute floor needs.
|
|
558
|
+
if (legName === "semantic") {
|
|
559
|
+
existing.scores.semanticRaw = Math.max(0, r.score);
|
|
560
|
+
}
|
|
561
|
+
existing.scores.hybrid = existing.score;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// Scores breakdown mirrors the pre-round-4 shape: a leg's key is
|
|
565
|
+
// present only when the candidate appeared in that leg (never a
|
|
566
|
+
// zero placeholder), so `scores.entityLeg === undefined` still
|
|
567
|
+
// means "the entity leg did not retrieve this candidate".
|
|
568
|
+
merged.set(id, {
|
|
569
|
+
node: r.node,
|
|
570
|
+
score: contrib,
|
|
571
|
+
scores: {
|
|
572
|
+
[legName]: n,
|
|
573
|
+
...(legName === "semantic"
|
|
574
|
+
? { semanticRaw: Math.max(0, r.score) }
|
|
575
|
+
: {}),
|
|
576
|
+
hybrid: contrib,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
addLeg(keywordNorm, wk, "keyword");
|
|
583
|
+
addLeg(semanticNorm, ws, "semantic");
|
|
584
|
+
addLeg(entityNorm, we, "entityLeg");
|
|
585
|
+
// Image (CLIP) leg — added last, with its own renormalized weight.
|
|
586
|
+
// Inert when `wi` is 0 (no CLIP, no image rows), so the three-leg
|
|
587
|
+
// result is untouched.
|
|
588
|
+
addLeg(imageNorm, wi, "imageLeg");
|
|
589
|
+
// Temporal recency: bounded multiplicative nudge toward fresh memories,
|
|
590
|
+
// active only for temporal-marker queries. At the default strength
|
|
591
|
+
// (0.05) the maximum boost is 5% for a just-updated memory, decaying
|
|
592
|
+
// with the configured half-life — relevance still dominates.
|
|
593
|
+
const recencyHalfLifeMs = options.recencyHalfLifeMs ?? DEFAULT_RECENCY_HALF_LIFE_MS;
|
|
594
|
+
const temporalStrength = options.temporalRecencyStrength ?? DEFAULT_TEMPORAL_RECENCY_STRENGTH;
|
|
595
|
+
if (adaptive &&
|
|
596
|
+
weights.temporal &&
|
|
597
|
+
temporalStrength > 0 &&
|
|
598
|
+
recencyHalfLifeMs > 0) {
|
|
599
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
600
|
+
for (const entry of merged.values()) {
|
|
601
|
+
const ageMs = Math.max(0, nowMs - entry.node.updatedAt);
|
|
602
|
+
const freshness = Math.pow(0.5, ageMs / recencyHalfLifeMs);
|
|
603
|
+
entry.score *= 1 + temporalStrength * freshness;
|
|
604
|
+
entry.scores.hybrid = entry.score;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return merged;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Fuse two (optionally three) ranked retrieval lists into one, then apply
|
|
611
|
+
* trust weighting.
|
|
612
|
+
*
|
|
613
|
+
* All legs are expected to be pre-ranked best-first (as returned by FTS5
|
|
614
|
+
* bm25, cosine-similarity search, and the entity-inverted-index lookup).
|
|
615
|
+
* Candidates appearing in multiple legs accumulate each leg's RRF
|
|
616
|
+
* contribution.
|
|
617
|
+
*
|
|
618
|
+
* @param keywordResults — Ranked keyword/FTS5 leg (best first).
|
|
619
|
+
* @param semanticResults — Ranked semantic/embedding leg (best first).
|
|
620
|
+
* @param options — Tunable weights; see {@link FusionOptions}.
|
|
621
|
+
* @param entityResults — Ranked entity-inverted-index leg (best first).
|
|
622
|
+
* Optional third RRF leg: memories sharing entities with the query that
|
|
623
|
+
* the keyword/semantic legs missed still enter the candidate pool.
|
|
624
|
+
* @returns Fused list sorted by descending hybrid score, with a `scores`
|
|
625
|
+
* breakdown (`keyword`, `semantic`, `entityLeg`, `hybrid`, plus `entity`
|
|
626
|
+
* when the entity-overlap boost fires) on each entry.
|
|
627
|
+
*/
|
|
628
|
+
export function fuseResults(keywordResults, semanticResults, options = {}, entityResults = [], imageResults = []) {
|
|
629
|
+
const mode = options.fusionMode ?? DEFAULT_FUSION_MODE;
|
|
630
|
+
// The image (CLIP) leg is additive: when it is empty — which is the
|
|
631
|
+
// case for every text-only store, and whenever Track B is off — the
|
|
632
|
+
// fusion below is byte-identical to the pre-image pipeline.
|
|
633
|
+
const imageLegWeight = options.imageLegWeight ??
|
|
634
|
+
// When the caller has not pinned a weight, an ACTIVE leg gets a
|
|
635
|
+
// sane default so Track B actually contributes — otherwise the
|
|
636
|
+
// feature would be off for everyone who did not read the config
|
|
637
|
+
// docs. A store with no image results pays nothing (the weight is
|
|
638
|
+
// never consulted), so this cannot change a text-only install's
|
|
639
|
+
// output.
|
|
640
|
+
(imageResults.length > 0 ? DEFAULT_IMAGE_LEG_WEIGHT : 0);
|
|
641
|
+
const imageActive = imageResults.length > 0 && imageLegWeight > 0;
|
|
642
|
+
const merged = mode === "convex"
|
|
643
|
+
? convexFuse(keywordResults, semanticResults, entityResults, options, imageActive ? imageResults : [], imageActive ? imageLegWeight : 0)
|
|
644
|
+
: rrfFuse(keywordResults, semanticResults, entityResults, options, imageActive ? imageResults : [], imageActive ? imageLegWeight : 0);
|
|
645
|
+
const trustFloor = options.trustFloor ?? DEFAULT_TRUST_FLOOR;
|
|
646
|
+
const confidenceStrength = options.confidenceWeightStrength ?? DEFAULT_CONFIDENCE_WEIGHT;
|
|
647
|
+
const recencyHalfLifeMs = options.recencyHalfLifeMs ?? DEFAULT_RECENCY_HALF_LIFE_MS;
|
|
648
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
649
|
+
const queryEntities = options.queryEntities ?? [];
|
|
650
|
+
const entityWeight = options.entityWeight ??
|
|
651
|
+
(queryEntities.length > 0 ? DEFAULT_ENTITY_WEIGHT : 0);
|
|
652
|
+
// Entity-fused scoring: a third signal on top of keyword + semantic.
|
|
653
|
+
// Memories whose stored entities / tags overlap the query's entities get
|
|
654
|
+
// a gentle multiplicative boost. No-op unless the caller supplied query
|
|
655
|
+
// entities (hybridSearch extracts them per query). Records
|
|
656
|
+
// `scores.entity` — the overlap ratio — distinct from the
|
|
657
|
+
// `scores.entityLeg` RRF-leg vote above (see the item2 comment).
|
|
658
|
+
if (entityWeight > 0 && queryEntities.length > 0) {
|
|
659
|
+
for (const entry of merged.values()) {
|
|
660
|
+
const overlap = entityOverlap(queryEntities, {
|
|
661
|
+
tags: entry.node.tags,
|
|
662
|
+
metadata: entry.node.metadata,
|
|
663
|
+
});
|
|
664
|
+
if (overlap > 0) {
|
|
665
|
+
entry.score *= 1 + entityWeight * overlap;
|
|
666
|
+
entry.scores.entity = overlap;
|
|
667
|
+
entry.scores.hybrid = entry.score;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// Trust weighting: a gentle multiplier (trustFloor–1.0) so high-trust
|
|
672
|
+
// memories get a small boost without dominating pure relevance.
|
|
673
|
+
for (const entry of merged.values()) {
|
|
674
|
+
entry.score *=
|
|
675
|
+
trustFloor + (entry.node.trustScore ?? 1.0) * (1 - trustFloor);
|
|
676
|
+
entry.scores.hybrid = entry.score;
|
|
677
|
+
}
|
|
678
|
+
// Provenance-trust weighting: fold the write-time provenance tier into
|
|
679
|
+
// scoring alongside relevance. Low-trust channels (imported,
|
|
680
|
+
// tool-output, chat) are gently down-ranked via
|
|
681
|
+
// `score *= 1 - strength * (1 - tierTrust)` — the maximum penalty at
|
|
682
|
+
// the default strength (0.5) is ~14% for the imported tier, so
|
|
683
|
+
// relevance ordering dominates unless channels genuinely differ.
|
|
684
|
+
// Uniform across same-tier result sets, so single-channel corpora
|
|
685
|
+
// (including the retrieval eval) keep byte-identical ranking —
|
|
686
|
+
// neutral-or-better by construction. Records `scores.provenance`.
|
|
687
|
+
const provenanceStrength = options.provenanceWeightStrength ?? DEFAULT_PROVENANCE_WEIGHT_STRENGTH;
|
|
688
|
+
if (provenanceStrength > 0) {
|
|
689
|
+
for (const entry of merged.values()) {
|
|
690
|
+
// Nodes without a tier (legacy fixtures, custom adapters that
|
|
691
|
+
// predate the layer) are left exactly neutral — the weighting is
|
|
692
|
+
// additive and never rewrites historical scores.
|
|
693
|
+
const tier = entry.node.provenance;
|
|
694
|
+
if (!isProvenanceTier(tier))
|
|
695
|
+
continue;
|
|
696
|
+
const multiplier = provenanceMultiplier(tier, provenanceStrength);
|
|
697
|
+
entry.score *= multiplier;
|
|
698
|
+
entry.scores.provenance = multiplier;
|
|
699
|
+
entry.scores.hybrid = entry.score;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Confidence-aware ranking: fold in the evidence state machine. A
|
|
703
|
+
// memory that has been repeatedly reinforced ranks slightly above an
|
|
704
|
+
// equally-relevant but contradicted/superseded one. `confidenceWeight`
|
|
705
|
+
// returns sqrt(trust * confidence) and crushes anything below the
|
|
706
|
+
// confidence floor to ~10%, which is exactly the behavior wanted for
|
|
707
|
+
// "Knowledge Update" / "Contradiction Resolution" queries: the stale
|
|
708
|
+
// version of a fact should not outrank its current version when both
|
|
709
|
+
// match. Blended with `confidenceStrength` so pure relevance still
|
|
710
|
+
// dominates unless evidence is strongly against.
|
|
711
|
+
if (confidenceStrength > 0) {
|
|
712
|
+
for (const entry of merged.values()) {
|
|
713
|
+
const combined = confidenceWeight(entry.node.trustScore ?? 1.0, entry.node.confidence);
|
|
714
|
+
const multiplier = 1 - confidenceStrength + confidenceStrength * (combined / 1.0);
|
|
715
|
+
entry.score *= multiplier;
|
|
716
|
+
entry.scores.hybrid = entry.score;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Recency tie-break: among candidates whose fused scores are exactly
|
|
720
|
+
// tied (within float noise — see RECENCY_EPSILON), prefer the more
|
|
721
|
+
// recently updated memory. A genuine rank difference is three orders
|
|
722
|
+
// of magnitude above the epsilon window, so this can never reorder
|
|
723
|
+
// meaningfully-separated candidates; it only makes tie order
|
|
724
|
+
// deterministic and knowledge-update-friendly (the current version of
|
|
725
|
+
// a fact surfaces before its stale duplicate).
|
|
726
|
+
if (recencyHalfLifeMs > 0) {
|
|
727
|
+
const recencyBoost = (updatedAt) => {
|
|
728
|
+
const ageMs = Math.max(0, nowMs - updatedAt);
|
|
729
|
+
return Math.pow(0.5, ageMs / recencyHalfLifeMs); // 1.0 → fresh … → 0.0
|
|
730
|
+
};
|
|
731
|
+
const ranked = [...merged.values()].sort(compareScoredMemories);
|
|
732
|
+
for (let i = 1; i < ranked.length; i += 1) {
|
|
733
|
+
const current = ranked[i];
|
|
734
|
+
const prev = ranked[i - 1];
|
|
735
|
+
// `ranked` is sorted descending, so the difference is >= 0; the
|
|
736
|
+
// epsilon check admits both exact ties and float-noise trails.
|
|
737
|
+
if (prev.score - current.score <= RECENCY_EPSILON &&
|
|
738
|
+
// Only swap when the lower-scored candidate is actually FRESHER
|
|
739
|
+
// — an older candidate trailing within epsilon stays put.
|
|
740
|
+
current.node.updatedAt > prev.node.updatedAt) {
|
|
741
|
+
const boost = recencyBoost(current.node.updatedAt);
|
|
742
|
+
if (boost > 0) {
|
|
743
|
+
// Nudge by a fraction of the epsilon window proportional to
|
|
744
|
+
// freshness — enough to swap the tie, never enough to leapfrog
|
|
745
|
+
// a candidate outside the epsilon band.
|
|
746
|
+
current.score += RECENCY_EPSILON * boost * 0.5;
|
|
747
|
+
current.scores.hybrid = current.score;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return [...merged.values()].sort(compareScoredMemories).map((entry) => ({
|
|
753
|
+
node: entry.node,
|
|
754
|
+
score: entry.score,
|
|
755
|
+
scores: entry.scores,
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
/* ------------------------------------------------------------------ *
|
|
759
|
+
* Relevance gating: abstention + tail trimming
|
|
760
|
+
*
|
|
761
|
+
* Two different problems, one knob each.
|
|
762
|
+
*
|
|
763
|
+
* 1. ABSTENTION. When nothing in the store is genuinely about the
|
|
764
|
+
* query, the honest answer is "no memories" — not "the ten least
|
|
765
|
+
* bad rows". A memory layer that always returns k results forces
|
|
766
|
+
* the caller to invent an answer. LongMemEval scores this directly
|
|
767
|
+
* through its `_abs` questions (events that never happened) and
|
|
768
|
+
* LoCoMo through its adversarial category, which is why this lives
|
|
769
|
+
* here rather than in the caller.
|
|
770
|
+
*
|
|
771
|
+
* 2. TAIL TRIMMING. Precision@10 measured 0.099 on LoCoMo: about nine
|
|
772
|
+
* of every ten injected items were noise. Dropping candidates that
|
|
773
|
+
* trail the top score by a large factor costs little recall and
|
|
774
|
+
* sharply improves the signal-to-noise of a context pack.
|
|
775
|
+
* ------------------------------------------------------------------ */
|
|
776
|
+
/**
|
|
777
|
+
* Default tail-trim threshold: a candidate is kept when its fused score
|
|
778
|
+
* is at least this fraction of the top score. Deliberately low — it
|
|
779
|
+
* exists to cut the long tail of "matched one stopword" rows, not to
|
|
780
|
+
* make fine-grained relevance decisions.
|
|
781
|
+
*/
|
|
782
|
+
export const DEFAULT_RELEVANCE_RATIO = 0.15;
|
|
783
|
+
/**
|
|
784
|
+
* Default abstention floor on the semantic leg's cosine similarity.
|
|
785
|
+
*
|
|
786
|
+
* 0.3 is not arbitrary: `SEMANTIC_FUSION_THRESHOLD` and the fusion
|
|
787
|
+
* comments already treat ~0.3 as the point where a real embedder's
|
|
788
|
+
* paraphrase matches begin. Below it, a hit is coincidence.
|
|
789
|
+
*
|
|
790
|
+
* This is OPT-IN (`filter.abstainBelowSimilarity`) because the floor is
|
|
791
|
+
* embedder-dependent — a 256-d hash embedder and a 1024-d transformer
|
|
792
|
+
* do not share a cosine scale. Calibrate per embedder before enabling.
|
|
793
|
+
*/
|
|
794
|
+
export const DEFAULT_ABSTAIN_SIMILARITY = 0.3;
|
|
795
|
+
/** Compact English function-word list for query-expansion filtering. */
|
|
796
|
+
const STOPWORDS = new Set([
|
|
797
|
+
"the",
|
|
798
|
+
"and",
|
|
799
|
+
"for",
|
|
800
|
+
"with",
|
|
801
|
+
"that",
|
|
802
|
+
"this",
|
|
803
|
+
"from",
|
|
804
|
+
"have",
|
|
805
|
+
"has",
|
|
806
|
+
"had",
|
|
807
|
+
"was",
|
|
808
|
+
"were",
|
|
809
|
+
"are",
|
|
810
|
+
"you",
|
|
811
|
+
"your",
|
|
812
|
+
"they",
|
|
813
|
+
"them",
|
|
814
|
+
"their",
|
|
815
|
+
"there",
|
|
816
|
+
"here",
|
|
817
|
+
"what",
|
|
818
|
+
"when",
|
|
819
|
+
"where",
|
|
820
|
+
"which",
|
|
821
|
+
"who",
|
|
822
|
+
"whom",
|
|
823
|
+
"how",
|
|
824
|
+
"why",
|
|
825
|
+
"did",
|
|
826
|
+
"does",
|
|
827
|
+
"doing",
|
|
828
|
+
"would",
|
|
829
|
+
"could",
|
|
830
|
+
"should",
|
|
831
|
+
"about",
|
|
832
|
+
"into",
|
|
833
|
+
"than",
|
|
834
|
+
"then",
|
|
835
|
+
"them",
|
|
836
|
+
"some",
|
|
837
|
+
"any",
|
|
838
|
+
"all",
|
|
839
|
+
"one",
|
|
840
|
+
"two",
|
|
841
|
+
"also",
|
|
842
|
+
"just",
|
|
843
|
+
"like",
|
|
844
|
+
"been",
|
|
845
|
+
"being",
|
|
846
|
+
"over",
|
|
847
|
+
"under",
|
|
848
|
+
"more",
|
|
849
|
+
"most",
|
|
850
|
+
"much",
|
|
851
|
+
"many",
|
|
852
|
+
"very",
|
|
853
|
+
"will",
|
|
854
|
+
"can",
|
|
855
|
+
"not",
|
|
856
|
+
"but",
|
|
857
|
+
"she",
|
|
858
|
+
"him",
|
|
859
|
+
"her",
|
|
860
|
+
"his",
|
|
861
|
+
"its",
|
|
862
|
+
"our",
|
|
863
|
+
"out",
|
|
864
|
+
"get",
|
|
865
|
+
"got",
|
|
866
|
+
"say",
|
|
867
|
+
"said",
|
|
868
|
+
]);
|
|
869
|
+
/** Lowercase alphanumeric tokens — shared by gating and expansion. */
|
|
870
|
+
function tokenize(text) {
|
|
871
|
+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* True when any candidate literally contains a query term.
|
|
875
|
+
*
|
|
876
|
+
* Guards abstention against its main failure mode: a bullseye exact-term
|
|
877
|
+
* hit (a rare proper noun, a product name) can score a low cosine when
|
|
878
|
+
* the embedder barely knows the word. Abstaining there would throw away
|
|
879
|
+
* a perfect answer, so a literal match always wins over the cosine test.
|
|
880
|
+
*/
|
|
881
|
+
export function hasExactTermMatch(query, results) {
|
|
882
|
+
const terms = tokenize(query).filter((term) => term.length >= 3);
|
|
883
|
+
if (terms.length === 0)
|
|
884
|
+
return false;
|
|
885
|
+
for (const result of results) {
|
|
886
|
+
const haystack = `${result.node.summary ?? ""} ${result.node.content ?? ""}`.toLowerCase();
|
|
887
|
+
for (const term of terms) {
|
|
888
|
+
if (haystack.includes(term))
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Decide whether retrieval found anything worth returning at all.
|
|
896
|
+
*
|
|
897
|
+
* Conservative by construction — three independent escape hatches, each
|
|
898
|
+
* of which suppresses abstention:
|
|
899
|
+
*
|
|
900
|
+
* - no floor configured, or no results at all → never abstain;
|
|
901
|
+
* - no candidate carries a semantic score (keyword-only retrieval, or no
|
|
902
|
+
* embedding provider) → cannot judge similarity, so never abstain;
|
|
903
|
+
* - any candidate reaches the cosine floor → clearly relevant, return.
|
|
904
|
+
*
|
|
905
|
+
* Only when every candidate is below the floor do we abstain, and even
|
|
906
|
+
* then a literal term match rescues the result set.
|
|
907
|
+
*
|
|
908
|
+
* **Reads `scores.semanticRaw`, never `scores.semantic`.** Convex fusion
|
|
909
|
+
* (the default mode) min-max-normalizes each leg, so `scores.semantic`
|
|
910
|
+
* holds a fusion weight whose top candidate is always exactly `1` —
|
|
911
|
+
* comparing that against an absolute floor can never fail, which made
|
|
912
|
+
* abstention dead code on the default path. `semanticRaw` carries the
|
|
913
|
+
* unnormalized cosine on the embedder's own scale. The fallback to
|
|
914
|
+
* `scores.semantic` is for hand-built results and custom fusion paths
|
|
915
|
+
* that predate the raw field; those already store a raw value in
|
|
916
|
+
* `semantic` (see `rrfFuse`).
|
|
917
|
+
*/
|
|
918
|
+
export function shouldAbstain(results, query, options = {}) {
|
|
919
|
+
const floor = options.abstainBelowSimilarity;
|
|
920
|
+
if (floor === undefined || results.length === 0)
|
|
921
|
+
return false;
|
|
922
|
+
const similarities = [];
|
|
923
|
+
for (const result of results) {
|
|
924
|
+
const similarity = result.scores?.semanticRaw ??
|
|
925
|
+
result.scores?.semantic ??
|
|
926
|
+
// The CLIP image leg is a genuine relevance signal too: an image
|
|
927
|
+
// matched by pixels carries a real cosine in ITS space. Without
|
|
928
|
+
// this, an image surfaced only by the image leg looks like it has
|
|
929
|
+
// no embedding evidence at all and the gate abstains on it — the
|
|
930
|
+
// one case where abstention is plainly wrong, because something
|
|
931
|
+
// DID match.
|
|
932
|
+
result.scores?.imageLeg;
|
|
933
|
+
if (typeof similarity === "number")
|
|
934
|
+
similarities.push(similarity);
|
|
935
|
+
}
|
|
936
|
+
// No embedding signal anywhere: cannot judge, so do not abstain.
|
|
937
|
+
if (similarities.length === 0)
|
|
938
|
+
return false;
|
|
939
|
+
if (Math.max(...similarities) >= floor)
|
|
940
|
+
return false;
|
|
941
|
+
return !hasExactTermMatch(query, results);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Apply abstention and tail trimming to a fused, best-first result list.
|
|
945
|
+
*
|
|
946
|
+
* Pure and dependency-free (like {@link fuseResults}) so it can be unit
|
|
947
|
+
* tested without storage, embeddings, or a MemOS instance.
|
|
948
|
+
*/
|
|
949
|
+
export function applyRelevanceGate(results, query, options = {}) {
|
|
950
|
+
if (results.length === 0)
|
|
951
|
+
return results;
|
|
952
|
+
if (shouldAbstain(results, query, options))
|
|
953
|
+
return [];
|
|
954
|
+
const ratio = options.relevanceRatio;
|
|
955
|
+
if (ratio === undefined || ratio <= 0)
|
|
956
|
+
return results;
|
|
957
|
+
const top = results[0].score;
|
|
958
|
+
if (top <= 0)
|
|
959
|
+
return results;
|
|
960
|
+
const floor = top * ratio;
|
|
961
|
+
return results.filter((result) => result.score >= floor);
|
|
962
|
+
}
|
|
963
|
+
/* ------------------------------------------------------------------ *
|
|
964
|
+
* Pseudo-relevance feedback (PRF) query expansion
|
|
965
|
+
*
|
|
966
|
+
* Open-domain questions are memos' weakest LoCoMo category (hit@10
|
|
967
|
+
* 0.533). They are phrased with vocabulary the stored memories never
|
|
968
|
+
* use, so the keyword leg misses and a small embedding model has little
|
|
969
|
+
* to work with. PRF is the classic LLM-free fix: take the top few
|
|
970
|
+
* candidates from a first pass on faith, harvest their distinctive
|
|
971
|
+
* vocabulary, and re-run the query with those terms appended.
|
|
972
|
+
*
|
|
973
|
+
* Blindly trusting the first pass is what makes this risky — hence
|
|
974
|
+
* opt-in via `filter.queryExpansion`, and the two result lists are
|
|
975
|
+
* RRF-fused so a bad expansion can only dilute, never replace, the
|
|
976
|
+
* original ranking.
|
|
977
|
+
* ------------------------------------------------------------------ */
|
|
978
|
+
/**
|
|
979
|
+
* Build an expanded query by harvesting distinctive terms from the top
|
|
980
|
+
* `maxDocs` candidates of a first retrieval pass.
|
|
981
|
+
*
|
|
982
|
+
* Scores terms by DOCUMENT FREQUENCY across the top candidates rather
|
|
983
|
+
* than raw term frequency, so one long memory cannot dominate the
|
|
984
|
+
* expansion. Returns `""` when nothing was harvested, which callers
|
|
985
|
+
* treat as "skip the second pass".
|
|
986
|
+
*/
|
|
987
|
+
export function expandQueryWithPseudoRelevance(query, results, options = {}) {
|
|
988
|
+
const maxDocs = options.maxDocs ?? 3;
|
|
989
|
+
const maxTerms = options.maxTerms ?? 8;
|
|
990
|
+
if (!query.trim() || results.length === 0)
|
|
991
|
+
return "";
|
|
992
|
+
const queryTerms = new Set(tokenize(query));
|
|
993
|
+
const docFrequency = new Map();
|
|
994
|
+
for (const result of results.slice(0, maxDocs)) {
|
|
995
|
+
const text = `${result.node.summary ?? ""} ${result.node.content ?? ""}`;
|
|
996
|
+
const seenInDoc = new Set();
|
|
997
|
+
for (const term of tokenize(text)) {
|
|
998
|
+
if (term.length < 3)
|
|
999
|
+
continue;
|
|
1000
|
+
if (STOPWORDS.has(term))
|
|
1001
|
+
continue;
|
|
1002
|
+
if (queryTerms.has(term))
|
|
1003
|
+
continue;
|
|
1004
|
+
if (seenInDoc.has(term))
|
|
1005
|
+
continue;
|
|
1006
|
+
seenInDoc.add(term);
|
|
1007
|
+
docFrequency.set(term, (docFrequency.get(term) ?? 0) + 1);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const harvested = [...docFrequency.entries()]
|
|
1011
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
1012
|
+
.slice(0, maxTerms)
|
|
1013
|
+
.map(([term]) => term);
|
|
1014
|
+
if (harvested.length === 0)
|
|
1015
|
+
return "";
|
|
1016
|
+
return `${query} ${harvested.join(" ")}`;
|
|
1017
|
+
}
|
|
1018
|
+
//# sourceMappingURL=retrieval.js.map
|