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.
- package/.env.example +12 -0
- package/CHANGELOG.md +26 -0
- package/README.md +27 -1
- package/dist/{chunk-XJFV7AJW.js → chunk-HUEWT6U5.js} +90 -15
- package/dist/chunk-HUEWT6U5.js.map +1 -0
- package/dist/{chunk-ZAWIPEYX.js → chunk-IZWOEBFM.js} +2 -2
- package/dist/{chunk-36IFANFI.js → chunk-TIGPI3BE.js} +15 -6
- package/dist/chunk-TIGPI3BE.js.map +1 -0
- package/dist/index.js +22 -9
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +2 -2
- package/dist/workers/background-worker-cli.js +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/cli/commands/search.ts +22 -4
- package/src/mcp/handlers/search.handler.ts +7 -2
- package/src/mcp/schemas/index.ts +5 -0
- package/src/mcp/server.ts +5 -0
- package/src/services/search.service.test.ts +191 -3
- package/src/services/search.service.ts +121 -18
- package/src/types/search.ts +8 -0
- package/dist/chunk-36IFANFI.js.map +0 -1
- package/dist/chunk-XJFV7AJW.js.map +0 -1
- /package/dist/{chunk-ZAWIPEYX.js.map → chunk-IZWOEBFM.js.map} +0 -0
package/dist/mcp/server.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
IntelligentCrawler
|
|
4
|
-
} from "../chunk-
|
|
4
|
+
} from "../chunk-IZWOEBFM.js";
|
|
5
5
|
import {
|
|
6
6
|
JobService,
|
|
7
7
|
createDocumentId,
|
|
8
8
|
createServices,
|
|
9
9
|
createStoreId
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-HUEWT6U5.js";
|
|
11
11
|
import "../chunk-6FHWC36B.js";
|
|
12
12
|
|
|
13
13
|
// src/workers/background-worker.ts
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
package/src/mcp/schemas/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
783
|
+
return finalBoost;
|
|
681
784
|
}
|
|
682
785
|
|
|
683
786
|
/**
|