pi-memoria 0.2.0 → 0.3.0
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/AGENTS.md +53 -0
- package/README.md +126 -166
- package/index.ts +299 -204
- package/package.json +28 -69
- package/src/aliases.ts +104 -0
- package/src/config.ts +22 -184
- package/src/database.ts +301 -0
- package/src/errors.ts +55 -0
- package/src/files.ts +29 -0
- package/src/hot-memory.ts +231 -0
- package/src/results.ts +264 -0
- package/src/sessions.ts +246 -843
- package/src/text.ts +48 -0
- package/skills/memoria/SKILL.md +0 -77
- package/skills/memoria-sessions/SKILL.md +0 -69
- package/src/commands.ts +0 -398
- package/src/frontmatter.ts +0 -318
- package/src/index-engine.ts +0 -1071
- package/src/recall.ts +0 -211
- package/src/rerank.ts +0 -85
- package/src/runtime.ts +0 -1469
- package/src/search.ts +0 -499
- package/src/similarity.ts +0 -374
- package/src/store.ts +0 -1724
- package/src/synonyms.ts +0 -105
- package/src/timeexpr.ts +0 -145
- package/src/tokenize.ts +0 -295
- package/src/tools.ts +0 -813
- package/src/types.ts +0 -541
- package/src/usage.ts +0 -188
- package/src/util.ts +0 -141
package/src/search.ts
DELETED
|
@@ -1,499 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BM25 search over a MemoryIndex.
|
|
3
|
-
*
|
|
4
|
-
* The search path is fully in-memory: posting lists are sorted by document index
|
|
5
|
-
* and scoring never touches the filesystem. Only snippet extraction for the
|
|
6
|
-
* final hits may read a body, and those are cached.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { tokenize, tokenizeRaw, stem, isCjkChar } from "./tokenize.ts";
|
|
10
|
-
import { truncateChars } from "./util.ts";
|
|
11
|
-
import { priorityRank } from "./store.ts";
|
|
12
|
-
import { parseTimeExpression } from "./timeexpr.ts";
|
|
13
|
-
import { synonymsFor } from "./synonyms.ts";
|
|
14
|
-
import type { MemoryIndex } from "./index-engine.ts";
|
|
15
|
-
import type { Priority, QueryTerm, ScoreBreakdown, SearchHit, SearchOptions, SearchResult } from "./types.ts";
|
|
16
|
-
|
|
17
|
-
const K1 = 1.2;
|
|
18
|
-
const B = 0.75;
|
|
19
|
-
const MAX_QUERY_TERMS = 28;
|
|
20
|
-
const MAX_CANDIDATES = 2500;
|
|
21
|
-
const MAX_POSTINGS_SCANNED = 300_000;
|
|
22
|
-
const PREFIX_LIMIT = 12;
|
|
23
|
-
const FUZZY_LIMIT = 5;
|
|
24
|
-
const EXACT_SCAN_LIMIT = 64;
|
|
25
|
-
|
|
26
|
-
const PRIORITY_BOOST: Record<Priority, number> = { low: 0, normal: 0.2, high: 0.9, critical: 2.0 };
|
|
27
|
-
/** Boost for notes updated inside a time window parsed from the query. */
|
|
28
|
-
const TIME_WINDOW_BOOST = 1.5;
|
|
29
|
-
/** How much of its source's score a note pulled in through `related` keeps. */
|
|
30
|
-
const RELATED_SOURCE_LIMIT = 3;
|
|
31
|
-
/** A superseded note is demoted, not hidden: history stays searchable. */
|
|
32
|
-
const SUPERSEDED_FACTOR = 0.35;
|
|
33
|
-
|
|
34
|
-
interface Candidate {
|
|
35
|
-
idx: number;
|
|
36
|
-
score: number;
|
|
37
|
-
/** Bitmask of matched query-term positions. */
|
|
38
|
-
mask: number;
|
|
39
|
-
/** Set when the candidate was pulled in through another note's `related`. */
|
|
40
|
-
relatedTo?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface AnalyzeOptions {
|
|
44
|
-
/** Query-side synonym table. */
|
|
45
|
-
synonyms?: Record<string, string[]>;
|
|
46
|
-
/** Multiplier applied to synonym-expanded terms. */
|
|
47
|
-
synonymWeight?: number;
|
|
48
|
-
/** Weighted query segments; when present, `query` is only used for phrases. */
|
|
49
|
-
parts?: Array<{ text: string; weight: number }>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Build weighted query terms, rarest first.
|
|
54
|
-
*
|
|
55
|
-
* When `parts` is supplied (the current prompt plus weighted previous turns) the
|
|
56
|
-
* strongest weight per term wins, so a term from the current prompt outranks the
|
|
57
|
-
* same term merely mentioned a turn ago. Synonyms from the store's table are
|
|
58
|
-
* added at `synonymWeight` of the term they expand.
|
|
59
|
-
*/
|
|
60
|
-
export function analyzeQuery(index: MemoryIndex, query: string, options: AnalyzeOptions = {}): QueryTerm[] {
|
|
61
|
-
const weights = new Map<string, number>();
|
|
62
|
-
const addText = (text: string, partWeight: number): void => {
|
|
63
|
-
const surfaceSet = new Set(tokenizeRaw(text));
|
|
64
|
-
for (const token of tokenize(text)) {
|
|
65
|
-
const weight = (surfaceSet.has(token) ? 1 : 0.85) * partWeight;
|
|
66
|
-
weights.set(token, Math.max(weights.get(token) ?? 0, weight));
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
if (options.parts && options.parts.length > 0) {
|
|
70
|
-
for (const part of options.parts) addText(part.text, part.weight);
|
|
71
|
-
} else {
|
|
72
|
-
addText(query, 1);
|
|
73
|
-
}
|
|
74
|
-
const table = options.synonyms;
|
|
75
|
-
if (table && Object.keys(table).length > 0) {
|
|
76
|
-
const synonymWeight = options.synonymWeight ?? 0.6;
|
|
77
|
-
for (const [term, weight] of [...weights.entries()]) {
|
|
78
|
-
for (const synonym of synonymsFor(table, term)) {
|
|
79
|
-
let tokens = tokenize(synonym);
|
|
80
|
-
if (tokens.length === 0 && synonym.trim()) tokens = [synonym.trim().toLowerCase()];
|
|
81
|
-
for (const token of tokens) weights.set(token, Math.max(weights.get(token) ?? 0, weight * synonymWeight));
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const n = Math.max(1, index.aliveCount);
|
|
86
|
-
const terms: QueryTerm[] = [];
|
|
87
|
-
for (const [term, weight] of weights) {
|
|
88
|
-
const termId = index.termId(term);
|
|
89
|
-
const df = termId >= 0 ? index.documentFrequency(termId) : 0;
|
|
90
|
-
const idf = df > 0 ? Math.log(1 + (n - df + 0.5) / (df + 0.5)) : Math.log(1 + n + 0.5);
|
|
91
|
-
terms.push({
|
|
92
|
-
term,
|
|
93
|
-
weight,
|
|
94
|
-
termId: df > 0 ? termId : -1,
|
|
95
|
-
idf,
|
|
96
|
-
specific: /[._/\-]|\d/.test(term) || (stem(term) !== term && term.length > 5),
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
terms.sort((a, b) => b.idf - a.idf || b.weight - a.weight);
|
|
100
|
-
if (terms.length <= MAX_QUERY_TERMS) return terms;
|
|
101
|
-
const head = terms.slice(0, MAX_QUERY_TERMS);
|
|
102
|
-
const specificExtras = terms.slice(MAX_QUERY_TERMS).filter((term) => term.specific).slice(0, 8);
|
|
103
|
-
return [...head, ...specificExtras];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function normalizeForMatch(text: string): string {
|
|
107
|
-
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Extract a query-focused excerpt from a body. */
|
|
111
|
-
export function extractSnippet(body: string, terms: string[], maxChars: number): string {
|
|
112
|
-
const flat = body.replace(/\s+/g, " ").trim();
|
|
113
|
-
if (!flat) return "";
|
|
114
|
-
if (flat.length <= maxChars) return flat;
|
|
115
|
-
const haystack = flat.toLowerCase();
|
|
116
|
-
const needles = terms
|
|
117
|
-
.map((term) => term.toLowerCase())
|
|
118
|
-
.filter((term) => term.length >= 2 && !isCjkChar(term[0]))
|
|
119
|
-
.slice(0, 10);
|
|
120
|
-
const positions: Array<{ index: number; term: string }> = [];
|
|
121
|
-
for (const needle of needles) {
|
|
122
|
-
let from = 0;
|
|
123
|
-
for (let found = 0; found < 6; found += 1) {
|
|
124
|
-
const at = haystack.indexOf(needle, from);
|
|
125
|
-
if (at === -1) break;
|
|
126
|
-
positions.push({ index: at, term: needle });
|
|
127
|
-
from = at + needle.length;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (positions.length === 0) {
|
|
131
|
-
return `${flat.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
|
132
|
-
}
|
|
133
|
-
const half = Math.floor(maxChars / 2);
|
|
134
|
-
let bestStart = 0;
|
|
135
|
-
let bestScore = -1;
|
|
136
|
-
for (const position of positions) {
|
|
137
|
-
const start = Math.max(0, position.index - half);
|
|
138
|
-
const end = Math.min(flat.length, start + maxChars);
|
|
139
|
-
const windowText = haystack.slice(start, end);
|
|
140
|
-
let score = 0;
|
|
141
|
-
for (const needle of needles) if (windowText.includes(needle)) score += 1;
|
|
142
|
-
if (score > bestScore) {
|
|
143
|
-
bestScore = score;
|
|
144
|
-
bestStart = start;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
let start = bestStart;
|
|
148
|
-
if (start > 0) {
|
|
149
|
-
const space = flat.indexOf(" ", start);
|
|
150
|
-
if (space !== -1 && space - start < 24) start = space + 1;
|
|
151
|
-
}
|
|
152
|
-
let snippet = flat.slice(start, Math.min(flat.length, start + maxChars)).trim();
|
|
153
|
-
if (start > 0) snippet = `…${snippet}`;
|
|
154
|
-
if (start + maxChars < flat.length) snippet = `${snippet.replace(/[,;:.\s]+$/, "")}…`;
|
|
155
|
-
return snippet;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function baseScore(doc: MemoryIndex["docs"][number], now: number): number {
|
|
159
|
-
const priority = PRIORITY_BOOST[doc.priority] ?? 0.2;
|
|
160
|
-
const ageDays = Math.max(0, (now - (doc.updated || doc.created)) / 86_400_000);
|
|
161
|
-
return priority + 0.35 * Math.exp(-ageDays / 180);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function matchesCategory(docCategory: string, filter: string): boolean {
|
|
165
|
-
if (filter.endsWith("/*")) return docCategory === filter.slice(0, -2) || docCategory.startsWith(filter.slice(0, -1));
|
|
166
|
-
return docCategory === filter;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function passesFilters(doc: MemoryIndex["docs"][number], options: SearchOptions, keepUnfiled: boolean): boolean {
|
|
170
|
-
if (options.root && doc.root !== options.root) return false;
|
|
171
|
-
if (!keepUnfiled && doc.unfiled) return false;
|
|
172
|
-
if (options.category && !matchesCategory(doc.category, options.category)) return false;
|
|
173
|
-
if (options.minPriority && priorityRank(doc.priority) < priorityRank(options.minPriority)) return false;
|
|
174
|
-
if (options.since && doc.updated < options.since) return false;
|
|
175
|
-
if (options.tags && options.tags.length > 0) {
|
|
176
|
-
for (const tag of options.tags) if (!doc.tags.includes(tag)) return false;
|
|
177
|
-
}
|
|
178
|
-
if (options.anyTags && options.anyTags.length > 0) {
|
|
179
|
-
if (!options.anyTags.some((tag) => doc.tags.includes(tag))) return false;
|
|
180
|
-
}
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Decode a query-term bitmask back into term labels (best first). */
|
|
185
|
-
function maskToTerms(mask: number, queryTerms: QueryTerm[]): string[] {
|
|
186
|
-
const out: string[] = [];
|
|
187
|
-
for (let i = 0; i < queryTerms.length && i < 31; i += 1) {
|
|
188
|
-
if (mask & (1 << i)) out.push(queryTerms[i].term);
|
|
189
|
-
}
|
|
190
|
-
return out;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Run a search against a warm index. Pure and synchronous. */
|
|
194
|
-
export function searchIndex(index: MemoryIndex, query: string, options: SearchOptions = {}): SearchResult {
|
|
195
|
-
const started = performance.now();
|
|
196
|
-
const limit = Math.max(1, Math.min(100, options.limit ?? 8));
|
|
197
|
-
const usePrefix = options.prefix !== false;
|
|
198
|
-
const useFuzzy = options.fuzzy !== false;
|
|
199
|
-
const minScore = options.minScore ?? 0;
|
|
200
|
-
const keepUnfiled = options.includeUnfiled !== false;
|
|
201
|
-
const avgDocLength = index.avgDocLength;
|
|
202
|
-
const n = Math.max(1, index.aliveCount);
|
|
203
|
-
const queryTerms = analyzeQuery(index, query, {
|
|
204
|
-
synonyms: options.synonyms,
|
|
205
|
-
synonymWeight: options.synonymWeight,
|
|
206
|
-
parts: options.parts,
|
|
207
|
-
});
|
|
208
|
-
const timeWindow = options.timeHints === false ? undefined : parseTimeExpression(query);
|
|
209
|
-
if (timeWindow) options.onTimeWindow?.(timeWindow);
|
|
210
|
-
const inWindow = (doc: MemoryIndex["docs"][number]): boolean => {
|
|
211
|
-
if (!timeWindow) return false;
|
|
212
|
-
if (timeWindow.since !== undefined && doc.updated < timeWindow.since) return false;
|
|
213
|
-
if (timeWindow.until !== undefined && doc.updated >= timeWindow.until) return false;
|
|
214
|
-
return true;
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
// Filter-only browse mode: no usable query terms (e.g. all stopwords) but
|
|
218
|
-
// filters were supplied. Return the newest/highest-priority matches.
|
|
219
|
-
if (queryTerms.length === 0) {
|
|
220
|
-
const now = Date.now();
|
|
221
|
-
const candidates: Candidate[] = [];
|
|
222
|
-
for (let idx = 0; idx < index.docs.length; idx += 1) {
|
|
223
|
-
if (!index.alive[idx]) continue;
|
|
224
|
-
const doc = index.docs[idx];
|
|
225
|
-
if (!passesFilters(doc, options, keepUnfiled)) continue;
|
|
226
|
-
candidates.push({ idx, score: baseScore(doc, now), mask: 0 });
|
|
227
|
-
}
|
|
228
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
229
|
-
const browseHits: SearchHit[] = candidates.slice(0, limit).map((candidate) => {
|
|
230
|
-
const doc = index.docs[candidate.idx];
|
|
231
|
-
return {
|
|
232
|
-
doc,
|
|
233
|
-
score: Math.round(candidate.score * 1000) / 1000,
|
|
234
|
-
matched: [],
|
|
235
|
-
snippet: truncateChars(doc.preview, 280),
|
|
236
|
-
exact: false,
|
|
237
|
-
};
|
|
238
|
-
});
|
|
239
|
-
return {
|
|
240
|
-
hits: browseHits,
|
|
241
|
-
total: candidates.length,
|
|
242
|
-
tookMs: Math.round((performance.now() - started) * 1000) / 1000,
|
|
243
|
-
truncated: candidates.length > limit,
|
|
244
|
-
missing: [],
|
|
245
|
-
timeWindow,
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const scratch = index.searchScratch();
|
|
250
|
-
const scoreBuf = scratch.scores;
|
|
251
|
-
const coverageBuf = scratch.coverage;
|
|
252
|
-
const maskBuf = scratch.mask;
|
|
253
|
-
const touched = scratch.touched;
|
|
254
|
-
let touchedCount = 0;
|
|
255
|
-
const missing: string[] = [];
|
|
256
|
-
let scanned = 0;
|
|
257
|
-
let truncated = false;
|
|
258
|
-
|
|
259
|
-
const postings = index.postings;
|
|
260
|
-
const docMetas = index.docs;
|
|
261
|
-
const alive = index.alive;
|
|
262
|
-
const dfTable = index.df;
|
|
263
|
-
const k1Scale = K1 * (1 - B);
|
|
264
|
-
const bScale = K1 * B;
|
|
265
|
-
|
|
266
|
-
const addTerm = (termId: number, weight: number, idf: number, termIndex: number): number => {
|
|
267
|
-
if (termId < 0) return 0;
|
|
268
|
-
const posting = postings[termId];
|
|
269
|
-
const df = dfTable[termId] ?? 0;
|
|
270
|
-
if (df <= 0 || posting.length === 0) return 0;
|
|
271
|
-
const effectiveIdf = idf > 0 ? idf : Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
|
272
|
-
const bit = termIndex < 31 ? 1 << termIndex : 0;
|
|
273
|
-
let added = 0;
|
|
274
|
-
for (let i = 0; i + 1 < posting.length; i += 2) {
|
|
275
|
-
const docIdx = posting[i];
|
|
276
|
-
if (alive[docIdx] !== true) continue;
|
|
277
|
-
const tf = posting[i + 1];
|
|
278
|
-
if (scoreBuf[docIdx] === 0) touched[touchedCount++] = docIdx;
|
|
279
|
-
// Inlined BM25: idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl))
|
|
280
|
-
scoreBuf[docIdx] += (effectiveIdf * (tf * (K1 + 1)) * weight) / (tf + k1Scale + (bScale * docMetas[docIdx].tokenCount) / avgDocLength);
|
|
281
|
-
coverageBuf[docIdx] += 1;
|
|
282
|
-
maskBuf[docIdx] |= bit;
|
|
283
|
-
added += 1;
|
|
284
|
-
scanned += 1;
|
|
285
|
-
}
|
|
286
|
-
return added;
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
for (let termIndex = 0; termIndex < queryTerms.length; termIndex += 1) {
|
|
290
|
-
const queryTerm = queryTerms[termIndex];
|
|
291
|
-
if (scanned > MAX_POSTINGS_SCANNED) {
|
|
292
|
-
truncated = true;
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
let added = 0;
|
|
296
|
-
if (queryTerm.termId >= 0) {
|
|
297
|
-
added += addTerm(queryTerm.termId, queryTerm.weight, queryTerm.idf, termIndex);
|
|
298
|
-
}
|
|
299
|
-
if (added === 0 && usePrefix && queryTerm.term.length >= 3) {
|
|
300
|
-
for (const termId of index.prefixMatches(queryTerm.term, PREFIX_LIMIT)) {
|
|
301
|
-
const extra = index.terms[termId].length - queryTerm.term.length;
|
|
302
|
-
const weight = queryTerm.weight * (0.55 / (1 + Math.max(0, extra)));
|
|
303
|
-
added += addTerm(termId, weight, 0, termIndex);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
if (added === 0 && useFuzzy && queryTerm.term.length >= 4) {
|
|
307
|
-
const maxDistance = queryTerm.term.length >= 8 ? 2 : 1;
|
|
308
|
-
for (const termId of index.fuzzyMatches(queryTerm.term, maxDistance, FUZZY_LIMIT)) {
|
|
309
|
-
added += addTerm(termId, queryTerm.weight * 0.35, 0, termIndex);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (added === 0) missing.push(queryTerm.term);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** Reset the shared scratch buffers so the next search starts clean. */
|
|
316
|
-
const releaseScratch = () => {
|
|
317
|
-
for (let i = 0; i < touchedCount; i += 1) {
|
|
318
|
-
const idx = touched[i];
|
|
319
|
-
scoreBuf[idx] = 0;
|
|
320
|
-
coverageBuf[idx] = 0;
|
|
321
|
-
maskBuf[idx] = 0;
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
const now = Date.now();
|
|
326
|
-
const candidates: Candidate[] = [];
|
|
327
|
-
for (let t = 0; t < touchedCount; t += 1) {
|
|
328
|
-
const idx = touched[t];
|
|
329
|
-
const doc = index.docs[idx];
|
|
330
|
-
if (!passesFilters(doc, options, keepUnfiled)) continue;
|
|
331
|
-
candidates.push({
|
|
332
|
-
idx,
|
|
333
|
-
score: scoreBuf[idx] + coverageBuf[idx] * 0.6 + baseScore(doc, now) + (inWindow(doc) ? TIME_WINDOW_BOOST : 0),
|
|
334
|
-
mask: maskBuf[idx],
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
releaseScratch();
|
|
338
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
339
|
-
const capped = candidates.length > MAX_CANDIDATES;
|
|
340
|
-
if (capped) truncated = true;
|
|
341
|
-
const trimmed = candidates.length > MAX_CANDIDATES ? candidates.slice(0, MAX_CANDIDATES) : candidates;
|
|
342
|
-
|
|
343
|
-
// Exact-phrase bonuses need the body; only pay for the strongest candidates.
|
|
344
|
-
const normalizedQuery = normalizeForMatch(query);
|
|
345
|
-
const phraseEligible = normalizedQuery.length >= 3;
|
|
346
|
-
if (phraseEligible) {
|
|
347
|
-
for (const candidate of trimmed.slice(0, EXACT_SCAN_LIMIT)) {
|
|
348
|
-
const doc = index.docs[candidate.idx];
|
|
349
|
-
if (doc.title.toLowerCase().includes(normalizedQuery)) candidate.score += 6;
|
|
350
|
-
else if (doc.summary.toLowerCase().includes(normalizedQuery)) candidate.score += 2;
|
|
351
|
-
if (index.bodyOrPreview(candidate.idx).toLowerCase().includes(normalizedQuery)) candidate.score += 3;
|
|
352
|
-
}
|
|
353
|
-
trimmed.sort((a, b) => b.score - a.score);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const hits: SearchHit[] = [];
|
|
357
|
-
const claimed = new Set<number>();
|
|
358
|
-
for (const candidate of trimmed) {
|
|
359
|
-
if (hits.length >= limit) break;
|
|
360
|
-
if (candidate.score < minScore) continue;
|
|
361
|
-
const doc = index.docs[candidate.idx];
|
|
362
|
-
const body = index.bodyOrPreview(candidate.idx);
|
|
363
|
-
claimed.add(candidate.idx);
|
|
364
|
-
hits.push({
|
|
365
|
-
doc,
|
|
366
|
-
score: candidate.score,
|
|
367
|
-
matched: maskToTerms(candidate.mask, queryTerms),
|
|
368
|
-
snippet: extractSnippet(body, queryTerms.slice(0, 10).map((term) => term.term), options.limit === 1 ? 600 : (options.snippetChars ?? 280)),
|
|
369
|
-
exact: phraseEligible && normalizeForMatch(`${doc.title}\n${body}`).includes(normalizedQuery),
|
|
370
|
-
...(candidate.relatedTo ? { relatedTo: candidate.relatedTo } : {}),
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Related notes: a note that says "see Bob" should surface Bob too, even if
|
|
375
|
-
// his note does not contain the query words. Pulled in after ranking so the
|
|
376
|
-
// link never displaces a direct match.
|
|
377
|
-
const relatedLimit = options.expandRelated === false ? 0 : Math.max(0, options.relatedHits ?? 3);
|
|
378
|
-
if (relatedLimit > 0 && hits.length > 0) {
|
|
379
|
-
const budget = relatedLimit;
|
|
380
|
-
let added = 0;
|
|
381
|
-
for (const hit of hits.slice(0, RELATED_SOURCE_LIMIT)) {
|
|
382
|
-
if (added >= budget) break;
|
|
383
|
-
const sourceIdx = index.idxForId(hit.doc.id);
|
|
384
|
-
if (sourceIdx === undefined) continue;
|
|
385
|
-
for (const targetIdx of index.relatedDocs(sourceIdx)) {
|
|
386
|
-
if (added >= budget) break;
|
|
387
|
-
if (claimed.has(targetIdx)) continue;
|
|
388
|
-
const target = index.docs[targetIdx];
|
|
389
|
-
claimed.add(targetIdx);
|
|
390
|
-
added += 1;
|
|
391
|
-
hits.push({
|
|
392
|
-
doc: target,
|
|
393
|
-
score: hit.score * (options.relatedBoost ?? 0.35),
|
|
394
|
-
matched: [],
|
|
395
|
-
snippet: extractSnippet(index.bodyOrPreview(targetIdx), queryTerms.slice(0, 10).map((term) => term.term), 280),
|
|
396
|
-
exact: false,
|
|
397
|
-
relatedTo: hit.doc.id,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Superseded notes are demoted (or dropped on request) but never hidden by
|
|
404
|
-
// default: the old decision stays searchable next to the one that replaced it.
|
|
405
|
-
const supersedeMap = index.supersedeMap();
|
|
406
|
-
if (supersedeMap.size > 0) {
|
|
407
|
-
for (const hit of hits) {
|
|
408
|
-
const supersededBy = supersedeMap.get(hit.doc.id);
|
|
409
|
-
if (!supersededBy || supersededBy.length === 0) continue;
|
|
410
|
-
hit.supersededBy = supersededBy;
|
|
411
|
-
hit.score *= SUPERSEDED_FACTOR;
|
|
412
|
-
}
|
|
413
|
-
if (options.dropSuperseded) {
|
|
414
|
-
for (let i = hits.length - 1; i >= 0; i -= 1) if (hits[i].supersededBy) hits.splice(i, 1);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
hits.sort((a, b) => b.score - a.score);
|
|
419
|
-
if (hits.length > limit) hits.length = limit;
|
|
420
|
-
for (const hit of hits) hit.score = Math.round(hit.score * 1000) / 1000;
|
|
421
|
-
|
|
422
|
-
if (options.explain) {
|
|
423
|
-
for (const hit of hits) {
|
|
424
|
-
const idx = index.idxForId(hit.doc.id);
|
|
425
|
-
if (idx === undefined) continue;
|
|
426
|
-
hit.breakdown = explainHit(index, idx, queryTerms, {
|
|
427
|
-
phrase: hit.exact ? 1 : 0,
|
|
428
|
-
related: hit.relatedTo ? 1 : 0,
|
|
429
|
-
supersededPenalty: hit.supersededBy ? 1 - SUPERSEDED_FACTOR : 0,
|
|
430
|
-
timeBoost: inWindow(index.docs[idx]) ? TIME_WINDOW_BOOST : 0,
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
return {
|
|
436
|
-
hits,
|
|
437
|
-
total: candidates.length,
|
|
438
|
-
tookMs: Math.round((performance.now() - started) * 1000) / 1000,
|
|
439
|
-
truncated,
|
|
440
|
-
missing: [...new Set(missing)].slice(0, 8),
|
|
441
|
-
timeWindow,
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Attribute a hit's score to its components and terms.
|
|
447
|
-
*
|
|
448
|
-
* Only runs with `explain: true`, and only for the returned hits, so it may
|
|
449
|
-
* afford a binary search per query term per hit.
|
|
450
|
-
*/
|
|
451
|
-
export function explainHit(
|
|
452
|
-
index: MemoryIndex,
|
|
453
|
-
idx: number,
|
|
454
|
-
queryTerms: QueryTerm[],
|
|
455
|
-
extra: { phrase: number; related: number; supersededPenalty: number; timeBoost: number },
|
|
456
|
-
): ScoreBreakdown {
|
|
457
|
-
const doc = index.docs[idx];
|
|
458
|
-
const avgDocLength = index.avgDocLength;
|
|
459
|
-
const k1Scale = K1 * (1 - B);
|
|
460
|
-
const bScale = K1 * B;
|
|
461
|
-
const n = Math.max(1, index.aliveCount);
|
|
462
|
-
let bm25 = 0;
|
|
463
|
-
const terms: ScoreBreakdown["terms"] = [];
|
|
464
|
-
for (const queryTerm of queryTerms) {
|
|
465
|
-
let contribution = 0;
|
|
466
|
-
let matches = 0;
|
|
467
|
-
if (queryTerm.termId >= 0) {
|
|
468
|
-
const tf = index.postingTfFor(queryTerm.termId, idx);
|
|
469
|
-
if (tf > 0) {
|
|
470
|
-
const df = index.documentFrequency(queryTerm.termId);
|
|
471
|
-
const idf = df > 0 ? Math.log(1 + (n - df + 0.5) / (df + 0.5)) : queryTerm.idf;
|
|
472
|
-
contribution = (idf * (tf * (K1 + 1)) * queryTerm.weight) / (tf + k1Scale + (bScale * doc.tokenCount) / avgDocLength);
|
|
473
|
-
matches = 1;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
if (contribution > 0) {
|
|
477
|
-
bm25 += contribution;
|
|
478
|
-
terms.push({ term: queryTerm.term, matches, contribution: Math.round(contribution * 1000) / 1000 });
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
terms.sort((a, b) => b.contribution - a.contribution);
|
|
482
|
-
const coverageTerms = queryTerms.filter((queryTerm) => queryTerm.termId >= 0 && index.postingTfFor(queryTerm.termId, idx) > 0).length;
|
|
483
|
-
const coverage = coverageTerms * 0.6;
|
|
484
|
-
const priorityRecency = baseScore(doc, Date.now());
|
|
485
|
-
const phrase = extra.phrase > 0 ? 3 : 0;
|
|
486
|
-
const related = extra.related > 0 ? 1 : 0;
|
|
487
|
-
const supersededPenalty = extra.supersededPenalty > 0 ? -(1 - SUPERSEDED_FACTOR) : 0;
|
|
488
|
-
return {
|
|
489
|
-
bm25: Math.round(bm25 * 1000) / 1000,
|
|
490
|
-
coverage: Math.round(coverage * 1000) / 1000,
|
|
491
|
-
priorityRecency: Math.round(priorityRecency * 1000) / 1000,
|
|
492
|
-
phrase,
|
|
493
|
-
related,
|
|
494
|
-
supersededPenalty: Math.round(supersededPenalty * 1000) / 1000,
|
|
495
|
-
timeBoost: extra.timeBoost,
|
|
496
|
-
total: Math.round((bm25 + coverage + priorityRecency + phrase + related + supersededPenalty + extra.timeBoost) * 1000) / 1000,
|
|
497
|
-
terms: terms.slice(0, 12),
|
|
498
|
-
};
|
|
499
|
-
}
|