bluera-knowledge 0.9.38 → 0.9.39

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.
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createMCPServer,
3
3
  runMCPServer
4
- } from "../chunk-36IFANFI.js";
5
- import "../chunk-XJFV7AJW.js";
4
+ } from "../chunk-TIGPI3BE.js";
5
+ import "../chunk-HUEWT6U5.js";
6
6
  import "../chunk-6FHWC36B.js";
7
7
  export {
8
8
  createMCPServer,
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  IntelligentCrawler
4
- } from "../chunk-ZAWIPEYX.js";
4
+ } from "../chunk-IZWOEBFM.js";
5
5
  import {
6
6
  JobService,
7
7
  createDocumentId,
8
8
  createServices,
9
9
  createStoreId
10
- } from "../chunk-XJFV7AJW.js";
10
+ } from "../chunk-HUEWT6U5.js";
11
11
  import "../chunk-6FHWC36B.js";
12
12
 
13
13
  // src/workers/background-worker.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.9.38",
3
+ "version": "0.9.39",
4
4
  "description": "CLI tool for managing knowledge stores with semantic search",
5
5
  "type": "module",
6
6
  "bin": {
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.9.38",
3
+ "version": "0.9.39",
4
4
  "description": "Clone repos, crawl docs, search locally. Fast, authoritative answers for AI coding agents.",
5
5
  "commands": "./commands",
6
6
  "hooks": "./hooks/hooks.json",
@@ -18,6 +18,10 @@ export function createSearchCommand(getOptions: () => GlobalOptions): Command {
18
18
  )
19
19
  .option('-n, --limit <count>', 'Maximum results to return (default: 10)', '10')
20
20
  .option('-t, --threshold <score>', 'Minimum score 0-1; omit low-relevance results')
21
+ .option(
22
+ '--min-relevance <score>',
23
+ 'Minimum raw cosine similarity 0-1; returns empty if no results meet threshold'
24
+ )
21
25
  .option('--include-content', 'Show full document content, not just preview snippet')
22
26
  .option(
23
27
  '--detail <level>',
@@ -32,6 +36,7 @@ export function createSearchCommand(getOptions: () => GlobalOptions): Command {
32
36
  mode?: SearchMode;
33
37
  limit?: string;
34
38
  threshold?: string;
39
+ minRelevance?: string;
35
40
  includeContent?: boolean;
36
41
  detail?: DetailLevel;
37
42
  }
@@ -82,6 +87,8 @@ export function createSearchCommand(getOptions: () => GlobalOptions): Command {
82
87
  limit: parseInt(options.limit ?? '10', 10),
83
88
  threshold:
84
89
  options.threshold !== undefined ? parseFloat(options.threshold) : undefined,
90
+ minRelevance:
91
+ options.minRelevance !== undefined ? parseFloat(options.minRelevance) : undefined,
85
92
  includeContent: options.includeContent,
86
93
  detail: options.detail ?? 'minimal',
87
94
  });
@@ -96,12 +103,23 @@ export function createSearchCommand(getOptions: () => GlobalOptions): Command {
96
103
  }
97
104
  } else {
98
105
  console.log(`\nSearch: "${query}"`);
99
- console.log(
100
- `Mode: ${results.mode} | Detail: ${String(options.detail)} | Stores: ${String(results.stores.length)} | Results: ${String(results.totalResults)} | Time: ${String(results.timeMs)}ms\n`
101
- );
106
+
107
+ // Build status line with optional confidence info
108
+ let statusLine = `Mode: ${results.mode} | Detail: ${String(options.detail)} | Stores: ${String(results.stores.length)} | Results: ${String(results.totalResults)} | Time: ${String(results.timeMs)}ms`;
109
+ if (results.confidence !== undefined) {
110
+ statusLine += ` | Confidence: ${results.confidence}`;
111
+ }
112
+ if (results.maxRawScore !== undefined) {
113
+ statusLine += ` | MaxRaw: ${results.maxRawScore.toFixed(3)}`;
114
+ }
115
+ console.log(`${statusLine}\n`);
102
116
 
103
117
  if (results.results.length === 0) {
104
- console.log('No results found.\n');
118
+ if (results.confidence === 'low') {
119
+ console.log('No sufficiently relevant results found.\n');
120
+ } else {
121
+ console.log('No results found.\n');
122
+ }
105
123
  } else {
106
124
  for (let i = 0; i < results.results.length; i++) {
107
125
  const r = results.results[i];
@@ -72,6 +72,7 @@ export const handleSearch: ToolHandler<SearchArgs> = async (
72
72
  mode: 'hybrid',
73
73
  limit: validated.limit,
74
74
  detail: validated.detail,
75
+ minRelevance: validated.minRelevance,
75
76
  };
76
77
 
77
78
  const results = await services.search.search(searchQuery);
@@ -107,6 +108,8 @@ export const handleSearch: ToolHandler<SearchArgs> = async (
107
108
  totalResults: results.totalResults,
108
109
  mode: results.mode,
109
110
  timeMs: results.timeMs,
111
+ confidence: results.confidence,
112
+ maxRawScore: results.maxRawScore,
110
113
  },
111
114
  null,
112
115
  2
@@ -115,8 +118,10 @@ export const handleSearch: ToolHandler<SearchArgs> = async (
115
118
  // Calculate actual token estimate based on response content
116
119
  const responseTokens = estimateTokens(responseJson);
117
120
 
118
- // Create visible header with token usage
119
- const header = `Search: "${validated.query}" | Results: ${String(results.totalResults)} | ${formatTokenCount(responseTokens)} tokens | ${String(results.timeMs)}ms\n\n`;
121
+ // Create visible header with token usage and confidence
122
+ const confidenceInfo =
123
+ results.confidence !== undefined ? ` | Confidence: ${results.confidence}` : '';
124
+ const header = `Search: "${validated.query}" | Results: ${String(results.totalResults)} | ${formatTokenCount(responseTokens)} tokens | ${String(results.timeMs)}ms${confidenceInfo}\n\n`;
120
125
 
121
126
  // Log the complete MCP response that will be sent to Claude Code
122
127
  logger.info(
@@ -28,6 +28,11 @@ export const SearchArgsSchema = z.object({
28
28
  detail: z.enum(['minimal', 'contextual', 'full']).default('minimal'),
29
29
  limit: z.number().int().positive().default(10),
30
30
  stores: z.array(z.string()).optional(),
31
+ minRelevance: z
32
+ .number()
33
+ .min(0, 'minRelevance must be between 0 and 1')
34
+ .max(1, 'minRelevance must be between 0 and 1')
35
+ .optional(),
31
36
  });
32
37
 
33
38
  export type SearchArgs = z.infer<typeof SearchArgsSchema>;
package/src/mcp/server.ts CHANGED
@@ -70,6 +70,11 @@ export function createMCPServer(options: MCPServerOptions): Server {
70
70
  items: { type: 'string' },
71
71
  description: 'Specific store IDs to search (optional)',
72
72
  },
73
+ minRelevance: {
74
+ type: 'number',
75
+ description:
76
+ 'Minimum raw cosine similarity (0-1). Returns empty if no results meet threshold. Use to filter irrelevant results.',
77
+ },
73
78
  },
74
79
  required: ['query'],
75
80
  },
@@ -1161,6 +1161,7 @@ describe('SearchService - Edge Cases', () => {
1161
1161
  });
1162
1162
 
1163
1163
  it('handles threshold parameter for vector search', async () => {
1164
+ // Setup: multiple results with varying scores
1164
1165
  vi.mocked(mockLanceStore.search).mockResolvedValue([
1165
1166
  {
1166
1167
  id: createDocumentId('doc1'),
@@ -1168,22 +1169,41 @@ describe('SearchService - Edge Cases', () => {
1168
1169
  content: 'high score',
1169
1170
  metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
1170
1171
  },
1172
+ {
1173
+ id: createDocumentId('doc2'),
1174
+ score: 0.5,
1175
+ content: 'medium score',
1176
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
1177
+ },
1178
+ {
1179
+ id: createDocumentId('doc3'),
1180
+ score: 0.3,
1181
+ content: 'low score',
1182
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
1183
+ },
1171
1184
  ]);
1172
1185
 
1186
+ // Search with high threshold
1173
1187
  const results = await searchService.search({
1174
1188
  query: 'test',
1175
1189
  stores: [storeId],
1176
1190
  mode: 'vector',
1177
1191
  limit: 10,
1178
- threshold: 0.9,
1192
+ threshold: 0.8,
1179
1193
  });
1180
1194
 
1195
+ // Verify lanceStore.search was called (without checking threshold param - it's unused in LanceStore)
1181
1196
  expect(vi.mocked(mockLanceStore.search)).toHaveBeenCalledWith(
1182
1197
  storeId,
1183
1198
  expect.anything(),
1184
- expect.anything(),
1185
- 0.9
1199
+ expect.anything()
1186
1200
  );
1201
+
1202
+ // Verify threshold filtering works: only high-score result should pass
1203
+ // After normalization, the top result has score 1.0, and threshold 0.8 filters out lower ones
1204
+ // The normalized scores are: doc1=1.0, doc2=0.31, doc3=0.0 (relative to max 0.95)
1205
+ expect(results.results.length).toBe(1);
1206
+ expect(results.results[0]?.id).toBe('doc1');
1187
1207
  });
1188
1208
  });
1189
1209
 
@@ -1992,3 +2012,171 @@ describe('SearchService - Threshold Filtering', () => {
1992
2012
  expect(results.query).toBe('test query');
1993
2013
  });
1994
2014
  });
2015
+
2016
+ describe('SearchService - Raw Score and Confidence', () => {
2017
+ let mockLanceStore: LanceStore;
2018
+ let mockEmbeddingEngine: EmbeddingEngine;
2019
+ let searchService: SearchService;
2020
+ const storeId = createStoreId('test-store');
2021
+
2022
+ beforeEach(() => {
2023
+ mockLanceStore = {
2024
+ search: vi.fn(),
2025
+ fullTextSearch: vi.fn(),
2026
+ } as unknown as LanceStore;
2027
+
2028
+ mockEmbeddingEngine = {
2029
+ embed: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
2030
+ } as unknown as EmbeddingEngine;
2031
+
2032
+ searchService = new SearchService(mockLanceStore, mockEmbeddingEngine);
2033
+ });
2034
+
2035
+ it('exposes rawVectorScore in rankingMetadata for hybrid search', async () => {
2036
+ // Mock results with known raw vector scores
2037
+ vi.mocked(mockLanceStore.search).mockResolvedValue([
2038
+ {
2039
+ id: createDocumentId('doc1'),
2040
+ score: 0.85, // Raw cosine similarity
2041
+ content: 'vector result',
2042
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2043
+ },
2044
+ {
2045
+ id: createDocumentId('doc2'),
2046
+ score: 0.65,
2047
+ content: 'another vector result',
2048
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2049
+ },
2050
+ ]);
2051
+ vi.mocked(mockLanceStore.fullTextSearch).mockResolvedValue([]);
2052
+
2053
+ const results = await searchService.search({
2054
+ query: 'test query',
2055
+ stores: [storeId],
2056
+ mode: 'hybrid',
2057
+ limit: 10,
2058
+ });
2059
+
2060
+ // Verify results have rawVectorScore in rankingMetadata
2061
+ expect(results.results.length).toBeGreaterThan(0);
2062
+ const firstResult = results.results[0];
2063
+ expect(firstResult?.rankingMetadata).toBeDefined();
2064
+ expect(firstResult?.rankingMetadata?.rawVectorScore).toBeDefined();
2065
+ expect(firstResult?.rankingMetadata?.rawVectorScore).toBe(0.85);
2066
+ });
2067
+
2068
+ it('returns confidence level based on maxRawScore', async () => {
2069
+ // Mock results with high raw vector score (>= 0.5)
2070
+ vi.mocked(mockLanceStore.search).mockResolvedValue([
2071
+ {
2072
+ id: createDocumentId('doc1'),
2073
+ score: 0.6, // High confidence threshold
2074
+ content: 'high score result',
2075
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2076
+ },
2077
+ ]);
2078
+ vi.mocked(mockLanceStore.fullTextSearch).mockResolvedValue([]);
2079
+
2080
+ const results = await searchService.search({
2081
+ query: 'test query',
2082
+ stores: [storeId],
2083
+ mode: 'hybrid',
2084
+ limit: 10,
2085
+ });
2086
+
2087
+ expect(results.confidence).toBe('high');
2088
+ expect(results.maxRawScore).toBe(0.6);
2089
+ });
2090
+
2091
+ it('returns medium confidence for scores between 0.3 and 0.5', async () => {
2092
+ vi.mocked(mockLanceStore.search).mockResolvedValue([
2093
+ {
2094
+ id: createDocumentId('doc1'),
2095
+ score: 0.4, // Medium confidence
2096
+ content: 'medium score result',
2097
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2098
+ },
2099
+ ]);
2100
+ vi.mocked(mockLanceStore.fullTextSearch).mockResolvedValue([]);
2101
+
2102
+ const results = await searchService.search({
2103
+ query: 'test query',
2104
+ stores: [storeId],
2105
+ mode: 'hybrid',
2106
+ limit: 10,
2107
+ });
2108
+
2109
+ expect(results.confidence).toBe('medium');
2110
+ expect(results.maxRawScore).toBe(0.4);
2111
+ });
2112
+
2113
+ it('returns low confidence for scores below 0.3', async () => {
2114
+ vi.mocked(mockLanceStore.search).mockResolvedValue([
2115
+ {
2116
+ id: createDocumentId('doc1'),
2117
+ score: 0.2, // Low confidence
2118
+ content: 'low score result',
2119
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2120
+ },
2121
+ ]);
2122
+ vi.mocked(mockLanceStore.fullTextSearch).mockResolvedValue([]);
2123
+
2124
+ const results = await searchService.search({
2125
+ query: 'test query',
2126
+ stores: [storeId],
2127
+ mode: 'hybrid',
2128
+ limit: 10,
2129
+ });
2130
+
2131
+ expect(results.confidence).toBe('low');
2132
+ expect(results.maxRawScore).toBe(0.2);
2133
+ });
2134
+
2135
+ it('filters results with minRelevance based on raw score', async () => {
2136
+ vi.mocked(mockLanceStore.search).mockResolvedValue([
2137
+ {
2138
+ id: createDocumentId('doc1'),
2139
+ score: 0.25, // Below minRelevance threshold
2140
+ content: 'low relevance result',
2141
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2142
+ },
2143
+ ]);
2144
+ vi.mocked(mockLanceStore.fullTextSearch).mockResolvedValue([]);
2145
+
2146
+ const results = await searchService.search({
2147
+ query: 'irrelevant query',
2148
+ stores: [storeId],
2149
+ mode: 'hybrid',
2150
+ limit: 10,
2151
+ minRelevance: 0.4, // Filter out results below this raw score
2152
+ });
2153
+
2154
+ // Should return empty since max raw score (0.25) < minRelevance (0.4)
2155
+ expect(results.results.length).toBe(0);
2156
+ expect(results.confidence).toBe('low');
2157
+ });
2158
+
2159
+ it('returns results when maxRawScore meets minRelevance', async () => {
2160
+ vi.mocked(mockLanceStore.search).mockResolvedValue([
2161
+ {
2162
+ id: createDocumentId('doc1'),
2163
+ score: 0.5, // Above minRelevance threshold
2164
+ content: 'relevant result',
2165
+ metadata: { type: 'file' as const, storeId, indexedAt: new Date() },
2166
+ },
2167
+ ]);
2168
+ vi.mocked(mockLanceStore.fullTextSearch).mockResolvedValue([]);
2169
+
2170
+ const results = await searchService.search({
2171
+ query: 'relevant query',
2172
+ stores: [storeId],
2173
+ mode: 'hybrid',
2174
+ limit: 10,
2175
+ minRelevance: 0.4,
2176
+ });
2177
+
2178
+ // Should return results since max raw score (0.5) >= minRelevance (0.4)
2179
+ expect(results.results.length).toBe(1);
2180
+ expect(results.confidence).toBe('high');
2181
+ });
2182
+ });
@@ -9,6 +9,7 @@ import type {
9
9
  SearchQuery,
10
10
  SearchResponse,
11
11
  SearchResult,
12
+ SearchConfidence,
12
13
  DetailLevel,
13
14
  CodeUnit,
14
15
  } from '../types/search.js';
@@ -246,6 +247,19 @@ export class SearchService {
246
247
  return result;
247
248
  }
248
249
 
250
+ /**
251
+ * Calculate confidence level based on max raw vector similarity score.
252
+ * Configurable via environment variables.
253
+ */
254
+ private calculateConfidence(maxRawScore: number): SearchConfidence {
255
+ const highThreshold = parseFloat(process.env['SEARCH_CONFIDENCE_HIGH'] ?? '0.5');
256
+ const mediumThreshold = parseFloat(process.env['SEARCH_CONFIDENCE_MEDIUM'] ?? '0.3');
257
+
258
+ if (maxRawScore >= highThreshold) return 'high';
259
+ if (maxRawScore >= mediumThreshold) return 'medium';
260
+ return 'low';
261
+ }
262
+
249
263
  async search(query: SearchQuery): Promise<SearchResponse> {
250
264
  const startTime = Date.now();
251
265
  const mode = query.mode ?? 'hybrid';
@@ -264,22 +278,61 @@ export class SearchService {
264
278
  detail,
265
279
  intent: primaryIntent,
266
280
  intents,
281
+ minRelevance: query.minRelevance,
267
282
  },
268
283
  'Search query received'
269
284
  );
270
285
 
271
286
  let allResults: SearchResult[] = [];
287
+ let maxRawScore = 0;
272
288
 
273
289
  // Fetch more results than needed to allow for deduplication
274
290
  const fetchLimit = limit * 3;
275
291
 
276
292
  if (mode === 'vector') {
293
+ // For vector mode, get raw scores first for confidence calculation
294
+ const rawResults = await this.vectorSearchRaw(query.query, stores, fetchLimit);
295
+ maxRawScore = rawResults.length > 0 ? (rawResults[0]?.score ?? 0) : 0;
277
296
  allResults = await this.vectorSearch(query.query, stores, fetchLimit, query.threshold);
278
297
  } else if (mode === 'fts') {
298
+ // FTS mode doesn't have vector similarity, so no confidence calculation
279
299
  allResults = await this.ftsSearch(query.query, stores, fetchLimit);
280
300
  } else {
281
- // Hybrid: combine vector and FTS with RRF
282
- allResults = await this.hybridSearch(query.query, stores, fetchLimit, query.threshold);
301
+ // Hybrid: combine vector and FTS with RRF, get maxRawScore for confidence
302
+ const hybridResult = await this.hybridSearchWithMetadata(
303
+ query.query,
304
+ stores,
305
+ fetchLimit,
306
+ query.threshold
307
+ );
308
+ allResults = hybridResult.results;
309
+ maxRawScore = hybridResult.maxRawScore;
310
+ }
311
+
312
+ // Apply minRelevance filter - if max raw score is below threshold, return empty
313
+ if (query.minRelevance !== undefined && maxRawScore < query.minRelevance) {
314
+ const timeMs = Date.now() - startTime;
315
+ logger.info(
316
+ {
317
+ query: query.query,
318
+ mode,
319
+ maxRawScore,
320
+ minRelevance: query.minRelevance,
321
+ timeMs,
322
+ },
323
+ 'Search filtered by minRelevance - no sufficiently relevant results'
324
+ );
325
+
326
+ return {
327
+ query: query.query,
328
+ mode,
329
+ stores,
330
+ results: [],
331
+ totalResults: 0,
332
+ timeMs,
333
+ confidence: this.calculateConfidence(maxRawScore),
334
+ maxRawScore,
335
+ };
283
336
  }
284
337
 
285
338
  // Deduplicate by source file - keep best chunk per source (considers query relevance)
@@ -302,6 +355,7 @@ export class SearchService {
302
355
  });
