Files
gbrain/src/core/search/hybrid.ts
T
baf1a47798 v0.35.0.0 feat: ZeroEntropy zembed-1 + zerank-2 reranker (#1008)
* feat(ai): add ZeroEntropy recipe + reranker touchpoint type

Widens `TouchpointKind` with `'reranker'`, adds `RerankerTouchpoint`
interface, extends `Recipe.touchpoints` and `AIGatewayConfig` to carry
reranker model state. Registers `zeroentropyai` recipe (zembed-1
embeddings + zerank-{2,1,1-small} rerankers) in the recipe registry.

Recipe declares the 7 Matryoshka dims (2560/1280/640/320/160/80/40),
Voyage-style dense-payload hedge (chars_per_token=1, safety_factor=0.5),
and 5MB rerank payload cap. Pinned by test/ai/zeroentropy-recipe.test.ts
including F1 regression (implementation literal is 'openai-compatible')
and F2 regression (base_url_default ends with /v1, no doubling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai/dims): thread input_type 4th-arg + ZE flexible-dim allowlist

`dimsProviderOptions` gains an optional `inputType?: 'query' | 'document'`
4th param so asymmetric providers (ZE zembed-1, Voyage v3+) can route
query-side vs document-side encoding. Per-model filtering inside the
openai-compatible branch keeps `input_type` from leaking to symmetric
providers (OpenAI text-3, DashScope, Zhipu) that would 400 on it.

Adds `ZEROENTROPY_VALID_DIMS` allowlist (2560/1280/640/320/160/80/40),
`supportsZeroEntropyDimension(modelId)`, and `isValidZeroEntropyDim(dims)`.
Throws `AIConfigError` with paste-ready fix hint when zembed-1 is
configured with an invalid dim (most common: defaulting to 1536 from
DEFAULT_EMBEDDING_DIMENSIONS).

The 4th-arg is optional; existing call sites (1 production + N tests
across Voyage/OpenAI/DashScope/Zhipu/MiniMax) compile unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai/gateway): zeroEntropyCompatFetch + embedQuery + gateway.rerank()

Two seams land together because they share the same recipe + auth path.

zeroEntropyCompatFetch handles ZE's non-OpenAI-compatible wire shape:
  - URL rewrite: SDK's `${base_url}/embeddings` -> `${base_url}/models/embed`
  - Body inject: `input_type` (default 'document'; 'query' when threaded
    via providerOptions) + explicit `encoding_format: 'float'`
  - Response rewrite: `{results: [{embedding}]}` -> `{data: [{embedding,
    index}]}` so the AI SDK's openai-compat schema validates
  - `usage.prompt_tokens` injected from `total_tokens` (Voyage hit the
    same SDK schema requirement at :655)
  - Layer 1 (Content-Length) + Layer 2 (per-embedding size) OOM caps
    via tagged `ZeroEntropyResponseTooLargeError` (kept separate from
    `VoyageResponseTooLargeError` because the Voyage cap tests do
    structural source-text greps pinning the Voyage name)
  - Wired in `instantiateEmbedding()` via the existing
    `recipe.id === 'voyage' ? voyageCompatFetch : ...` ternary pattern

embedQuery(text) routes `inputType: 'query'` through dimsProviderOptions
for the search hot path. Companion to embed(texts) which now takes an
optional 2nd-arg inputType (defaults to undefined -> 'document' for
asymmetric providers).

gateway.rerank() is the new native HTTP path (no AI-SDK reranking
abstraction). Resolves the configured reranker model via
`getRerankerModel()` (new accessor), parses + asserts the model is in
the recipe's touchpoint.reranker.models allowlist (CDX2-F11:
assertTouchpoint does not enforce allowlists for openai-compatible
recipes — rerank() does it directly). Posts to
`${recipe.base_url}/models/rerank` with bearer auth. Returns
`RerankResult[]` sorted by `relevanceScore`. Errors classify into
`RerankError.reason: 'auth' | 'rate_limit' | 'network' | 'timeout' |
'payload_too_large' | 'unknown'`. 5s default timeout. Pre-flight payload
guard rejects bodies over `recipe.max_payload_bytes` BEFORE any HTTP
call so applyReranker can fail-open without burning a round-trip.
`_rerankTransport` + `__setRerankTransportForTests` mirror the embed
test seam.

`AIGatewayConfig.reranker_model` + isAvailable('reranker') branch +
configureGateway / reconfigureGatewayWithEngine extensions thread the
reranker model through the same state path as embedding/expansion/chat.
`applyResolveAuth` + `defaultResolveAuth` widen the touchpoint param to
include `'reranker'`. `KnownTouchpointKey` + `getTouchpoint()` in
model-resolver widen to cover `'reranker'`.

Pinned by:
- test/ai/embedQuery.test.ts (8): returns single Float32Array, threads
  input_type='query' for ZE, drops field for OpenAI text-3,
  back-compat: legacy embed() callers without 4th arg keep their
  previous Voyage no-input_type shape
- test/ai/rerank.test.ts (21): URL (F2 regression — no /v1/v1/), body
  shape, bearer header, response parsing, error classification across
  6 HTTP shapes, payload pre-flight (no transport call), allowlist
  enforcement
- test/ai/zeroentropy-compat-fetch.test.ts (14): structural source
  assertions for the shim that mirror test/voyage-response-cap.test.ts —
  URL rewrite path, body injection, response rewrite, usage.prompt_tokens
  injection, OOM caps Layer 1 + Layer 2 + instanceof rethrow,
  instantiateEmbedding wiring branch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(search): applyReranker + rerank-failure audit + hybrid wire-in

src/core/search/rerank.ts — the call-site abstraction. Slices the top
`opts.topNIn` deduped candidates, sends to gateway.rerank(), reorders by
relevanceScore desc, appends the un-reranked tail in its original RRF
order (recall protection). Fail-open on every RerankError.reason: logs
via `logRerankFailure` and returns the input array unchanged. Stamps
`rerank_score` onto reordered items. `topNOut: null` is the explicit
"don't truncate" signal — distinct from `undefined` (fall through to
mode bundle); pin in test (CDX2-F16).

src/core/rerank-audit.ts — failure-only JSONL audit at
`~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation;
mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure`
+ `readRecentRerankFailures(days)`. **No `logRerankSuccess`** — CDX2-F22
deliberately drops success-event logging: writing once per tokenmax
search is hot-path I/O churn AND success events leak query
volume + timing into a local audit. The doctor check reads
`search.reranker.enabled` first so "no events in window" gets
interpreted correctly (disabled -> healthy by definition; enabled ->
healthy because nothing failed). Query text is SHA-256-prefix-hashed
(8 hex chars) for privacy. Honors `GBRAIN_AUDIT_DIR`.

