Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/search/algorithms/hybrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
27 changes: 21 additions & 6 deletions src/search/doc-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
37 changes: 34 additions & 3 deletions tests/doc-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
20 changes: 19 additions & 1 deletion tests/search.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
// ─────────────────────────────────────────────────────────────────────────
Expand Down
Loading