303
356
 
304
357
  const timeMs = Date.now() - startTime;
358
+ const confidence = mode !== 'fts' ? this.calculateConfidence(maxRawScore) : undefined;
305
359
 
306
360
  logger.info(
307
361
  {
@@ -310,6 +364,8 @@ export class SearchService {
310
364
  resultCount: enhancedResults.length,
311
365
  dedupedFrom: allResults.length,
312
366
  intents: intents.map((i) => `${i.intent}(${i.confidence.toFixed(2)})`),
367
+ maxRawScore: mode !== 'fts' ? maxRawScore : undefined,
368
+ confidence,
313
369
  timeMs,
314
370
  },
315
371
  'Search complete'
@@ -322,6 +378,8 @@ export class SearchService {
322
378
  results: enhancedResults,
323
379
  totalResults: enhancedResults.length,
324
380
  timeMs,
381
+ confidence,
382
+ maxRawScore: mode !== 'fts' ? maxRawScore : undefined,
325
383
  };
326
384
  }
327
385
 
@@ -412,27 +470,41 @@ export class SearchService {
412
470
  return normalized;
413
471
  }
414
472
 
415
- private async vectorSearch(
473
+ /**
474
+ * Fetch raw vector search results without normalization.
475
+ * Returns results with raw cosine similarity scores [0-1].
476
+ */
477
+ private async vectorSearchRaw(
416
478
  query: string,
417
479
  stores: readonly StoreId[],
418
- limit: number,
419
- threshold?: number
480
+ limit: number
420
481
  ): Promise<SearchResult[]> {
421
482
  const queryVector = await this.embeddingEngine.embed(query);
422
483
  const results: SearchResult[] = [];
423
484
 
424
485
  for (const storeId of stores) {
425
- const hits = await this.lanceStore.search(storeId, queryVector, limit, threshold);
486
+ const hits = await this.lanceStore.search(storeId, queryVector, limit);
426
487
  results.push(
427
488
  ...hits.map((r) => ({
428
489
  id: r.id,
429
- score: r.score,
490
+ score: r.score, // Raw cosine similarity (1 - distance)
430
491
  content: r.content,
431
492
  metadata: r.metadata,
432
493
  }))
433
494
  );
434
495
  }
435
496
 
497
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
498
+ }
499
+
500
+ private async vectorSearch(
501
+ query: string,
502
+ stores: readonly StoreId[],
503
+ limit: number,
504
+ threshold?: number
505
+ ): Promise<SearchResult[]> {
506
+ const results = await this.vectorSearchRaw(query, stores, limit);
507
+
436
508
  // Normalize scores and apply threshold filter
437
509
  const normalized = this.normalizeAndFilterScores(results, threshold);
438
510
  return normalized.slice(0, limit);
@@ -460,20 +532,37 @@ export class SearchService {
460
532
  return results.sort((a, b) => b.score - a.score).slice(0, limit);
461
533
  }
462
534
 
463
- private async hybridSearch(
535
+ /**
536
+ * Internal hybrid search result with additional metadata for confidence calculation.
537
+ */
538
+ private async hybridSearchWithMetadata(
464
539
  query: string,
465
540
  stores: readonly StoreId[],
466
541
  limit: number,
467
542
  threshold?: number
468
- ): Promise<SearchResult[]> {
543
+ ): Promise<{ results: SearchResult[]; maxRawScore: number }> {
469
544
  // Classify query intents for context-aware ranking (supports multiple intents)
470
545
  const intents = classifyQueryIntents(query);
471
546
 
472
- // Get both result sets (don't pass threshold - apply after RRF normalization)
473
- const [vectorResults, ftsResults] = await Promise.all([
474
- this.vectorSearch(query, stores, limit * 2),
475
- this.ftsSearch(query, stores, limit * 2),
476
- ]);
547
+ // Get raw vector results (unnormalized) to track raw cosine similarity
548
+ // We use these for both raw score tracking and as the basis for normalized vector results
549
+ const rawVectorResults = await this.vectorSearchRaw(query, stores, limit * 2);
550
+
551
+ // Build map of raw vector scores by document ID
552
+ const rawVectorScores = new Map<string, number>();
553
+ rawVectorResults.forEach((r) => {
554
+ rawVectorScores.set(r.id, r.score);
555
+ });
556
+
557
+ // Track max raw score for confidence calculation
558
+ const maxRawScore = rawVectorResults.length > 0 ? (rawVectorResults[0]?.score ?? 0) : 0;
559
+
560
+ // Normalize raw vector results directly (avoids duplicate embedding call)
561
+ // Don't apply threshold here - it's applied to final RRF-normalized scores at the end
562
+ const vectorResults = this.normalizeAndFilterScores(rawVectorResults);
563
+
564
+ // Get FTS results in parallel (only one call needed now)
565
+ const ftsResults = await this.ftsSearch(query, stores, limit * 2);
477
566
 
478
567
  // Build rank maps
479
568
  const vectorRanks = new Map<string, number>();
@@ -497,6 +586,7 @@ export class SearchService {
497
586
  id: string;
498
587
  score: number;
499
588
  result: SearchResult;
589
+ rawVectorScore: number | undefined;
500
590
  metadata: {
501
591
  vectorRank?: number;
502
592
  ftsRank?: number;
@@ -506,6 +596,7 @@ export class SearchService {
506
596
  frameworkBoost: number;
507
597
  urlKeywordBoost: number;
508
598
  pathKeywordBoost: number;
599
+ rawVectorScore?: number;
509
600
  };
510
601
  }> = [];
511
602
 
@@ -516,6 +607,7 @@ export class SearchService {
516
607
  for (const [id, result] of allDocs) {
517
608
  const vectorRank = vectorRanks.get(id) ?? Infinity;
518
609
  const ftsRank = ftsRanks.get(id) ?? Infinity;
610
+ const rawVectorScore = rawVectorScores.get(id);
519
611
 
520
612
  const vectorRRF = vectorRank !== Infinity ? vectorWeight / (k + vectorRank) : 0;
521
613
  const ftsRRF = ftsRank !== Infinity ? ftsWeight / (k + ftsRank) : 0;
@@ -545,6 +637,7 @@ export class SearchService {
545
637
  frameworkBoost: number;
546
638
  urlKeywordBoost: number;
547
639
  pathKeywordBoost: number;
640
+ rawVectorScore?: number;
548
641
  } = {
549
642
  vectorRRF,
550
643
  ftsRRF,
@@ -560,6 +653,9 @@ export class SearchService {
560
653
  if (ftsRank !== Infinity) {
561
654
  metadata.ftsRank = ftsRank;
562
655
  }
656
+ if (rawVectorScore !== undefined) {
657
+ metadata.rawVectorScore = rawVectorScore;
658
+ }
563
659
 
564
660
  rrfScores.push({
565
661
  id,
@@ -570,6 +666,7 @@ export class SearchService {
570
666
  urlKeywordBoost *
571
667
  pathKeywordBoost,
572
668
  result,
669
+ rawVectorScore,
573
670
  metadata,
574
671
  });
575
672
  }
@@ -616,10 +713,10 @@ export class SearchService {
616
713
 
617
714
  // Apply threshold filter on normalized scores (UX consistency)
618
715
  if (threshold !== undefined) {
619
- return normalizedResults.filter((r) => r.score >= threshold);
716
+ normalizedResults = normalizedResults.filter((r) => r.score >= threshold);
620
717
  }
621
718
 
622
- return normalizedResults;
719
+ return { results: normalizedResults, maxRawScore };
623
720
  }
624
721
 
625
722
  async searchAllStores(query: SearchQuery, storeIds: StoreId[]): Promise<SearchResponse> {
@@ -655,7 +752,7 @@ export class SearchService {
655
752
  baseBoost = 0.75; // Internal implementation files (not too harsh)
656
753
  break;
657
754
  case 'test':
658
- baseBoost = 0.7; // Tests significantly lower
755
+ baseBoost = parseFloat(process.env['SEARCH_TEST_FILE_BOOST'] ?? '0.5'); // Tests strongly penalized
659
756
  break;
660
757
  case 'config':
661
758
  baseBoost = 0.5; // Config files rarely answer questions
@@ -676,8 +773,14 @@ export class SearchService {
676
773
  }
677
774
 
678
775
  const blendedMultiplier = totalConfidence > 0 ? weightedMultiplier / totalConfidence : 1.0;
776
+ const finalBoost = baseBoost * blendedMultiplier;
777
+
778
+ // Cap test file boost to prevent intent multipliers from overriding the penalty
779
+ if (fileType === 'test') {
780
+ return Math.min(finalBoost, 0.6);
781
+ }
679
782
 
680
- return baseBoost * blendedMultiplier;
783
+ return finalBoost;
681
784
  }
682
785
 
683
786
  /**