src/core/search/hybrid.ts — slots `applyReranker` between
`dedupResults()` and `enforceTokenBudget()` in the main RRF path.
Resolution: per-call `opts.reranker` overrides; otherwise pulled from
the resolved mode bundle (tokenmax -> enabled, others -> disabled in
commit 5). Cache rows store final reranked results; the bumped
knobsHash (commit 5) ensures rows can't leak across reranker configs.

src/core/types.ts — adds `SearchOpts.reranker` as a structural type so
callers can pass per-call overrides; runtime type lives in
src/core/search/rerank.ts (avoids circular import).

Tests:
- test/search/rerank.test.ts (14): reorder, tail preserve, fail-open on
  every error class, topNOut null vs number, score stamping, empty +
  enabled=false pass-through
- test/rerank-audit.test.ts (10): JSONL round-trip, error_summary
  truncated to 200, corrupt rows skipped, missing dir -> [], ISO-week
  rotation walks current + previous week, no logRerankSuccess export
  (CDX2-F22 contract)
- test/search/hybrid-reranker-integration.test.ts (6): reranker fires
  when enabled, doesn't when disabled, reorders correctly, preserves
  tail, stamps rerank_score, fail-opens on rerankerFn throw — uses
  PGLite + stubbed embed transport, no API keys

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(search/mode): reranker mode-bundle fields + KNOBS_HASH_VERSION v=2

Extends `ModeBundle` with five reranker fields: `reranker_enabled`,
`reranker_model`, `reranker_top_n_in`, `reranker_top_n_out`,
`reranker_timeout_ms`. Per-mode defaults:

  - conservative -> enabled=false (cost-sensitive)
  - balanced     -> enabled=false (opt-in via search.reranker.enabled)
  - tokenmax     -> enabled=true  (the high-cost-tolerant tier; ~$0.0003/query)

Defaults model to `zeroentropyai:zerank-2`, topNIn=30, topNOut=null
(no truncate by default; preserves tokenmax's searchLimit=50 end-to-end
per CDX2-F16), timeout_ms=5000.

`SearchKeyOverrides` + `SearchPerCallOpts` + `resolveSearchMode.pick`
all extend to thread the new fields through the resolution chain
(per-call -> per-key config -> mode bundle -> default).

`loadOverridesFromConfig` adds parsers for the five new
`search.reranker.*` config keys. `top_n_out` parsing distinguishes
three input shapes (CDX2-F15):
  key absent           -> undefined (fall through to mode bundle)
  'null'|'none'|empty  -> explicit null (no truncate)
  positive integer     -> that number

`SEARCH_MODE_CONFIG_KEYS` extends so `gbrain search modes --reset`
clears the reranker overrides too.

**KNOBS_HASH_VERSION bumps 1 -> 2** (CDX1-F14). Five new entries
appended to `parts[]` (append-only convention CDX2-F13; reordering
existing fields would silently rebuild every existing cache row).
Includes `reranker_timeout_ms` so a 5s -> 100ms change invalidates
stale rows (CDX2-F14: more fail-opens = different search behavior).

Mid-rolling-deploy note (CDX2-F12): v=1 and v=2 processes produce
distinct cacheRowIds for the same (source_id, query_text). Expect a
temporary hit-rate dip + cache-row doubling for hot queries. Clears
naturally within `cache.ttl_seconds` (default 3600s).

src/commands/search.ts extends `KNOB_DESCRIPTIONS` with five new
entries so `gbrain search modes` renders them. test/search-mode.test.ts
extends the three bundle fixtures and bumps the KNOBS_HASH_VERSION
expectation to 2.

Pinned by test/search/knobs-hash-reranker.test.ts (13): each of the 5
reranker fields independently flips the hash, top_n_out=null renders
stable, append-only convention enforced via source-position assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): probeRerankerConfig + reranker_health check

`gbrain models doctor` gains two new probes:

- `probeRerankerConfig` (zero-network) validates that the configured
  reranker model resolves through the recipe registry, that the recipe
  declares a `reranker` touchpoint, and that the model is in
  `touchpoint.models[]`. Direct allowlist check here — assertTouchpoint
  does not enforce allowlists for openai-compatible recipes (CDX2-F11).
  Surfaces paste-ready `gbrain config set search.reranker.model
  <zerank-2|zerank-1|zerank-1-small>` fix hint.

- `probeRerankerReachability` (1-token-equivalent) sends a minimal
  `{query: "probe", documents: ["probe"]}` rerank to verify auth + URL.
  Failures classify via `classifyError` into auth/rate_limit/network/
  unknown. Skipped silently when reranker is unconfigured.

Also extends `probeEmbeddingConfig` with a `providerId === 'zeroentropyai'`
branch that catches the silent-1536-default bug class for zembed-1
configurations (same posture as the existing Voyage branch).

`ProbeResult.touchpoint` widens to include `'reranker_config'`.

`gbrain doctor` adds `checkRerankerHealth` to both the abbreviated
(doctorReportRemote) and full (runDoctor) check sets. Logic:

  1) Read `search.reranker.enabled` first. Disabled + no failures =>
     'reranker disabled'. Enabled + no failures => healthy.
  2) Walk last 7 days of ~/.gbrain/audit/rerank-failures-*.jsonl.
  3) ANY auth failure warns (config-time problem the probe should have
     caught — surface it).
  4) ANY payload_too_large failure warns (workload mismatch).
  5) Transient (network/timeout/rate_limit) warns at >=5 in window.
     Below that they're noise; reranker fails open anyway.

CDX2-F21 blind-spot fix: reading enabled state first means "no events"
gets interpreted correctly — never confuses "never-used" with "success
logging broken" (the latter is impossible because there is no success
logging by design, CDX2-F22).

Engine-agnostic; file-based + one config-key read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): ZeroEntropy live API round-trip + wire into Tier 2 CI

test/e2e/zeroentropy-live.test.ts exercises the full stack against the
real api.zeroentropy.dev: embed (default 2560-dim + flexible 1280),
embedQuery (asymmetric query side), batch embed (3 distinct vectors),
rerank (3 docs sorted by relevance score, photosynthesis-relevant docs
beat the irrelevant cat doc), rerank with topN truncation.

