sweet-search 0.0.1 → 2.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/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- package/scripts/verify-runtime.js +176 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Semantic Module
|
|
3
|
+
*
|
|
4
|
+
* Extracted from sweet-search.js (SOLID refactor).
|
|
5
|
+
* Contains the 3-stage semantic search pipeline and standard semantic search.
|
|
6
|
+
*
|
|
7
|
+
* Functions that use `this` are regular function declarations (not arrows)
|
|
8
|
+
* so they work correctly when wired onto SweetSearch.prototype.
|
|
9
|
+
*
|
|
10
|
+
* Rescoring Fix changes:
|
|
11
|
+
* Phase 0: Enhanced per-stage instrumentation, score-distribution signals
|
|
12
|
+
* Phase 1: Batched normalized-dot Stage 2 scoring (no per-candidate norms)
|
|
13
|
+
* Phase 2: Fixed Stage 2.5 dimension mismatch, float store direct access
|
|
14
|
+
* Phase 3: Adaptive oversampling (replaces fixed 200/200 pools)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
getBinaryEmbedding,
|
|
19
|
+
getEmbedding,
|
|
20
|
+
truncateForHNSW,
|
|
21
|
+
floatToInt8,
|
|
22
|
+
normalizedFloatToInt8,
|
|
23
|
+
int8CosineSimilarity,
|
|
24
|
+
int8BatchDotScores,
|
|
25
|
+
} from '../embedding/embedding-service.js';
|
|
26
|
+
import { EMBEDDING_CONFIG, BINARY_HNSW_CONFIG } from '../infrastructure/config/index.js';
|
|
27
|
+
|
|
28
|
+
const CASCADE_DEFERRED_STATS = { skipped: true, reason: 'cascade_deferred', provider: null, documents: 0, tokens: 0 };
|
|
29
|
+
|
|
30
|
+
function cascadeDefer(candidates, stats, searchPath, k = 50) {
|
|
31
|
+
const results = candidates.slice(0, k).map(r => ({ ...r, searchPath }));
|
|
32
|
+
stats.rerank = CASCADE_DEFERRED_STATS;
|
|
33
|
+
return { results, stats };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Phase 0: Score-Spread Analysis (shared signal source)
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
// Thresholds shared between shouldSkipRerank and adaptive pool sizing.
|
|
41
|
+
// Single source of truth — no parallel heuristic stacks.
|
|
42
|
+
const SCORE_SPREAD = {
|
|
43
|
+
topGapThreshold: 0.10, // Gap above this = clear winner
|
|
44
|
+
spreadThreshold: 0.08, // Spread below this = tight cluster / ambiguous
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Analyze score-spread signals from a set of scores. O(n) single pass —
|
|
49
|
+
* no sort needed since we only need top-1, top-2, min, mean, and variance.
|
|
50
|
+
*
|
|
51
|
+
* Used by both shouldSkipRerank (to skip CE) and adaptive pool sizing
|
|
52
|
+
* (to shrink/widen candidate pools). Single computation, reused everywhere.
|
|
53
|
+
*/
|
|
54
|
+
function analyzeScoreSpread(scores) {
|
|
55
|
+
if (!scores || scores.length < 2) return null;
|
|
56
|
+
const n = scores.length;
|
|
57
|
+
|
|
58
|
+
// Single O(n) pass: top1, top2, min, sum, sumSq
|
|
59
|
+
let top1 = -Infinity, top2 = -Infinity, min = Infinity, sum = 0;
|
|
60
|
+
for (let i = 0; i < n; i++) {
|
|
61
|
+
const s = scores[i];
|
|
62
|
+
sum += s;
|
|
63
|
+
if (s > top1) { top2 = top1; top1 = s; }
|
|
64
|
+
else if (s > top2) { top2 = s; }
|
|
65
|
+
if (s < min) min = s;
|
|
66
|
+
}
|
|
67
|
+
const mean = sum / n;
|
|
68
|
+
let variance = 0;
|
|
69
|
+
for (let i = 0; i < n; i++) variance += (scores[i] - mean) ** 2;
|
|
70
|
+
variance /= n;
|
|
71
|
+
|
|
72
|
+
const topGap = top1 - top2;
|
|
73
|
+
const spread = top1 - min;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
top1, top2, topGap, spread, mean,
|
|
77
|
+
stdDev: Math.sqrt(variance),
|
|
78
|
+
count: n,
|
|
79
|
+
isDecisive: topGap > SCORE_SPREAD.topGapThreshold,
|
|
80
|
+
isAmbiguous: spread < SCORE_SPREAD.spreadThreshold,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Phase 3: Adaptive Pool Sizing
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute adaptive Stage 2 pool size based on k and pre-computed score signals.
|
|
90
|
+
* Uses the same isDecisive/isAmbiguous signals as shouldSkipRerank.
|
|
91
|
+
*/
|
|
92
|
+
function adaptiveStage2Pool(k, analysis, config) {
|
|
93
|
+
const {
|
|
94
|
+
minStage2 = 40,
|
|
95
|
+
maxStage2 = 400,
|
|
96
|
+
oversample1 = 10,
|
|
97
|
+
} = config;
|
|
98
|
+
|
|
99
|
+
let base = Math.max(minStage2, k * oversample1);
|
|
100
|
+
|
|
101
|
+
if (analysis) {
|
|
102
|
+
if (analysis.isDecisive) {
|
|
103
|
+
base = Math.max(minStage2, Math.floor(base * 0.6));
|
|
104
|
+
return { size: Math.min(base, maxStage2), reason: `shrink_decisive (gap=${analysis.topGap.toFixed(3)})` };
|
|
105
|
+
}
|
|
106
|
+
if (analysis.isAmbiguous) {
|
|
107
|
+
base = Math.min(maxStage2, Math.floor(base * 1.5));
|
|
108
|
+
return { size: base, reason: `widen_ambiguous (spread=${analysis.spread.toFixed(3)})` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { size: Math.min(base, maxStage2), reason: 'default' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute adaptive Stage 2.5 pool size.
|
|
117
|
+
* Always smaller than Stage 2 pool — float rescoring is more expensive.
|
|
118
|
+
*/
|
|
119
|
+
function adaptiveStage2_5Pool(k, analysis, config) {
|
|
120
|
+
const {
|
|
121
|
+
minStage2_5 = 20,
|
|
122
|
+
maxStage2_5 = 200,
|
|
123
|
+
oversample2 = 5,
|
|
124
|
+
} = config;
|
|
125
|
+
|
|
126
|
+
let base = Math.max(minStage2_5, k * oversample2);
|
|
127
|
+
|
|
128
|
+
if (analysis && analysis.isDecisive) {
|
|
129
|
+
base = Math.max(minStage2_5, Math.floor(base * 0.6));
|
|
130
|
+
return { size: Math.min(base, maxStage2_5), reason: `shrink_decisive (gap=${analysis.topGap.toFixed(3)})` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { size: Math.min(base, maxStage2_5), reason: 'default' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// 3-Stage Semantic Search
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 3-Stage Semantic Search Pipeline
|
|
142
|
+
*
|
|
143
|
+
* Performance targets (after rescoring fix):
|
|
144
|
+
* Stage 1 (Binary): ~100us for 1000 candidates
|
|
145
|
+
* Stage 2 (Int8): ~200-500us for adaptive candidates (batched normalized dot)
|
|
146
|
+
* Stage 2.5 (Float): ~500us for adaptive candidates (direct-access store)
|
|
147
|
+
* Stage 3 (Rerank): ~50-100ms for 20 candidates
|
|
148
|
+
* Total: <150ms end-to-end
|
|
149
|
+
*
|
|
150
|
+
* Returns: { results: Array, stats: Object } with embedding/rerank stats for CostTracker
|
|
151
|
+
*
|
|
152
|
+
* Uses `this` extensively.
|
|
153
|
+
*/
|
|
154
|
+
export async function semanticSearch3Stage(query, options = {}) {
|
|
155
|
+
const { k = 10, rerank = true, useLateInteraction = this.useLateInteraction } = options;
|
|
156
|
+
const stats = { stages: {} };
|
|
157
|
+
const adaptiveConfig = BINARY_HNSW_CONFIG.retrieval.adaptive || {};
|
|
158
|
+
|
|
159
|
+
// Generate binary embedding (with caching)
|
|
160
|
+
const embedStart = performance.now();
|
|
161
|
+
const embedResult = await getBinaryEmbedding(query);
|
|
162
|
+
stats.embed_us = Math.round((performance.now() - embedStart) * 1000);
|
|
163
|
+
this.log(`Embedding: ${stats.embed_us}us (${embedResult.source})`);
|
|
164
|
+
|
|
165
|
+
// P0 FIX: Add embedding stats for CostTracker
|
|
166
|
+
stats.embedding = {
|
|
167
|
+
source: embedResult.source || (embedResult.cached ? 'cache' : 'api'),
|
|
168
|
+
tokens: embedResult.tokens || Math.ceil(query.length / 4),
|
|
169
|
+
provider: EMBEDDING_CONFIG.provider,
|
|
170
|
+
cached: embedResult.cached || embedResult.source === 'vocabulary' || embedResult.source === 'lru' || false,
|
|
171
|
+
latency_us: stats.embed_us,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Stage 1: Binary HNSW search
|
|
175
|
+
// Pass floatQuery for asymmetric distance during graph traversal
|
|
176
|
+
const stage1Start = performance.now();
|
|
177
|
+
const truncatedFloat = truncateForHNSW(embedResult.float);
|
|
178
|
+
const stage1Result = await this.binaryHnswIndex.search(
|
|
179
|
+
embedResult.binary, this.stage1Candidates, { floatQuery: truncatedFloat }
|
|
180
|
+
);
|
|
181
|
+
const stage1Scores = stage1Result.results.map(r => r.score);
|
|
182
|
+
const stage1Analysis = analyzeScoreSpread(stage1Scores);
|
|
183
|
+
stats.stages.binary = {
|
|
184
|
+
latency_us: stage1Result.latency_us,
|
|
185
|
+
candidates: stage1Result.results.length,
|
|
186
|
+
scoreDistribution: stage1Analysis,
|
|
187
|
+
};
|
|
188
|
+
this.log(`Stage 1 (Binary): ${stage1Result.latency_us}us, ${stage1Result.results.length} candidates`);
|
|
189
|
+
|
|
190
|
+
if (stage1Result.results.length === 0) {
|
|
191
|
+
stats.rerank = {
|
|
192
|
+
skipped: true,
|
|
193
|
+
reason: 'no_candidates',
|
|
194
|
+
provider: null,
|
|
195
|
+
documents: 0,
|
|
196
|
+
tokens: 0,
|
|
197
|
+
};
|
|
198
|
+
return { results: [], stats };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
// Stage 2: Int8 Rescore (Phase 1 — batched normalized dot product)
|
|
203
|
+
//
|
|
204
|
+
// Key optimization: use normalizedFloatToInt8 (matches index-time quantizer)
|
|
205
|
+
// and raw dot product scoring. Since both query and document int8 vectors
|
|
206
|
+
// are quantized from L2-normalized floats, dot/(127²) ≈ cosine.
|
|
207
|
+
// No per-candidate norm computation needed.
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
const stage2Start = performance.now();
|
|
210
|
+
const useBatchedDot = BINARY_HNSW_CONFIG.retrieval.useBatchedDot !== false;
|
|
211
|
+
|
|
212
|
+
// Phase 3: Adaptive Stage 2 pool size (uses shared score-spread analysis)
|
|
213
|
+
const stage2Pool = adaptiveStage2Pool(k, stage1Analysis, adaptiveConfig);
|
|
214
|
+
const stage2Count = Math.min(stage2Pool.size, stage1Result.results.length);
|
|
215
|
+
|
|
216
|
+
// Collect int8 vectors for scoring
|
|
217
|
+
const stage2Candidates = stage1Result.results.slice(0, stage2Count);
|
|
218
|
+
const int8Vectors = [];
|
|
219
|
+
const validIndices = [];
|
|
220
|
+
let missingInt8Count = 0;
|
|
221
|
+
|
|
222
|
+
for (let i = 0; i < stage2Candidates.length; i++) {
|
|
223
|
+
const int8Vector = this.binaryHnswIndex.getInt8Vector(stage2Candidates[i].id);
|
|
224
|
+
if (int8Vector) {
|
|
225
|
+
int8Vectors.push(int8Vector);
|
|
226
|
+
validIndices.push(i);
|
|
227
|
+
} else {
|
|
228
|
+
stage2Candidates[i].int8Score = 0.0;
|
|
229
|
+
stage2Candidates[i].missingInt8 = true;
|
|
230
|
+
missingInt8Count++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (useBatchedDot) {
|
|
235
|
+
// Phase 1 NEW PATH: Batched normalized dot product
|
|
236
|
+
// Use same quantizer as index time. No per-candidate norms.
|
|
237
|
+
const queryInt8 = normalizedFloatToInt8(truncatedFloat);
|
|
238
|
+
stats.queryInt8 = queryInt8;
|
|
239
|
+
if (int8Vectors.length > 0) {
|
|
240
|
+
const batchScores = int8BatchDotScores(queryInt8, int8Vectors);
|
|
241
|
+
for (let j = 0; j < validIndices.length; j++) {
|
|
242
|
+
stage2Candidates[validIndices[j]].int8Score = batchScores[j];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
// Phase 1 OLD PATH (fallback): Per-candidate int8 cosine similarity
|
|
247
|
+
const queryInt8 = floatToInt8(truncatedFloat);
|
|
248
|
+
stats.queryInt8 = queryInt8;
|
|
249
|
+
for (let j = 0; j < validIndices.length; j++) {
|
|
250
|
+
stage2Candidates[validIndices[j]].int8Score = int8CosineSimilarity(queryInt8, int8Vectors[j]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (missingInt8Count > 0) {
|
|
255
|
+
this.log(`Warning: ${missingInt8Count} candidates missing int8 vectors (given neutral score)`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sort by int8 score
|
|
259
|
+
let scoredCandidates = [...stage2Candidates];
|
|
260
|
+
scoredCandidates.sort((a, b) => b.int8Score - a.int8Score);
|
|
261
|
+
|
|
262
|
+
const int8Scores = scoredCandidates.filter(c => !c.missingInt8).map(c => c.int8Score);
|
|
263
|
+
const int8Analysis = analyzeScoreSpread(int8Scores);
|
|
264
|
+
stats.stages.int8 = {
|
|
265
|
+
latency_us: Math.round((performance.now() - stage2Start) * 1000),
|
|
266
|
+
candidates: scoredCandidates.length,
|
|
267
|
+
missingVectors: missingInt8Count,
|
|
268
|
+
poolSize: stage2Count,
|
|
269
|
+
poolReason: stage2Pool.reason,
|
|
270
|
+
scoringPath: useBatchedDot ? 'batched-dot' : 'per-candidate-cosine',
|
|
271
|
+
scoreDistribution: int8Analysis,
|
|
272
|
+
};
|
|
273
|
+
this.log(`Stage 2 (Int8): ${stats.stages.int8.latency_us}us, ${scoredCandidates.length} rescored (pool: ${stage2Count}, ${stage2Pool.reason}, ${useBatchedDot ? 'batched' : 'legacy'})`);
|
|
274
|
+
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
// Stage 2.5: Float Rescore (Phase 2 — fixed dimension, direct-access store)
|
|
277
|
+
//
|
|
278
|
+
// Fixes from the plan:
|
|
279
|
+
// 1. Query and document vectors scored at same intended dimension
|
|
280
|
+
// (both use truncateForHNSW output, no silent Math.min truncation)
|
|
281
|
+
// 2. Float vectors loaded from direct-access store (not SQLite)
|
|
282
|
+
// 3. SQLite retained as fallback if float store not available
|
|
283
|
+
// -------------------------------------------------------------------------
|
|
284
|
+
const stage2_5Pool = adaptiveStage2_5Pool(k, int8Analysis, adaptiveConfig);
|
|
285
|
+
const stage2_5Count = Math.min(stage2_5Pool.size, scoredCandidates.length);
|
|
286
|
+
|
|
287
|
+
if (stage2_5Count > 0 && embedResult.float) {
|
|
288
|
+
const stage2_5Start = performance.now();
|
|
289
|
+
try {
|
|
290
|
+
const pool = scoredCandidates.slice(0, stage2_5Count);
|
|
291
|
+
// Phase 2 fix: query at intended dimension (truncated + normalized)
|
|
292
|
+
const queryFloat = truncatedFloat;
|
|
293
|
+
const poolIds = pool.map(c => c.id);
|
|
294
|
+
|
|
295
|
+
let floatVectors = null;
|
|
296
|
+
let floatSource = 'none';
|
|
297
|
+
let missingFloatCount = 0;
|
|
298
|
+
|
|
299
|
+
// Prefer direct-access float store (Phase 2)
|
|
300
|
+
if (this.floatVectorStore && this.floatVectorStore.loaded) {
|
|
301
|
+
const result = this.floatVectorStore.batchScore(queryFloat, poolIds);
|
|
302
|
+
if (result.scores.size > 0) {
|
|
303
|
+
for (const c of pool) {
|
|
304
|
+
const score = result.scores.get(c.id);
|
|
305
|
+
if (score !== undefined) {
|
|
306
|
+
c.floatScore = score;
|
|
307
|
+
} else {
|
|
308
|
+
c.floatScore = c.int8Score; // fallback
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// result.missing is the authoritative count (IDs not in store)
|
|
312
|
+
missingFloatCount = result.missing;
|
|
313
|
+
floatSource = 'float-store';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Fallback: SQLite _loadFloatVectors (if float store unavailable)
|
|
318
|
+
if (floatSource === 'none' && this._loadFloatVectors) {
|
|
319
|
+
floatVectors = await this._loadFloatVectors(poolIds);
|
|
320
|
+
if (floatVectors && floatVectors.size > 0) {
|
|
321
|
+
for (const c of pool) {
|
|
322
|
+
const fv = floatVectors.get(c.id);
|
|
323
|
+
if (fv) {
|
|
324
|
+
// Phase 2 fix: dimension mismatch is a correctness bug, not a
|
|
325
|
+
// recoverable condition. Fail loud so it gets fixed at index time.
|
|
326
|
+
if (fv.length !== queryFloat.length) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Stage 2.5 dimension mismatch: query=${queryFloat.length}, doc=${fv.length} (id=${c.id}). ` +
|
|
329
|
+
'Re-index to align stored vectors with current hnswDimension.'
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
let dot = 0;
|
|
333
|
+
for (let i = 0; i < queryFloat.length; i++) dot += queryFloat[i] * fv[i];
|
|
334
|
+
c.floatScore = dot;
|
|
335
|
+
} else {
|
|
336
|
+
c.floatScore = c.int8Score; // fallback
|
|
337
|
+
missingFloatCount++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
floatSource = 'sqlite-fallback';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (floatSource !== 'none') {
|
|
345
|
+
pool.sort((a, b) => b.floatScore - a.floatScore);
|
|
346
|
+
scoredCandidates = pool;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const floatScores = pool.filter(c => c.floatScore !== undefined).map(c => c.floatScore);
|
|
350
|
+
stats.stages.floatRescore = {
|
|
351
|
+
latency_us: Math.round((performance.now() - stage2_5Start) * 1000),
|
|
352
|
+
candidates: pool.length,
|
|
353
|
+
poolSize: stage2_5Count,
|
|
354
|
+
poolReason: stage2_5Pool.reason,
|
|
355
|
+
source: floatSource,
|
|
356
|
+
missingVectors: missingFloatCount,
|
|
357
|
+
scoreDistribution: analyzeScoreSpread(floatScores),
|
|
358
|
+
};
|
|
359
|
+
this.log(`Stage 2.5 (Float): ${stats.stages.floatRescore.latency_us}us, ${pool.length} rescored (${floatSource}, pool: ${stage2_5Count})`);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
// Dimension mismatches are correctness bugs — propagate, don't swallow.
|
|
362
|
+
if (err.message.includes('dimension mismatch')) throw err;
|
|
363
|
+
this.log(`Stage 2.5 skipped: ${err.message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// CASCADE MODE: Return broad candidate set, let postprocess handle scoring.
|
|
368
|
+
if (this.cascadeEnabled) {
|
|
369
|
+
return cascadeDefer(scoredCandidates, stats, 'semantic-3stage', options.cascadeK);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// =========================================================================
|
|
373
|
+
// FLAG OFF: Existing Stage 3 rerank path, completely unchanged.
|
|
374
|
+
// =========================================================================
|
|
375
|
+
|
|
376
|
+
// EARLY EXIT: Use score spread analysis to skip reranking
|
|
377
|
+
const topCandidatesWithInt8 = scoredCandidates
|
|
378
|
+
.slice(0, Math.min(10, scoredCandidates.length))
|
|
379
|
+
.filter(c => !c.missingInt8);
|
|
380
|
+
const topInt8Scores = topCandidatesWithInt8.map(c => c.floatScore ?? c.int8Score);
|
|
381
|
+
const skipAnalysis = this.shouldSkipRerank(topInt8Scores, { highConfidence: 0.90 });
|
|
382
|
+
|
|
383
|
+
if (skipAnalysis.skip) {
|
|
384
|
+
this.log(`Early exit: ${skipAnalysis.reason} (scores: ${topInt8Scores.slice(0, 3).map(s => s.toFixed(3)).join(', ')})`);
|
|
385
|
+
|
|
386
|
+
stats.rerank = {
|
|
387
|
+
skipped: true,
|
|
388
|
+
reason: skipAnalysis.reason,
|
|
389
|
+
provider: null,
|
|
390
|
+
documents: 0,
|
|
391
|
+
tokens: 0,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const results = scoredCandidates.slice(0, k).map(r => ({
|
|
395
|
+
...r,
|
|
396
|
+
searchPath: 'semantic-3stage',
|
|
397
|
+
earlyExit: true,
|
|
398
|
+
skipReason: skipAnalysis.reason,
|
|
399
|
+
}));
|
|
400
|
+
|
|
401
|
+
return { results, stats };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Late interaction moved to post-expansion pipeline (Phase 6).
|
|
405
|
+
// See search-postprocess.js — runs after graph expansion so expanded
|
|
406
|
+
// candidates also benefit from MaxSim scoring.
|
|
407
|
+
|
|
408
|
+
// Stage 3: Rerank (if enabled AND a reranker is actually available)
|
|
409
|
+
let results = scoredCandidates;
|
|
410
|
+
if (rerank && scoredCandidates.length > k && this.reranker.isAnyAvailable?.()) {
|
|
411
|
+
try {
|
|
412
|
+
const stage3Start = performance.now();
|
|
413
|
+
const topCandidates = scoredCandidates.slice(0, this.stage3Candidates);
|
|
414
|
+
|
|
415
|
+
// Load full document content for reranking
|
|
416
|
+
const documents = await this.loadDocumentContent(topCandidates);
|
|
417
|
+
|
|
418
|
+
const rerankResult = await this.reranker.rerank(query, documents, k);
|
|
419
|
+
results = rerankResult.results.map((r, i) => ({
|
|
420
|
+
...topCandidates[r.originalIndex],
|
|
421
|
+
rerankScore: r.localRerankerScore || r.jinaScore || r.voyageScore || r.flashRankScore,
|
|
422
|
+
originalScore: topCandidates[r.originalIndex].int8Score,
|
|
423
|
+
binaryScore: topCandidates[r.originalIndex].score,
|
|
424
|
+
lateInteractionScore: topCandidates[r.originalIndex].lateInteractionScore,
|
|
425
|
+
preLateInteractionScore: topCandidates[r.originalIndex].preLateInteractionScore,
|
|
426
|
+
newRank: i + 1,
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
stats.stages.rerank = {
|
|
430
|
+
latency_ms: rerankResult.latency_ms,
|
|
431
|
+
model: rerankResult.model,
|
|
432
|
+
candidates: topCandidates.length,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
stats.rerank = {
|
|
436
|
+
skipped: false,
|
|
437
|
+
provider: rerankResult.model || 'direct-cross-encoder',
|
|
438
|
+
documents: topCandidates.length,
|
|
439
|
+
tokens: Math.ceil(query.length / 4) + (topCandidates.length * 150),
|
|
440
|
+
latency_ms: rerankResult.latency_ms,
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
this.log(`Stage 3 (Rerank): ${rerankResult.latency_ms}ms (${rerankResult.model})`);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
this.log(`Rerank failed: ${err.message}`);
|
|
446
|
+
results = scoredCandidates.slice(0, k);
|
|
447
|
+
|
|
448
|
+
stats.rerank = {
|
|
449
|
+
skipped: true,
|
|
450
|
+
reason: `error: ${err.message}`,
|
|
451
|
+
provider: null,
|
|
452
|
+
documents: 0,
|
|
453
|
+
tokens: 0,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
results = scoredCandidates.slice(0, k);
|
|
458
|
+
|
|
459
|
+
stats.rerank = {
|
|
460
|
+
skipped: true,
|
|
461
|
+
reason: rerank ? 'insufficient_candidates' : 'disabled',
|
|
462
|
+
provider: null,
|
|
463
|
+
documents: 0,
|
|
464
|
+
tokens: 0,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const formattedResults = results.map(r => ({
|
|
469
|
+
...r,
|
|
470
|
+
searchPath: 'semantic-3stage',
|
|
471
|
+
}));
|
|
472
|
+
|
|
473
|
+
// Return both results and stats for proper propagation
|
|
474
|
+
return { results: formattedResults, stats };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// =============================================================================
|
|
478
|
+
// Standard Semantic Search (fallback)
|
|
479
|
+
// =============================================================================
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Standard Semantic Search (fallback when binary index not available)
|
|
483
|
+
*
|
|
484
|
+
* Returns: { results: Array, stats: Object } with embedding/rerank stats for CostTracker
|
|
485
|
+
*
|
|
486
|
+
* Uses `this` extensively.
|
|
487
|
+
*/
|
|
488
|
+
export async function semanticSearchStandard(query, options = {}) {
|
|
489
|
+
const { k = 10, rerank = true } = options;
|
|
490
|
+
const stats = { stages: {} };
|
|
491
|
+
|
|
492
|
+
// Generate query embedding (with caching)
|
|
493
|
+
const embedStart = performance.now();
|
|
494
|
+
const embedResult = await getEmbedding(query, { isQuery: true });
|
|
495
|
+
const fullEmbedding = embedResult.embedding || embedResult; // Handle both new and old API
|
|
496
|
+
const embedLatency_us = embedResult.latency_us || Math.round((performance.now() - embedStart) * 1000);
|
|
497
|
+
const cacheStatus = embedResult.source || 'unknown';
|
|
498
|
+
this.log(`Embedding: ${embedLatency_us}us (${cacheStatus})`);
|
|
499
|
+
|
|
500
|
+
// P0 FIX: Add embedding stats for CostTracker
|
|
501
|
+
stats.embedding = {
|
|
502
|
+
source: embedResult.source || (embedResult.cached ? 'cache' : 'api'),
|
|
503
|
+
tokens: embedResult.tokens || Math.ceil(query.length / 4),
|
|
504
|
+
provider: EMBEDDING_CONFIG.provider,
|
|
505
|
+
cached: embedResult.cached || embedResult.source === 'vocabulary' || embedResult.source === 'lru' || false,
|
|
506
|
+
latency_us: embedLatency_us,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Truncate to HNSW dimension (1024d -> 512d Matryoshka)
|
|
510
|
+
const queryEmbedding = truncateForHNSW(fullEmbedding);
|
|
511
|
+
stats.queryInt8 = normalizedFloatToInt8(queryEmbedding);
|
|
512
|
+
|
|
513
|
+
let candidates;
|
|
514
|
+
|
|
515
|
+
if (this.hasHnswIndex) {
|
|
516
|
+
// ADAPTIVE CANDIDATE SIZING: Reduce candidates for simple queries
|
|
517
|
+
const baseNumCandidates = rerank ? Math.max(k * 10, 100) : k;
|
|
518
|
+
const numCandidates = this.getAdaptiveCandidateCount(query, baseNumCandidates);
|
|
519
|
+
|
|
520
|
+
const hnswResult = await this.hnswIndex.search(queryEmbedding, numCandidates);
|
|
521
|
+
candidates = hnswResult.results;
|
|
522
|
+
this.log(`HNSW: ${hnswResult.latency_us}us for ${hnswResult.k} candidates (adaptive: ${numCandidates})`);
|
|
523
|
+
} else if (this.hasCodebaseIndex) {
|
|
524
|
+
// Fallback: O(N) scan from SQLite
|
|
525
|
+
candidates = await this.vectorScan(queryEmbedding, rerank ? 100 : k);
|
|
526
|
+
this.log(`Vector scan: ${candidates.length} candidates`);
|
|
527
|
+
} else {
|
|
528
|
+
stats.rerank = {
|
|
529
|
+
skipped: true,
|
|
530
|
+
reason: 'no_index_available',
|
|
531
|
+
provider: null,
|
|
532
|
+
documents: 0,
|
|
533
|
+
tokens: 0,
|
|
534
|
+
};
|
|
535
|
+
return { results: [], stats };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (candidates.length === 0) {
|
|
539
|
+
stats.rerank = {
|
|
540
|
+
skipped: true,
|
|
541
|
+
reason: 'no_candidates',
|
|
542
|
+
provider: null,
|
|
543
|
+
documents: 0,
|
|
544
|
+
tokens: 0,
|
|
545
|
+
};
|
|
546
|
+
return { results: [], stats };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// CASCADE MODE: Return broad candidate set, let postprocess handle scoring.
|
|
550
|
+
if (this.cascadeEnabled) {
|
|
551
|
+
return cascadeDefer(candidates, stats, 'semantic');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// =========================================================================
|
|
555
|
+
// FLAG OFF: Existing rerank path, completely unchanged.
|
|
556
|
+
// =========================================================================
|
|
557
|
+
|
|
558
|
+
// EARLY EXIT: Use score spread analysis to skip reranking
|
|
559
|
+
const topScores = candidates.slice(0, Math.min(10, candidates.length)).map(c => c.score);
|
|
560
|
+
const skipAnalysis = this.shouldSkipRerank(topScores, { highConfidence: 0.92 });
|
|
561
|
+
|
|
562
|
+
if (skipAnalysis.skip) {
|
|
563
|
+
this.log(`Early exit: ${skipAnalysis.reason} (scores: ${topScores.slice(0, 3).map(s => s.toFixed(3)).join(', ')})`);
|
|
564
|
+
|
|
565
|
+
stats.rerank = {
|
|
566
|
+
skipped: true,
|
|
567
|
+
reason: skipAnalysis.reason,
|
|
568
|
+
provider: null,
|
|
569
|
+
documents: 0,
|
|
570
|
+
tokens: 0,
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const results = candidates.slice(0, k).map(r => ({
|
|
574
|
+
...r,
|
|
575
|
+
searchPath: 'semantic',
|
|
576
|
+
earlyExit: true,
|
|
577
|
+
skipReason: skipAnalysis.reason,
|
|
578
|
+
}));
|
|
579
|
+
|
|
580
|
+
return { results, stats };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Rerank if requested and we have candidates
|
|
584
|
+
let results = candidates;
|
|
585
|
+
if (rerank && candidates.length > k && this.reranker.isAnyAvailable?.()) {
|
|
586
|
+
try {
|
|
587
|
+
const rerankStart = Date.now();
|
|
588
|
+
|
|
589
|
+
// Prepare documents for reranking
|
|
590
|
+
const documents = await this.loadDocumentContent(candidates);
|
|
591
|
+
|
|
592
|
+
const rerankResult = await this.reranker.rerank(query, documents, k);
|
|
593
|
+
results = rerankResult.results.map((r, i) => ({
|
|
594
|
+
...candidates[r.originalIndex],
|
|
595
|
+
rerankScore: r.localRerankerScore || r.jinaScore || r.voyageScore || r.flashRankScore,
|
|
596
|
+
originalScore: candidates[r.originalIndex].score,
|
|
597
|
+
newRank: i + 1,
|
|
598
|
+
}));
|
|
599
|
+
|
|
600
|
+
stats.rerank = {
|
|
601
|
+
skipped: false,
|
|
602
|
+
provider: rerankResult.model || 'direct-cross-encoder',
|
|
603
|
+
documents: candidates.length,
|
|
604
|
+
tokens: Math.ceil(query.length / 4) + (candidates.length * 150),
|
|
605
|
+
latency_ms: rerankResult.latency_ms,
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
this.log(`Rerank: ${rerankResult.latency_ms}ms (${rerankResult.model})`);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
this.log(`Rerank failed: ${err.message}`);
|
|
611
|
+
results = candidates.slice(0, k);
|
|
612
|
+
|
|
613
|
+
stats.rerank = {
|
|
614
|
+
skipped: true,
|
|
615
|
+
reason: `error: ${err.message}`,
|
|
616
|
+
provider: null,
|
|
617
|
+
documents: 0,
|
|
618
|
+
tokens: 0,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
stats.rerank = {
|
|
623
|
+
skipped: true,
|
|
624
|
+
reason: rerank ? 'insufficient_candidates' : 'disabled',
|
|
625
|
+
provider: null,
|
|
626
|
+
documents: 0,
|
|
627
|
+
tokens: 0,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const formattedResults = results.map(r => ({
|
|
632
|
+
...r,
|
|
633
|
+
searchPath: 'semantic',
|
|
634
|
+
}));
|
|
635
|
+
|
|
636
|
+
return { results: formattedResults, stats };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// =============================================================================
|
|
640
|
+
// Helpers
|
|
641
|
+
// =============================================================================
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Score spread analysis for intelligent rerank skipping.
|
|
645
|
+
* Pure function — does not reference `this`. On prototype for call-site convenience.
|
|
646
|
+
*/
|
|
647
|
+
export function shouldSkipRerank(scores, options = {}) {
|
|
648
|
+
const {
|
|
649
|
+
topGapThreshold = SCORE_SPREAD.topGapThreshold,
|
|
650
|
+
spreadThreshold = SCORE_SPREAD.spreadThreshold,
|
|
651
|
+
highConfidence = 0.85,
|
|
652
|
+
minResults = 3,
|
|
653
|
+
minScoreThreshold = 0.50,
|
|
654
|
+
} = options;
|
|
655
|
+
|
|
656
|
+
if (!scores || scores.length < minResults) {
|
|
657
|
+
return { skip: false, reason: 'insufficient_results' };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const sorted = [...scores].sort((a, b) => b - a);
|
|
661
|
+
const topGap = sorted[0] - sorted[1];
|
|
662
|
+
const spread = sorted[0] - sorted[sorted.length - 1];
|
|
663
|
+
const topScores = sorted.slice(0, Math.min(3, sorted.length));
|
|
664
|
+
|
|
665
|
+
// Check 0: Never skip if scores are too low
|
|
666
|
+
if (sorted[0] < minScoreThreshold) {
|
|
667
|
+
return { skip: false, reason: `low_scores (max=${sorted[0].toFixed(3)}, threshold=${minScoreThreshold})` };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Check 1: Clear winner
|
|
671
|
+
if (topGap > topGapThreshold) {
|
|
672
|
+
return { skip: true, reason: `clear_winner (gap=${topGap.toFixed(3)})` };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Check 2: Tight cluster
|
|
676
|
+
if (spread < spreadThreshold) {
|
|
677
|
+
return { skip: true, reason: `tight_cluster (spread=${spread.toFixed(3)})` };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Check 3: All high confidence matches
|
|
681
|
+
if (topScores.every(s => s > highConfidence)) {
|
|
682
|
+
return { skip: true, reason: `high_confidence (min=${Math.min(...topScores).toFixed(3)})` };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return { skip: false, reason: 'needs_rerank' };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Adaptive candidate count based on query complexity
|
|
690
|
+
*/
|
|
691
|
+
export function getAdaptiveCandidateCount(query, baseCount) {
|
|
692
|
+
const trimmed = query.trim();
|
|
693
|
+
|
|
694
|
+
// Very short queries (likely identifiers): use 50% of base
|
|
695
|
+
if (trimmed.length < 15) {
|
|
696
|
+
return Math.max(Math.floor(baseCount * 0.5), 20);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Short queries without question words: use 75% of base
|
|
700
|
+
if (trimmed.length < 30 && !/\b(how|what|where|why|when|which)\b/i.test(trimmed)) {
|
|
701
|
+
return Math.max(Math.floor(baseCount * 0.75), 30);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Complex queries (questions, long): use full base
|
|
705
|
+
return baseCount;
|
|
706
|
+
}
|