diff --git a/src/search/algorithms/hybrid.ts b/src/search/algorithms/hybrid.ts index 0fe6ba5..3838e6f 100644 --- a/src/search/algorithms/hybrid.ts +++ b/src/search/algorithms/hybrid.ts @@ -28,8 +28,9 @@ export const hybridAlgorithm: SearchAlgorithm = { score(docs: SearchDoc[], query: string): ScoredBlock[] { const bm = bm25Algorithm.score(docs, query); const fz = fuzzyAlgorithm.score(docs, query); - const maxBm = Math.max(...bm.map((r) => r.score), 1e-9); - const maxFz = Math.max(...fz.map((r) => r.score), 1e-9); + // reduce, not Math.max(...spread): the spread trips V8's argument limit (~65K–125K docs) → RangeError. + const maxBm = bm.reduce((m, r) => (r.score > m ? r.score : m), 1e-9); + const maxFz = fz.reduce((m, r) => (r.score > m ? r.score : m), 1e-9); const bmMap = new Map(bm.map((r) => [r.ref, r.score / maxBm])); const fzMap = new Map(fz.map((r) => [r.ref, r.score / maxFz])); return docs.map((d) => ({ diff --git a/src/search/doc-cache.ts b/src/search/doc-cache.ts index 7fc57f6..2c5863c 100644 --- a/src/search/doc-cache.ts +++ b/src/search/doc-cache.ts @@ -10,10 +10,18 @@ * processed once; later searches are O(docs × query-terms). * * Keyed by doc text (immutable). Bounded by total cached source chars — - * oldest docs are evicted when the cap is exceeded, so a long-lived - * process serving many sessions cannot grow unboundedly. Hosts that want to - * release the memory eagerly on session shutdown/switch can call - * clearDocFeatures() (optional: the cap already bounds it). + * least-recently-used docs are evicted when the cap is exceeded (LRU), so a + * long-lived process serving many sessions cannot grow unboundedly, and the + * docs a host keeps re-querying are the ones retained. + * + * The default cap (8MB) is a conservative bound for multi-session servers. + * A single-session host whose corpus exceeds it would otherwise re-tokenize + * (corpus − cap) on EVERY call; call setDocCacheCap() with a value ≥ corpus + * size (or Infinity) to cache the whole corpus once and make later calls + * O(docs × query-terms). + * + * Hosts that want to release the memory eagerly on session shutdown/switch + * can call clearDocFeatures() (optional: the cap already bounds it). */ import { charBigrams, tfMap } from "./tokenizer.js"; @@ -44,7 +52,12 @@ function build(text: string): DocFeatures { export function docFeatures(text: string): DocFeatures { const hit = cache.get(text); - if (hit) return hit; + if (hit) { + // LRU: re-insert at the tail (most-recently-used); eviction takes from the head. + cache.delete(text); + cache.set(text, hit); + return hit; + } const f = build(text); if (text.length > 0 && text.length <= capChars) { while (cachedChars + text.length > capChars && cache.size > 0) { @@ -66,7 +79,9 @@ export function clearDocFeatures(): void { /** * Set the cache cap in source chars. Docs larger than the cap are never - * cached. Also used by tests to exercise eviction. + * cached. Pass a value ≥ the corpus size (or Infinity) to cache the whole + * corpus — a single-session host then avoids re-tokenizing on every call. + * Also used by tests to exercise eviction. */ export function setDocCacheCap(chars: number): void { capChars = Math.max(1, chars); diff --git a/tests/doc-cache.test.ts b/tests/doc-cache.test.ts index 59cc9d7..47091e2 100644 --- a/tests/doc-cache.test.ts +++ b/tests/doc-cache.test.ts @@ -59,15 +59,15 @@ test("clearDocFeatures: forces a rebuild", () => { assert.equal(docCacheInfo().entries, 1); }); -test("setDocCacheCap: small cap evicts oldest, info tracks occupancy", () => { +test("setDocCacheCap: small cap evicts least-recently-used, info tracks occupancy", () => { clearDocFeatures(); setDocCacheCap(20); try { docFeatures("a".repeat(10)); // 10 chars - docFeatures("b".repeat(10)); // evicts "a..." (10+10 <= 20 → both fit) + docFeatures("b".repeat(10)); // 10+10 <= 20 → both fit, no eviction assert.equal(docCacheInfo().entries, 2); assert.equal(docCacheInfo().chars, 20); - docFeatures("c".repeat(15)); // evicts "a...", then "b..." (15+10 > 20) + docFeatures("c".repeat(15)); // 20+15 > 20 → evict LRU head (a), then (b) assert.equal(docCacheInfo().entries, 1); assert.equal(docCacheInfo().chars, 15); } finally { @@ -76,6 +76,37 @@ test("setDocCacheCap: small cap evicts oldest, info tracks occupancy", () => { } }); +test("LRU: re-accessing a doc keeps it alive (FIFO would evict it)", () => { + clearDocFeatures(); + setDocCacheCap(25); + try { + const a = docFeatures("a".repeat(10)); // cache [a] + docFeatures("b".repeat(10)); // cache [a, b] + docFeatures("a".repeat(10)); // re-access a → LRU order [b, a] + docFeatures("c".repeat(15)); // 20+15>25 → evict LRU head (b); a survives + const a2 = docFeatures("a".repeat(10)); + assert.equal(a2, a, "LRU must keep the re-accessed doc; FIFO would have evicted it"); + } finally { + setDocCacheCap(DEFAULT_CAP); + clearDocFeatures(); + } +}); + +test("setDocCacheCap(Infinity): whole corpus cached, no eviction", () => { + clearDocFeatures(); + setDocCacheCap(Infinity); + try { + docFeatures("a".repeat(100)); + docFeatures("b".repeat(100)); + docFeatures("c".repeat(100)); + assert.equal(docCacheInfo().entries, 3, "no eviction under an Infinity cap"); + assert.equal(docCacheInfo().chars, 300); + } finally { + setDocCacheCap(DEFAULT_CAP); + clearDocFeatures(); + } +}); + test("setDocCacheCap: lowering the cap evicts immediately", () => { clearDocFeatures(); try { diff --git a/tests/search.test.ts b/tests/search.test.ts index 90480ec..3a2166f 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert"; -import { searchBlocks, searchBlocksAsync, blockDocs, messageDocs } from "../src/search.js"; +import { searchBlocks, searchBlocksAsync, blockDocs, messageDocs, clearDocFeatures } from "../src/search.js"; import { registerSearchAlgorithm, listSearchAlgorithms } from "../src/search.js"; import { createInitialState } from "../src/state.js"; import type { CompressionState, CompressionBlock } from "../src/types.js"; @@ -305,6 +305,24 @@ test("hybrid: OOV doc still recallable via bigram fallback", () => { assert.equal(r[0].ref, "b1"); }); +test("hybrid: no RangeError at large doc counts (argument-spread → reduce, #227)", () => { + // The old `Math.max(...scores)` spread threw RangeError past V8's argument + // limit (~65K–125K docs). 160K is the scale that crashed on Node 22. + clearDocFeatures(); + try { + const n = 160_000; + const docs: SearchDoc[] = new Array(n); + for (let i = 0; i < n; i++) { + docs[i] = { kind: "block", ref: `b${i}`, text: `doc ${i} alpha beta`, title: `b${i}`, blockId: `b${i}`, tier: 1, tokens: 10 }; + } + const r = searchBlocks(docs, "alpha", { algorithm: "hybrid", limit: 10 }); + assert.equal(r.length, 10); + assert.ok(r[0].score > 0); + } finally { + clearDocFeatures(); + } +}); + // ───────────────────────────────────────────────────────────────────────── // Async / semantic // ─────────────────────────────────────────────────────────────────────────