fix(search): fold detail into the query-cache key (#3515) (#3544)

Wave-assembled from PR #3544 by @time-attack.

Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
test
2026-08-13 12:18:13 -07:00
committed by Sina Matian
co-authored by Time Attakc
parent 189bf856ee
commit 5bd2c51053
7 changed files with 98 additions and 10 deletions
+7
View File
@@ -1841,6 +1841,13 @@ export async function hybridSearchCached(
// resolves) into the cache key so a row written under one exclude
// policy can't be served to a lookup under another.
hardExcludes: resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes),
// #3515 — fold the EFFECTIVE detail level into the cache key. detail
// gates dedup, chunk-source filtering, and the compiled_truth boost, so
// a `--detail low` write (compiled-truth-only result set) must never be
// served to a default `medium` lookup. Resolve auto-detect the same way
// bare hybridSearch does (opts.detail ?? autoDetectDetail(query)) so an
// auto-detected `high` query keys like an explicit `high` one.
detail: opts?.detail ?? autoDetectDetail(query),
});
// Cache decision: opts.useCache (explicit) wins over global config; global
+28 -1
View File
@@ -779,7 +779,18 @@ export function attributeKnob<K extends keyof ModeBundle>(
// to cache.ttl_seconds, with no warning and no way for an operator to tell.
// Same one-time global cold-miss pattern as the bumps above; refills within
// cache.ttl_seconds (3600s default).
export const KNOBS_HASH_VERSION = 15;
//
// bump 15→16 (#3515): `detail` folds into the key via ctx.detail (det=).
// detail is result-affecting by design — it gates dedup, chunk-source
// filtering, and the compiled_truth boost — but was absent from the key, so
// a `--detail low` write (compiled-truth-only result set) was served to a
// default `medium` lookup for the whole TTL. Same contamination class as
// [CDX-4], floor_ratio (v=3), and relationalRetrieval (v=10). v=14 was
// claimed by #3514 (compiled_truth boost scope, #3430) and v=15 by the
// `fts=` fold (#3677), so this lands as v=16 per the D8 sequencing
// convention (see the v=4/v=5 note above). Same one-time global cold-miss
// pattern as the bumps above.
export const KNOBS_HASH_VERSION = 16;
/**
* v0.36 (D8 / CDX-2) — second-arg context for the cache key. The
@@ -818,6 +829,17 @@ export interface KnobsHashContext {
* 'none' for legacy callers that don't thread excludes.
*/
hardExcludes?: string[];
/**
* v=16 (#3515): the EFFECTIVE detail level for this call — per-call
* SearchOpts.detail, or the auto-detected level when the caller didn't
* specify (hybridSearchCached threads `opts.detail ?? autoDetectDetail(query)`,
* matching what bare hybridSearch resolves). detail gates dedup,
* chunk-source filtering, and the compiled_truth boost, so a detail=low
* write must never be served to a detail=medium lookup. Lives in ctx (not
* ResolvedSearchKnobs) because it's per-call, not a mode knob — same path
* as col=/prov=. Undefined falls back to 'medium' (the documented default).
*/
detail?: 'low' | 'medium' | 'high';
}
export function knobsHash(
@@ -921,6 +943,11 @@ export function knobsHash(
// memoizes and validates against /^[a-z][a-z0-9_]*$/, so this stays a
// cheap, bounded string.
`fts=${getFtsLanguage()}`,
// v=16 addition (#3515, append-only): effective detail level. detail
// gates dedup, chunk-source filtering, and the compiled_truth boost, so
// a low write (compiled-truth-only set) must never be served to a
// medium/high lookup. Undefined falls back to 'medium' (the default).
`det=${ctx?.detail ?? 'medium'}`,
];
const h = createHash('sha256');
h.update(parts.join('|'));
+3 -2
View File
@@ -136,7 +136,7 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
return resolveSearchMode({ mode: 'balanced' });
}
test('KNOBS_HASH_VERSION is 15 (cross-modal still appended; 14→15 FTS language fold)', () => {
test('KNOBS_HASH_VERSION is 16 (cross-modal still appended; 15→16 detail fold #3515)', () => {
// v0.35 ladder: 1→2 reranker, 2→3 floor_ratio. v0.36 piggybacks on v=3
// with 7 cross-modal knobs + column/provider context. v0.40.4 (salem) +
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
@@ -149,7 +149,8 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
// #3430: 13→14 compiled_truth boost no longer applies at detail=medium.
// 14→15: the resolved FTS configuration name (fts=) — a language switch
// plus `reindex-search-vector` must not keep serving pre-switch rows.
expect(KNOBS_HASH_VERSION).toBe(15);
// #3515: 15→16 detail fold (det=).
expect(KNOBS_HASH_VERSION).toBe(16);
});
test('flipping unified_multimodal changes the hash', () => {
@@ -301,6 +301,41 @@ describe('hard-exclude cache isolation (#2825)', () => {
});
});
describe('detail cache isolation (#3515)', () => {
// Hashes computed the way hybridSearchCached does: same resolved mode, ctx
// carrying the effective detail level. A row written by a `--detail low`
// call (compiled-truth-only result set) must not be served to a default
// `medium` lookup, and vice versa.
const lowHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), { detail: 'low' });
const mediumHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), { detail: 'medium' });
const unsetHash = knobsHash(resolveSearchMode({ mode: 'balanced' }));
test('detail=low write is NOT served to a default (medium) lookup', async () => {
const cache = new SemanticQueryCache(engine);
const emb = makeEmbedding(8);
// Simulate `query "X" --detail low` populating the cache with the
// narrow compiled-truth-only result set.
await cache.store('what is the deploy process', emb, makeResults('narrow', 2), {
vector_enabled: true, detail_resolved: 'low', expansion_applied: false,
}, { knobsHash: lowHash });
// Default-detail lookup inside the TTL → MISS (falls through to a
// fresh, full search) instead of the narrow set.
expect((await cache.lookup(emb, { knobsHash: mediumHash })).hit).toBe(false);
// The low-detail caller still hits its own row.
const original = await cache.lookup(emb, { knobsHash: lowHash });
expect(original.hit).toBe(true);
expect(original.results?.length).toBe(2);
});
test('undefined detail keys like the documented medium default', () => {
expect(unsetHash).toBe(mediumHash);
expect(unsetHash).not.toBe(lowHash);
});
});
describe('FTS language cache isolation', () => {
// GBRAIN_FTS_LANGUAGE retokenizes BOTH sides of the keyword arm (the
// trigger-built search_vector and the query-side websearch_to_tsquery), so
+2 -2
View File
@@ -89,7 +89,7 @@ describe('alias_resolved boost stage', () => {
});
describe('KNOBS_HASH_VERSION', () => {
it('is 15 (14→15 folds the resolved FTS configuration name, so rows written before a reindex-search-vector language switch become unreachable)', () => {
expect(KNOBS_HASH_VERSION).toBe(15);
it('is 16 (15→16 detail fold makes detail-contaminated rows unreachable, #3515)', () => {
expect(KNOBS_HASH_VERSION).toBe(16);
});
});
+19 -3
View File
@@ -420,7 +420,23 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// GBRAIN_FTS_LANGUAGE retokenizes both the trigger-built search_vector and
// the query-side tsquery, so rows written under the previous language must
// not survive a `reindex-search-vector` switch.
expect(KNOBS_HASH_VERSION).toBe(15);
// #3515: bumped 15→16 to fold the effective detail level (det=) — a
// detail=low write must not be served to a detail=medium lookup.
expect(KNOBS_HASH_VERSION).toBe(16);
});
test('#3515: detail set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
const knobs = resolveSearchMode({ mode: 'balanced' });
const low = knobsHash(knobs, { detail: 'low' });
const medium = knobsHash(knobs, { detail: 'medium' });
const high = knobsHash(knobs, { detail: 'high' });
const unset = knobsHash(knobs);
expect(low).not.toBe(medium);
expect(medium).not.toBe(high);
expect(low).not.toBe(high);
// Undefined falls back to 'medium' — the documented default — so legacy
// callers that don't thread detail share the default-detail rows.
expect(unset).toBe(medium);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -585,8 +601,8 @@ describe('v0.40.4 — graph_signals knob', () => {
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION is 15 (14→15 FTS language fold)', () => {
expect(KNOBS_HASH_VERSION).toBe(15);
test('KNOBS_HASH_VERSION is 16 (15→16 detail fold #3515)', () => {
expect(KNOBS_HASH_VERSION).toBe(16);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
+4 -2
View File
@@ -44,7 +44,7 @@ function baseKnobs(): ResolvedSearchKnobs {
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 15 (…; 11→12 hard-excludes #2825; 12→13 embedding-provider migration #3390; 14→15 FTS language)', () => {
test('version is 16 (…; 12→13 embedding-provider migration #3390; 14→15 FTS language; 15→16 detail fold #3515)', () => {
// v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold
// floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs
// (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation).
@@ -71,7 +71,9 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
// name (fts=). It retokenizes both the trigger-built search_vector and
// the query-side tsquery, so rows written under the previous language
// must not survive a `reindex-search-vector` language switch.
expect(KNOBS_HASH_VERSION).toBe(15);
// #3515: 15→16 to fold the effective detail level (det=) — a detail=low
// write must not be served to a detail=medium lookup.
expect(KNOBS_HASH_VERSION).toBe(16);
});
test('hash is 16 hex chars regardless of reranker config', () => {