Gated on `ZEROENTROPY_API_KEY`: every test prints `[skip]` and returns
early without assertions when the env var is unset, so fork PRs and
contributor machines without a ZE account stay green.

CI wire-up: `.github/workflows/e2e.yml` Tier 2 step adds
`test/e2e/zeroentropy-live.test.ts` to its `bun test` invocation and
exposes `ZEROENTROPY_API_KEY: ${{ secrets.ZEROENTROPY_API_KEY }}` to
the runner. The secret is set on garrytan/gbrain at the repo scope
(separately from this commit — set via `gh secret set` so the value
never lands in source).

Tier 1 stays mechanical (no API keys); Tier 2 is the natural home for
provider-live tests because it's already the API-keyed lane.

Cost: each full run fires ~6 small HTTP calls totaling well under a
cent at the published $0.025/1M-token rate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.33.3.0 feat: ZeroEntropy zembed-1 + zerank-2 reranker

Release notes for the ZeroEntropy support wave: zembed-1 embeddings
(flexible-dim 2560/1280/640/320/160/80/40, asymmetric input_type) and
zerank-2 cross-encoder reranking land as a new openai-compatible recipe
alongside OpenAI/Voyage. Reranker defaults ON for tokenmax mode, OFF
for conservative/balanced (~$0.0003/query at tokenmax topNIn=30; rounding
error vs the tier's $700/mo Opus pairing per the CLAUDE.md cost matrix).

Search now ends with `RRF -> dedup -> reranker -> token-budget` when
reranker is enabled; fails open to RRF order on any error class
(audit-logged at ~/.gbrain/audit/rerank-failures-*.jsonl).

`KNOBS_HASH_VERSION` bumps 1 -> 2 to fold reranker config into the
query_cache row key. Rolling-deploy operators should expect a temporary
cache hit-rate dip + cache-row doubling for hot queries (clears
naturally within `cache.ttl_seconds`, default 3600s).

Files in this commit are pure docs / version bump:
- VERSION + package.json bump to 0.33.3.0
- CHANGELOG.md release-summary entry with "How to take advantage" block
- CLAUDE.md Key Files annotations for the new recipe + rerank.ts +
  rerank-audit.ts + gateway extensions
- docs/ai-providers/zeroentropy.md one-pager (setup, knob reference,
  failure observability, troubleshooting table)
- skills/migrations/v0.33.3.md (purely informational: no required user
  action; reranker is opt-in everywhere, ZE embedding is opt-in)
- llms-full.txt regenerated to match CLAUDE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:48:36 -07:00

910 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hybrid Search with Reciprocal Rank Fusion (RRF)
* Ported from production Ruby implementation (content_chunk.rb)
*
* Pipeline: keyword + vector → RRF fusion → normalize → boost → cosine re-score → dedup
*
* RRF score = sum(1 / (60 + rank_in_list))
* Compiled truth boost: 2.0x for compiled_truth chunks after RRF normalization
* Cosine re-score: blend 0.7*rrf + 0.3*cosine for query-specific ranking
*/
import type { BrainEngine } from '../engine.ts';
import { MAX_SEARCH_LIMIT, clampSearchLimit } from '../engine.ts';
import type { SearchResult, SearchOpts, HybridSearchMeta } from '../types.ts';
import { embed, embedQuery } from '../embedding.ts';
import { dedupResults } from './dedup.ts';
import { applyReranker } from './rerank.ts';
import { autoDetectDetail, classifyQuery } from './query-intent.ts';
import { expandAnchors, hydrateChunks } from './two-pass.ts';
import { enforceTokenBudget } from './token-budget.ts';
import { recordSearchTelemetry } from './telemetry.ts';
import {
weightsForIntent,
effectiveRrfK,
applyExactMatchBoost,
} from './intent-weights.ts';
import {
SemanticQueryCache,
loadCacheConfig,
} from './query-cache.ts';
const RRF_K = 60;
const COMPILED_TRUTH_BOOST = 2.0;
/**
* Backlink boost coefficient. Score is multiplied by (1 + BACKLINK_BOOST_COEF * log(1 + count)).
* - 0 backlinks: factor = 1.0 (no boost).
* - 1 backlink: factor ~= 1.035.
* - 10 backlinks: factor ~= 1.12.
* - 100 backlinks: factor ~= 1.23.
* Applied AFTER cosine re-score so it survives normalization, BEFORE dedup so the
* boosted ranking determines which chunks per page are kept.
*/
const BACKLINK_BOOST_COEF = 0.05;
const DEBUG = process.env.GBRAIN_SEARCH_DEBUG === '1';
/**
* Apply backlink boost to a result list in place. Mutates each result's score
* by (1 + BACKLINK_BOOST_COEF * log(1 + count)). Pure data transform; no DB call.
* Caller fetches counts via engine.getBacklinkCounts.
*/
export function applyBacklinkBoost(results: SearchResult[], counts: Map<string, number>): void {
for (const r of results) {
const count = counts.get(r.slug) ?? 0;
if (count > 0) {
r.score *= (1.0 + BACKLINK_BOOST_COEF * Math.log(1 + count));
}
}
}
/**
* v0.29.1 — apply salience boost (emotional_weight + take_count, NO time
* component). Mirror of applyBacklinkBoost. Mutate-in-place; caller re-sorts.
*
* `scores` is keyed by `${source_id}::${slug}` (composite) so multi-source
* brains don't conflate same-slug pages across sources (codex pass-1 #3).
*
* strength: 'on' (k=0.15) or 'strong' (k=0.30); 'off' callers should not
* invoke this function. Logarithmic compression keeps the factor in
* [1.0, ~1.6] so a strong boost can't catastrophically flip rankings.
*/
export function applySalienceBoost(
results: SearchResult[],
scores: Map<string, number>,
strength: 'on' | 'strong',
): void {
const k = strength === 'strong' ? 0.30 : 0.15;
for (const r of results) {
const key = `${r.source_id ?? 'default'}::${r.slug}`;
const score = scores.get(key);
if (!score || score <= 0) continue;
r.score *= (1.0 + k * Math.log(1 + score));
}
}
/**
* v0.29.1 — apply per-prefix recency boost. Mutate-in-place; caller re-sorts.
*
* `dates` is keyed by `${source_id}::${slug}`. The boost factor for each
* page comes from the per-prefix decay map: `1 + coefficient × halflife /
* (halflife + days_old)`. Evergreen prefixes (halflifeDays=0) contribute 0
* (factor stays 1.0).
*
* strength: 'on' multiplies the coefficient by 1.0; 'strong' multiplies by
* 1.5 (more aggressive recency tilt). Pages with no date entry in the map
* are skipped (factor 1.0).
*/
export function applyRecencyBoost(
results: SearchResult[],
dates: Map<string, Date>,
strength: 'on' | 'strong',
decayMap: import('./recency-decay.ts').RecencyDecayMap,
fallback: import('./recency-decay.ts').RecencyDecayConfig,
nowMs: number = Date.now(),
): void {
const strengthMul = strength === 'strong' ? 1.5 : 1.0;
// Sort prefixes longest-first so 'media/articles/' matches before 'media/'.
const prefixes = Object.keys(decayMap).sort((a, b) => b.length - a.length);
for (const r of results) {
const key = `${r.source_id ?? 'default'}::${r.slug}`;
const d = dates.get(key);
if (!d) continue;
const daysOld = Math.max(0, (nowMs - d.getTime()) / 86_400_000);
// Find first matching prefix.
let cfg: import('./recency-decay.ts').RecencyDecayConfig = fallback;
for (const p of prefixes) {
if (r.slug.startsWith(p)) {
cfg = decayMap[p];
break;
}
}
if (cfg.halflifeDays === 0 || cfg.coefficient === 0) continue; // evergreen
const recencyComponent = cfg.coefficient * cfg.halflifeDays / (cfg.halflifeDays + daysOld);
const factor = 1.0 + strengthMul * recencyComponent;
r.score *= factor;
}
}
/**
* v0.29.1 — runPostFusionStages: wrap backlink + salience + recency in a
* single stage that fires from EVERY hybridSearch return path (codex
* pass-1 #2 + pass-2 #4: keyword-only, embed-fail-fallback, full-hybrid).
* Without this wrapper, salience='on' silently does nothing on keyless
* installs that fall back to keyword-only.
*
* Mutates `results` in place; caller re-sorts.
*/
export interface PostFusionOpts {
applyBacklinks: boolean;
salience: 'off' | 'on' | 'strong';
recency: 'off' | 'on' | 'strong';
decayMap?: import('./recency-decay.ts').RecencyDecayMap;
fallback?: import('./recency-decay.ts').RecencyDecayConfig;
}
export async function runPostFusionStages(
engine: import('../engine.ts').BrainEngine,
results: SearchResult[],
opts: PostFusionOpts,
): Promise<void> {
if (results.length === 0) return;
// Backlink stage (existing behavior, preserved).
if (opts.applyBacklinks) {
try {
const slugs = Array.from(new Set(results.map(r => r.slug)));
const counts = await engine.getBacklinkCounts(slugs);
applyBacklinkBoost(results, counts);
} catch {
// Non-fatal; preserves the existing pre-v0.29.1 contract.
}
}
// Composite refs for the orthogonal axes (multi-source isolation).
const refs = Array.from(
new Map(
results.map(r => [`${r.source_id ?? 'default'}::${r.slug}`, { slug: r.slug, source_id: r.source_id ?? 'default' }]),
).values(),
);
// Salience stage (mattering, no time).
if (opts.salience !== 'off') {
try {
const scores = await engine.getSalienceScores(refs);
applySalienceBoost(results, scores, opts.salience);
} catch {
// Non-fatal.
}
}
// Recency stage (per-prefix decay, no mattering).
if (opts.recency !== 'off') {
try {
const dates = await engine.getEffectiveDates(refs);
const { DEFAULT_RECENCY_DECAY, DEFAULT_FALLBACK } = await import('./recency-decay.ts');
applyRecencyBoost(
results,
dates,
opts.recency,
opts.decayMap ?? DEFAULT_RECENCY_DECAY,
opts.fallback ?? DEFAULT_FALLBACK,
);
} catch {
// Non-fatal.
}
}
}
export interface HybridSearchOpts extends SearchOpts {
expansion?: boolean;
expandFn?: (query: string) => Promise<string[]>;
/** Override default RRF K constant (default: 60). Lower values boost top-ranked results more. */
rrfK?: number;
/** Override dedup pipeline parameters. */
dedupOpts?: {
cosineThreshold?: number;
maxTypeRatio?: number;
maxPerPage?: number;
};
/**
* v0.25.0 — optional side-channel for what hybridSearch actually did
* (vector ran or fell back, expansion fired or didn't, post-auto-detect
* detail). Surfaced via callback so the bare-return contract stays as
* `Promise<SearchResult[]>` for existing Cathedral II callers. Op-layer
* eval capture passes a callback that threads `meta` into the captured
* row; everyone else leaves it undefined and pays no cost.
*/
onMeta?: (meta: HybridSearchMeta) => void;
}
export async function hybridSearch(
engine: BrainEngine,
query: string,
opts?: HybridSearchOpts,
): Promise<SearchResult[]> {
// v0.32.3 search-lite mode: resolve the active mode + per-key overrides
// once at entry. Mode supplies DEFAULTS for intentWeighting, tokenBudget,
// expansion, and searchLimit when the caller leaves those undefined.
// Per-call opts and per-key config overrides still win.
//
// This MUST live in bare hybridSearch (NOT just in hybridSearchCached)
// because eval-replay and eval-longmemeval call bare hybridSearch — and
// per-mode evals would not test production search if modes lived only in
// the wrapper. See `[CDX-5+6]` in the plan.
const { loadSearchModeConfig, resolveSearchMode } = await import('./mode.ts');
const modeInput = await loadSearchModeConfig(engine);
const resolvedMode = resolveSearchMode({
mode: modeInput.mode,
overrides: modeInput.overrides,
perCall: {
intentWeighting: opts?.intentWeighting,
tokenBudget: opts?.tokenBudget,
expansion: opts?.expansion,
searchLimit: opts?.limit,
},
});
const limit = opts?.limit || resolvedMode.searchLimit;
const offset = opts?.offset || 0;
const innerLimit = Math.min(limit * 2, MAX_SEARCH_LIMIT);
// v0.32.x search-lite: classify intent once up front. Drives BOTH the
// legacy auto-detail / salience / recency suggestions AND the new
// weight-adjustment path. Intent weighting is on by default and can
// be disabled via `opts.intentWeighting = false`. The mode bundle
// supplies the default when neither per-call nor per-key sets it.
const suggestions = classifyQuery(query);
const intentWeightingOn = resolvedMode.intentWeighting;
const intentWeights = intentWeightingOn
? weightsForIntent(suggestions.intent)
: weightsForIntent('general');
// Auto-detect detail level from query intent when caller doesn't specify
const detail = opts?.detail ?? autoDetectDetail(query);
const detailResolved: 'low' | 'medium' | 'high' | null = detail ?? null;
const searchOpts: SearchOpts = {
limit: innerLimit,
detail,
// v0.20.0 Cathedral II Layer 10 — thread language + symbolKind through so
// per-engine searchKeyword / searchVector apply the filters at SQL level.
language: opts?.language,
symbolKind: opts?.symbolKind,
// v0.33: multi-type filter for whoknows ('person','company'). Pushes
// type filter to SQL level so the limit budget goes to candidate-typed
// pages instead of being eaten by note/transcript/article pages.
types: opts?.types,
// v0.29.1: since/until take precedence over deprecated afterDate/beforeDate.
// The engine still consumes the legacy field names; this aliasing keeps
// PR #618 callers compiling while the new names are the public surface.
afterDate: opts?.since ?? opts?.afterDate,
beforeDate: opts?.until ?? opts?.beforeDate,
// v0.34.1 (#861, D9 — P0 leak seal): thread source-scoping through so the
// inner engine.searchKeyword / engine.searchVector calls apply the
// WHERE source_id filter at SQL level. Pre-fix, this explicit pick
// silently DROPPED these fields and every authenticated MCP client
// could see pages from foreign sources via the hybrid search hot
// path. New SearchOpts fields scoped to source isolation MUST be
// added here too; the rebuild shape is intentional (HNSW inner-CTE
// ordering means we can't lazy-spread the full opts).
sourceId: opts?.sourceId,
sourceIds: opts?.sourceIds,
};
// Track what actually ran for the optional onMeta callback (v0.25.0).
// Caller leaves onMeta undefined → these flags are computed but never
// surfaced. Capture wrapper passes a closure to receive the meta and
// threads it into the eval_candidates row.
let expansionApplied = false;
// A throwing user callback must never break the search hot path — onMeta
// is a public surface (gbrain/search/hybrid) so a third-party closure bug
// shouldn't take down query/search responses.
//
// v0.32.3 search-lite: every emitMeta call ALSO records to the in-process
// search_telemetry rollup. Telemetry write is sync (bumps a bucket map),
// flush is fire-and-forget on 60s / 100-call thresholds. The hot path
// never waits.
let lastResultsCount = 0;
const emitMeta = (meta: HybridSearchMeta): void => {
try {
opts?.onMeta?.(meta);
} catch {
// swallow — capture telemetry is best-effort
}
try {
recordSearchTelemetry(engine, meta, { results_count: lastResultsCount });
} catch {
// swallow — telemetry must never break the search hot path.
}
};
if (DEBUG && detail) {
console.error(`[search-debug] auto-detail=${detail} for query="${query}"`);
}
// Run keyword search (always available, no API key needed)
const keywordResults = await engine.searchKeyword(query, searchOpts);
// v0.29.1: resolve salience/recency from caller (back-compat aliases for
// PR #618's `recencyBoost` numeric scale) or fall back to the heuristic.
// The wrapper fires from ALL THREE return paths (codex pass-1 #2 + pass-2 #4).
//
// v0.32.x search-lite: when caller hasn't set recency and the intent
// classifier suggests one, prefer that (suggestedRecency on temporal /
// event intents). The legacy heuristic still wins when intent weighting
// is off.
// Back-compat: recencyBoost: 1|2 → 'on'|'strong'; 0 → 'off'.
const legacyRecency: 'off' | 'on' | 'strong' | undefined =
opts?.recencyBoost === 2 ? 'strong' :
opts?.recencyBoost === 1 ? 'on' :
opts?.recencyBoost === 0 ? 'off' :
undefined;
const salienceMode: 'off' | 'on' | 'strong' = opts?.salience ?? suggestions.suggestedSalience;
// Intent-weighting recency suggestion is a NUDGE — it only fires when
// the caller left recency unspecified AND the legacy heuristic also
// didn't fire. The classifier's own suggestedRecency (from v0.29.1)
// still wins when it's set; the new intent suggestion is a fallback.
const intentRecency =
intentWeightingOn && intentWeights.suggestedRecency != null
? intentWeights.suggestedRecency
: null;
const recencyMode: 'off' | 'on' | 'strong' =
opts?.recency
?? legacyRecency
?? (suggestions.suggestedRecency !== 'off'
? suggestions.suggestedRecency
: (intentRecency ?? suggestions.suggestedRecency));
const postFusionOpts = {
applyBacklinks: true,
salience: salienceMode,
recency: recencyMode,
};
// Skip vector search entirely if the gateway has no embedding provider configured (Codex C3).
const { isAvailable } = await import('../ai/gateway.ts');
if (!isAvailable('embedding')) {
if (keywordResults.length > 0) {
await runPostFusionStages(engine, keywordResults, postFusionOpts);
keywordResults.sort((a, b) => b.score - a.score);
}
const noEmbedSliced = dedupResults(keywordResults).slice(offset, offset + limit);
// v0.32.3 search-lite: budget enforcement on the no-embedding-provider path.
const { results: noEmbedBudgeted, meta: noEmbedBudgetMeta } = enforceTokenBudget(noEmbedSliced, resolvedMode.tokenBudget);
lastResultsCount = noEmbedBudgeted.length;
emitMeta({
vector_enabled: false,
detail_resolved: detailResolved,
expansion_applied: false,
intent: suggestions.intent,
mode: resolvedMode.resolved_mode,
...(resolvedMode.tokenBudget && resolvedMode.tokenBudget > 0
? { token_budget: noEmbedBudgetMeta }
: {}),
});
return noEmbedBudgeted;
}
// Determine query variants (optionally with expansion)
// expandQuery already includes the original query in its return value,
// so we use it directly instead of prepending query again.
// v0.32.3 search-lite: expansion fires when (a) resolved mode says yes and
// (b) an expandFn is wired in. The mode bundle is the default; per-call
// SearchOpts.expansion still wins via resolveSearchMode's chain.
let queries = [query];
if (resolvedMode.expansion && opts?.expandFn) {
try {
queries = await opts.expandFn(query);
if (queries.length === 0) queries = [query];
// "Applied" = produced variants beyond the original, not just called.
expansionApplied = queries.length > 1;
} catch {
// Expansion failure is non-fatal
}
}
// Embed all query variants and run vector search
let vectorLists: SearchResult[][] = [];
let queryEmbedding: Float32Array | null = null;
try {
// v0.35.0.0+: query-side embedding. For asymmetric providers (ZE zembed-1,
// Voyage v3+) routes input_type='query' through the embed seam; symmetric
// providers ignore the field — no behavior change.
const embeddings = await Promise.all(queries.map(q => embedQuery(q)));
queryEmbedding = embeddings[0];
vectorLists = await Promise.all(
embeddings.map(emb => engine.searchVector(emb, searchOpts)),
);
} catch {
// Embedding failure is non-fatal, fall back to keyword-only
}
if (vectorLists.length === 0) {
// Embed/vector failed silently; record that vector did not run.
// v0.29.1 codex pass-2 #4: this is the third return path. Apply
// post-fusion stages here too — without it, salience='on' silently
// does nothing on embed failures.
if (keywordResults.length > 0) {
await runPostFusionStages(engine, keywordResults, postFusionOpts);
keywordResults.sort((a, b) => b.score - a.score);
}
const kwSliced = dedupResults(keywordResults).slice(offset, offset + limit);
// v0.32.3 search-lite: budget enforcement on the keyword-fallback path too.
const { results: kwBudgeted, meta: kwBudgetMeta } = enforceTokenBudget(kwSliced, resolvedMode.tokenBudget);
lastResultsCount = kwBudgeted.length;
emitMeta({
vector_enabled: false,
detail_resolved: detailResolved,
expansion_applied: expansionApplied,
intent: suggestions.intent,
mode: resolvedMode.resolved_mode,
...(resolvedMode.tokenBudget && resolvedMode.tokenBudget > 0
? { token_budget: kwBudgetMeta }
: {}),
});
return kwBudgeted;
}
// Merge all result lists via RRF (includes normalization + boost)
// Skip boost for detail=high (temporal/event queries want natural ranking)
//
// v0.32.x search-lite: when intent weighting is on, run RRF with
// per-list effective k values — entity/event intents nudge keyword
// contributions up by lowering their k. The base rrfK still controls
// the overall RRF shape; intent weights tilt within that shape.
const baseRrfK = opts?.rrfK ?? RRF_K;
const keywordK = effectiveRrfK(baseRrfK, intentWeights.keywordWeight);
const vectorK = effectiveRrfK(baseRrfK, intentWeights.vectorWeight);
const allLists: Array<{ list: SearchResult[]; k: number }> = [
...vectorLists.map(list => ({ list, k: vectorK })),
{ list: keywordResults, k: keywordK },
];
let fused = rrfFusionWeighted(allLists, detail !== 'high');
// Cosine re-scoring before dedup so semantically better chunks survive
if (queryEmbedding) {
fused = await cosineReScore(engine, fused, queryEmbedding);
}
// v0.29.1: post-fusion stages (backlink + salience + recency) run via
// runPostFusionStages so all three early-return paths share the same
// boost surface. Salience and recency are independent axes — either,
// both, or neither fires depending on resolved modes.
if (fused.length > 0) {
await runPostFusionStages(engine, fused, postFusionOpts);
// v0.32.x search-lite: intent exact-match boost (entity/event intents).
// No-op when boost factor is 1.0 (general intent or weighting disabled).
if (intentWeights.exactMatchBoost !== 1.0) {
applyExactMatchBoost(fused, query, intentWeights);
}
fused.sort((a, b) => b.score - a.score);
}
// v0.20.0 Cathedral II Layer 7 (A2): two-pass structural expansion.
// Default OFF. When opts.walkDepth > 0 OR opts.nearSymbol is set, we
// walk code_edges_chunk + code_edges_symbol up to walkDepth hops from
// the anchor set (top of `fused`). Expanded neighbors get score decayed
// by 1/(1+hop) from their anchor's score and merge back into the pool.
//
// Dedup per-page cap lifts to min(10, walkDepth * 5) when walking —
// structural neighbors from the same file/class are the whole point
// of two-pass; clipping them at 2/page defeats A2 (codex F5).
const walkDepth = Math.min(opts?.walkDepth ?? 0, 2);
const needsExpansion = walkDepth > 0 || Boolean(opts?.nearSymbol);
let dedupOpts = opts?.dedupOpts;
if (needsExpansion) {
const anchorSet = fused.slice(0, Math.max(10, limit));
try {
const expanded = await expandAnchors(engine, anchorSet, {
walkDepth,
nearSymbol: opts?.nearSymbol,
sourceId: opts?.sourceId,
});
// Resolve new chunk IDs (not already in fused) into full rows.
const existingIds = new Set(fused.map(r => r.chunk_id));
const newIds = expanded
.filter(e => !existingIds.has(e.chunk_id))
.map(e => e.chunk_id);
if (newIds.length > 0) {
const hydrated = await hydrateChunks(engine, newIds);
const scoreById = new Map(expanded.map(e => [e.chunk_id, e.score]));
for (const r of hydrated) {
r.score = scoreById.get(r.chunk_id) ?? 0.01;
fused.push(r);
}
fused.sort((a, b) => b.score - a.score);
}
// Widen per-page dedup cap when walking.
const capFromWalk = Math.min(10, Math.max(walkDepth * 5, 5));
dedupOpts = { ...(dedupOpts ?? {}), maxPerPage: capFromWalk };
} catch {
// Expansion is best-effort — missing edge tables or a transient
// DB error must not break base hybrid retrieval.
}
}
// v0.27.0 PR #618 recency boost was here; v0.29.1 unifies it into
// runPostFusionStages above so all three return paths get the same
// treatment. PR #618's recencyBoost: 0|1|2 still works via back-compat
// aliasing in the postFusionOpts resolver near line ~256.
// Dedup
const deduped = dedupResults(fused, dedupOpts);
// Auto-escalate: if detail=low returned 0, retry with high. The inner
// call's onMeta fires with the escalated detail_resolved; do NOT also
// fire here (would double-emit and capture stale meta).
if (deduped.length === 0 && opts?.detail === 'low') {
return hybridSearch(engine, query, { ...opts, detail: 'high' });
}
// v0.35.0.0+: cross-encoder reranker. Slots between dedup and slice so the
// reranker sees the full candidate pool (its own topNIn caps how many
// get sent upstream). Fail-open: any error returns deduped unchanged.
//
// Resolution: per-call SearchOpts.reranker overrides; otherwise pull
// from the resolved mode bundle (tokenmax → enabled, others → disabled).
// The resolved mode's fields already participate in knobsHash, so cache
// rows naturally segregate by reranker config.
const rerankerOpts = opts?.reranker ?? {
enabled: resolvedMode.reranker_enabled,
topNIn: resolvedMode.reranker_top_n_in,
topNOut: resolvedMode.reranker_top_n_out,
model: resolvedMode.reranker_model,
timeoutMs: resolvedMode.reranker_timeout_ms,
};
const reranked = rerankerOpts.enabled
? await applyReranker(query, deduped, rerankerOpts as any)
: deduped;
const sliced = reranked.slice(offset, offset + limit);
// v0.32.3 search-lite: budget enforcement at the main return path.
// hybridSearchCached used to be the only place this fired; now bare
// hybridSearch enforces it too so eval-replay + eval-longmemeval see
// the same budget behavior as the production query op.
const { results: budgeted, meta: budgetMeta } = enforceTokenBudget(sliced, resolvedMode.tokenBudget);
lastResultsCount = budgeted.length;
emitMeta({
vector_enabled: true,
detail_resolved: detailResolved,
expansion_applied: expansionApplied,
intent: suggestions.intent,
mode: resolvedMode.resolved_mode,
...(resolvedMode.tokenBudget && resolvedMode.tokenBudget > 0
? { token_budget: budgetMeta }
: {}),
});
return budgeted;
}
// ----------------------------------------------------------------------
// v0.32.x (search-lite) — cached + budgeted public wrapper
// ----------------------------------------------------------------------
/**
* Public wrapper around hybridSearch that adds the v0.32.x search-lite
* features: semantic query cache + token budget enforcement. Both are
* additive and backward-compatible; callers that don't opt in see the
* same behavior as plain hybridSearch.
*
* Pipeline:
* 1. Cache lookup (if enabled + we can produce a query embedding).
* 2. On miss: run hybridSearch normally.
* 3. Apply token budget (no-op when budget is undefined).
* 4. On miss + successful search: write back to cache (best-effort).
*
* The cache uses the same embedding the search pipeline would compute,
* so an extra embed() call only happens when hybridSearch would have
* skipped vector search entirely (no embedding provider configured). In
* that case the cache is also skipped — there's no embedding to key on.
*/
export async function hybridSearchCached(
engine: BrainEngine,
query: string,
opts?: HybridSearchOpts,
): Promise<SearchResult[]> {
// v0.32.3 search-lite mode: resolve mode + per-key overrides once. The
// resolved knob set drives cache enable/threshold/TTL AND the knobs_hash
// that scopes the cache row so a tokenmax write can't be served to a
// conservative read. See [CDX-4] in the plan.
const { loadSearchModeConfig, resolveSearchMode, knobsHash } = await import('./mode.ts');
const modeInputForCache = await loadSearchModeConfig(engine);
const resolvedForCache = resolveSearchMode({
mode: modeInputForCache.mode,
overrides: modeInputForCache.overrides,
perCall: {
cache_enabled: opts?.useCache,
tokenBudget: opts?.tokenBudget,
expansion: opts?.expansion,
intentWeighting: opts?.intentWeighting,
searchLimit: opts?.limit,
},
});
const cacheKnobsHash = knobsHash(resolvedForCache);
// Cache decision: opts.useCache (explicit) wins over global config; global
// config wins over mode bundle default. Mode bundle is on for all 3 modes
// today; the resolver already folded everything through.
const cacheCfg = await loadCacheConfig(engine);
const cacheEnabled = resolvedForCache.cache_enabled;
const cache = new SemanticQueryCache(engine, {
...cacheCfg,
enabled: cacheEnabled,
similarityThreshold: resolvedForCache.cache_similarity_threshold,
ttlSeconds: resolvedForCache.cache_ttl_seconds,
});
// Skip cache entirely when the request asks for two-pass walks or has
// a non-default embedding column — those interact with structural state
// that the cache can't safely express.
const skipCache =
!cache.isEnabled() ||
(opts?.walkDepth ?? 0) > 0 ||
Boolean(opts?.nearSymbol) ||
(opts?.embeddingColumn && opts.embeddingColumn !== 'embedding');
let cacheStatus: 'hit' | 'miss' | 'disabled' = skipCache ? 'disabled' : 'miss';
let cacheSimilarity: number | undefined;
let cacheAge: number | undefined;
// We need a query embedding to consult the cache. We try to embed once
// here so the same embedding can be threaded back into the search call
// if it misses — but the embedding helper isn't cheap, so we only
// attempt it when the cache is enabled AND the gateway has an embedding
// provider configured.
let queryEmbedding: Float32Array | null = null;
if (!skipCache) {
try {
const { isAvailable } = await import('../ai/gateway.ts');
if (isAvailable('embedding')) {
// v0.35.0.0+: query-side embedding (cache lookup path).
queryEmbedding = await embedQuery(query);
} else {
cacheStatus = 'disabled';
}
} catch {
cacheStatus = 'disabled';
queryEmbedding = null;
}
}
if (!skipCache && queryEmbedding && cacheStatus !== 'disabled') {
const hit = await cache.lookup(queryEmbedding, { sourceId: opts?.sourceId, knobsHash: cacheKnobsHash });
if (hit.hit && hit.results) {
cacheStatus = 'hit';
cacheSimilarity = hit.similarity;
cacheAge = hit.ageSeconds;
const limit = opts?.limit || 20;
const offset = opts?.offset || 0;
const sliced = hit.results.slice(offset, offset + limit);
// Budget enforcement — same pipeline tail as fresh path.
const { results: budgeted, meta: budgetMeta } = enforceTokenBudget(sliced, opts?.tokenBudget);
// Emit meta describing the cache path.
const cachedMeta: HybridSearchMeta = {
vector_enabled: hit.meta?.vector_enabled ?? true,
detail_resolved: hit.meta?.detail_resolved ?? null,
expansion_applied: hit.meta?.expansion_applied ?? false,
intent: hit.meta?.intent,
cache: {
status: 'hit',
similarity: cacheSimilarity,
age_seconds: cacheAge,
},
...(opts?.tokenBudget && opts.tokenBudget > 0
? { token_budget: budgetMeta }
: {}),
};
try {
opts?.onMeta?.(cachedMeta);
} catch {
// swallow — telemetry is best-effort
}
return budgeted;
}
}
// Cache miss (or disabled): run the normal search. We capture meta so
// we can write back to the cache + emit the merged meta to the caller.
// The closure-write pattern trips TS's narrowing (it infers `never`), so
// we use a single-element box to keep the type stable.
const innerMetaBox: { current: HybridSearchMeta | null } = { current: null };
const userOnMeta = opts?.onMeta;
const results = await hybridSearch(engine, query, {
...opts,
onMeta: (m) => {
innerMetaBox.current = m;
// Do NOT call userOnMeta here — we'll emit a merged meta below
// that also carries cache + budget info.
},
});
const innerMeta = innerMetaBox.current;
// Token budget pass (no-op when not set).
const { results: budgeted, meta: budgetMeta } = enforceTokenBudget(results, opts?.tokenBudget);
// Compose the final meta and emit.
const finalMeta: HybridSearchMeta = {
vector_enabled: innerMeta?.vector_enabled ?? false,
detail_resolved: innerMeta?.detail_resolved ?? null,
expansion_applied: innerMeta?.expansion_applied ?? false,
intent: innerMeta?.intent,
cache: { status: cacheStatus },
...(opts?.tokenBudget && opts.tokenBudget > 0
? { token_budget: budgetMeta }
: {}),
};
try {
userOnMeta?.(finalMeta);
} catch {
// swallow
}
// Best-effort writeback (skip when search returned empty so we don't
// cache zero-result queries forever — they often indicate a typo).
if (
cacheStatus === 'miss' &&
queryEmbedding &&
results.length > 0 &&
(innerMeta?.vector_enabled ?? false)
) {
void cache
.store(query, queryEmbedding, results, finalMeta, { sourceId: opts?.sourceId, knobsHash: cacheKnobsHash })
.catch(() => { /* swallow */ });
}
return budgeted;
}
/**
* v0.32.x search-lite — weighted RRF. Each list contributes with its own
* effective k value, which lets intent weighting bias keyword vs vector
* lists without re-weighting individual scores. Wraps rrfFusion internally
* by computing weighted contributions in a single pass.
*/
export function rrfFusionWeighted(
lists: Array<{ list: SearchResult[]; k: number }>,
applyBoost = true,
): SearchResult[] {
const scores = new Map<string, { result: SearchResult; score: number }>();
for (const { list, k } of lists) {
for (let rank = 0; rank < list.length; rank++) {
const r = list[rank];
const key = `${r.slug}:${r.chunk_id ?? r.chunk_text.slice(0, 50)}`;
const existing = scores.get(key);
const rrfScore = 1 / (k + rank);
if (existing) {
existing.score += rrfScore;
} else {
scores.set(key, { result: r, score: rrfScore });
}
}
}
const entries = Array.from(scores.values());
if (entries.length === 0) return [];
const maxScore = Math.max(...entries.map(e => e.score));
if (maxScore > 0) {
for (const e of entries) {
e.score = e.score / maxScore;
const boost = applyBoost && e.result.chunk_source === 'compiled_truth' ? COMPILED_TRUTH_BOOST : 1.0;
e.score *= boost;
}
}
return entries
.sort((a, b) => b.score - a.score)
.map(({ result, score }) => ({ ...result, score }));
}
/**
* Reciprocal Rank Fusion: merge multiple ranked lists.
* Each result gets score = sum(1 / (K + rank)) across all lists it appears in.
* After accumulation: normalize to 0-1, then boost compiled_truth chunks.
*/
export function rrfFusion(lists: SearchResult[][], k: number, applyBoost = true): SearchResult[] {
const scores = new Map<string, { result: SearchResult; score: number }>();
for (const list of lists) {
for (let rank = 0; rank < list.length; rank++) {
const r = list[rank];
const key = `${r.slug}:${r.chunk_id ?? r.chunk_text.slice(0, 50)}`;
const existing = scores.get(key);
const rrfScore = 1 / (k + rank);
if (existing) {
existing.score += rrfScore;
} else {
scores.set(key, { result: r, score: rrfScore });
}
}
}
const entries = Array.from(scores.values());
if (entries.length === 0) return [];
// Normalize to 0-1 by dividing by observed max
const maxScore = Math.max(...entries.map(e => e.score));
if (maxScore > 0) {
for (const e of entries) {
const rawScore = e.score;
e.score = e.score / maxScore;
// Apply compiled truth boost after normalization (skip for detail=high)
const boost = applyBoost && e.result.chunk_source === 'compiled_truth' ? COMPILED_TRUTH_BOOST : 1.0;
e.score *= boost;
if (DEBUG) {
console.error(`[search-debug] ${e.result.slug}:${e.result.chunk_id} rrf_raw=${rawScore.toFixed(4)} rrf_norm=${(rawScore / maxScore).toFixed(4)} boost=${boost} boosted=${e.score.toFixed(4)} source=${e.result.chunk_source}`);
}
}
}
// Sort by boosted score descending
return entries
.sort((a, b) => b.score - a.score)
.map(({ result, score }) => ({ ...result, score }));
}
/**
* Cosine re-scoring: blend RRF score with query-chunk cosine similarity.
* Runs before dedup so semantically better chunks survive.
*/
async function cosineReScore(
engine: BrainEngine,
results: SearchResult[],
queryEmbedding: Float32Array,
): Promise<SearchResult[]> {
const chunkIds = results
.map(r => r.chunk_id)
.filter((id): id is number => id != null);
if (chunkIds.length === 0) return results;
let embeddingMap: Map<number, Float32Array>;
try {
embeddingMap = await engine.getEmbeddingsByChunkIds(chunkIds);
} catch {
// DB error is non-fatal, return results without re-scoring
return results;
}
if (embeddingMap.size === 0) return results;
// Normalize RRF scores to 0-1 for blending
const maxRrf = Math.max(...results.map(r => r.score));
return results.map(r => {
const chunkEmb = r.chunk_id != null ? embeddingMap.get(r.chunk_id) : undefined;
if (!chunkEmb) return r;
const cosine = cosineSimilarity(queryEmbedding, chunkEmb);
const normRrf = maxRrf > 0 ? r.score / maxRrf : 0;
const blended = 0.7 * normRrf + 0.3 * cosine;
if (DEBUG) {
console.error(`[search-debug] ${r.slug}:${r.chunk_id} cosine=${cosine.toFixed(4)} norm_rrf=${normRrf.toFixed(4)} blended=${blended.toFixed(4)}`);
}
return { ...r, score: blended };
}).sort((a, b) => b.score - a.score);
}
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dot = 0, magA = 0, magB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
const denom = Math.sqrt(magA) * Math.sqrt(magB);
return denom === 0 ? 0 : dot / denom;
}