@gmickel/gno 0.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/README.md +256 -0
- package/assets/skill/SKILL.md +112 -0
- package/assets/skill/cli-reference.md +327 -0
- package/assets/skill/examples.md +234 -0
- package/assets/skill/mcp-reference.md +159 -0
- package/package.json +90 -0
- package/src/app/constants.ts +313 -0
- package/src/cli/colors.ts +65 -0
- package/src/cli/commands/ask.ts +545 -0
- package/src/cli/commands/cleanup.ts +105 -0
- package/src/cli/commands/collection/add.ts +120 -0
- package/src/cli/commands/collection/index.ts +10 -0
- package/src/cli/commands/collection/list.ts +108 -0
- package/src/cli/commands/collection/remove.ts +64 -0
- package/src/cli/commands/collection/rename.ts +95 -0
- package/src/cli/commands/context/add.ts +67 -0
- package/src/cli/commands/context/check.ts +153 -0
- package/src/cli/commands/context/index.ts +10 -0
- package/src/cli/commands/context/list.ts +109 -0
- package/src/cli/commands/context/rm.ts +52 -0
- package/src/cli/commands/doctor.ts +393 -0
- package/src/cli/commands/embed.ts +462 -0
- package/src/cli/commands/get.ts +356 -0
- package/src/cli/commands/index-cmd.ts +119 -0
- package/src/cli/commands/index.ts +102 -0
- package/src/cli/commands/init.ts +328 -0
- package/src/cli/commands/ls.ts +217 -0
- package/src/cli/commands/mcp/config.ts +300 -0
- package/src/cli/commands/mcp/index.ts +24 -0
- package/src/cli/commands/mcp/install.ts +203 -0
- package/src/cli/commands/mcp/paths.ts +470 -0
- package/src/cli/commands/mcp/status.ts +222 -0
- package/src/cli/commands/mcp/uninstall.ts +158 -0
- package/src/cli/commands/mcp.ts +20 -0
- package/src/cli/commands/models/clear.ts +103 -0
- package/src/cli/commands/models/index.ts +32 -0
- package/src/cli/commands/models/list.ts +214 -0
- package/src/cli/commands/models/path.ts +51 -0
- package/src/cli/commands/models/pull.ts +199 -0
- package/src/cli/commands/models/use.ts +85 -0
- package/src/cli/commands/multi-get.ts +400 -0
- package/src/cli/commands/query.ts +220 -0
- package/src/cli/commands/ref-parser.ts +108 -0
- package/src/cli/commands/reset.ts +191 -0
- package/src/cli/commands/search.ts +136 -0
- package/src/cli/commands/shared.ts +156 -0
- package/src/cli/commands/skill/index.ts +19 -0
- package/src/cli/commands/skill/install.ts +197 -0
- package/src/cli/commands/skill/paths-cmd.ts +81 -0
- package/src/cli/commands/skill/paths.ts +191 -0
- package/src/cli/commands/skill/show.ts +73 -0
- package/src/cli/commands/skill/uninstall.ts +141 -0
- package/src/cli/commands/status.ts +205 -0
- package/src/cli/commands/update.ts +68 -0
- package/src/cli/commands/vsearch.ts +188 -0
- package/src/cli/context.ts +64 -0
- package/src/cli/errors.ts +64 -0
- package/src/cli/format/search-results.ts +211 -0
- package/src/cli/options.ts +183 -0
- package/src/cli/program.ts +1330 -0
- package/src/cli/run.ts +213 -0
- package/src/cli/ui.ts +92 -0
- package/src/config/defaults.ts +20 -0
- package/src/config/index.ts +55 -0
- package/src/config/loader.ts +161 -0
- package/src/config/paths.ts +87 -0
- package/src/config/saver.ts +153 -0
- package/src/config/types.ts +280 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
- package/src/converters/adapters/officeparser/adapter.ts +126 -0
- package/src/converters/canonicalize.ts +89 -0
- package/src/converters/errors.ts +218 -0
- package/src/converters/index.ts +51 -0
- package/src/converters/mime.ts +163 -0
- package/src/converters/native/markdown.ts +115 -0
- package/src/converters/native/plaintext.ts +56 -0
- package/src/converters/path.ts +48 -0
- package/src/converters/pipeline.ts +159 -0
- package/src/converters/registry.ts +74 -0
- package/src/converters/types.ts +123 -0
- package/src/converters/versions.ts +24 -0
- package/src/index.ts +27 -0
- package/src/ingestion/chunker.ts +238 -0
- package/src/ingestion/index.ts +32 -0
- package/src/ingestion/language.ts +276 -0
- package/src/ingestion/sync.ts +671 -0
- package/src/ingestion/types.ts +219 -0
- package/src/ingestion/walker.ts +235 -0
- package/src/llm/cache.ts +467 -0
- package/src/llm/errors.ts +191 -0
- package/src/llm/index.ts +58 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
- package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
- package/src/llm/nodeLlamaCpp/generation.ts +88 -0
- package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
- package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
- package/src/llm/registry.ts +86 -0
- package/src/llm/types.ts +129 -0
- package/src/mcp/resources/index.ts +151 -0
- package/src/mcp/server.ts +229 -0
- package/src/mcp/tools/get.ts +220 -0
- package/src/mcp/tools/index.ts +160 -0
- package/src/mcp/tools/multi-get.ts +263 -0
- package/src/mcp/tools/query.ts +226 -0
- package/src/mcp/tools/search.ts +119 -0
- package/src/mcp/tools/status.ts +81 -0
- package/src/mcp/tools/vsearch.ts +198 -0
- package/src/pipeline/chunk-lookup.ts +44 -0
- package/src/pipeline/expansion.ts +256 -0
- package/src/pipeline/explain.ts +115 -0
- package/src/pipeline/fusion.ts +185 -0
- package/src/pipeline/hybrid.ts +535 -0
- package/src/pipeline/index.ts +64 -0
- package/src/pipeline/query-language.ts +118 -0
- package/src/pipeline/rerank.ts +223 -0
- package/src/pipeline/search.ts +261 -0
- package/src/pipeline/types.ts +328 -0
- package/src/pipeline/vsearch.ts +348 -0
- package/src/store/index.ts +41 -0
- package/src/store/migrations/001-initial.ts +196 -0
- package/src/store/migrations/index.ts +20 -0
- package/src/store/migrations/runner.ts +187 -0
- package/src/store/sqlite/adapter.ts +1242 -0
- package/src/store/sqlite/index.ts +7 -0
- package/src/store/sqlite/setup.ts +129 -0
- package/src/store/sqlite/types.ts +28 -0
- package/src/store/types.ts +506 -0
- package/src/store/vector/index.ts +13 -0
- package/src/store/vector/sqlite-vec.ts +373 -0
- package/src/store/vector/stats.ts +152 -0
- package/src/store/vector/types.ts +115 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query expansion for hybrid search.
|
|
3
|
+
* Uses GenerationPort to generate query variants.
|
|
4
|
+
*
|
|
5
|
+
* @module src/pipeline/expansion
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto'; // No Bun alternative for hashing
|
|
9
|
+
import type { GenerationPort } from '../llm/types';
|
|
10
|
+
import type { StoreResult } from '../store/types';
|
|
11
|
+
import { ok } from '../store/types';
|
|
12
|
+
import type { ExpansionResult } from './types';
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Constants
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const EXPANSION_PROMPT_VERSION = 'v1';
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
20
|
+
const JSON_EXTRACT_PATTERN = /\{[\s\S]*\}/;
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// Cache Key Generation
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate cache key for expansion results.
|
|
28
|
+
* Key = SHA256(promptVersion || modelUri || query || lang)
|
|
29
|
+
*/
|
|
30
|
+
export function generateCacheKey(
|
|
31
|
+
modelUri: string,
|
|
32
|
+
query: string,
|
|
33
|
+
lang: string
|
|
34
|
+
): string {
|
|
35
|
+
const data = [EXPANSION_PROMPT_VERSION, modelUri, query, lang].join('\0');
|
|
36
|
+
return createHash('sha256').update(data).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Prompt Templates
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const EXPANSION_PROMPT_EN = `You are a query expansion assistant. Given a search query, generate alternative phrasings to improve search results.
|
|
44
|
+
|
|
45
|
+
Input query: "{query}"
|
|
46
|
+
|
|
47
|
+
Generate a JSON object with:
|
|
48
|
+
- "lexicalQueries": array of 2-3 keyword-based variations (for BM25 search)
|
|
49
|
+
- "vectorQueries": array of 2-3 semantic rephrasing (for embedding search)
|
|
50
|
+
- "hyde": a short hypothetical document passage that would answer the query (optional)
|
|
51
|
+
|
|
52
|
+
Respond ONLY with valid JSON, no explanation.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
{
|
|
56
|
+
"lexicalQueries": ["deployment process", "how to deploy", "deploying application"],
|
|
57
|
+
"vectorQueries": ["steps to release software to production", "guide for application deployment"],
|
|
58
|
+
"hyde": "To deploy the application, first run the build command, then push to the staging environment..."
|
|
59
|
+
}`;
|
|
60
|
+
|
|
61
|
+
const EXPANSION_PROMPT_DE = `Du bist ein Query-Erweiterungs-Assistent. Generiere alternative Formulierungen für die Suchanfrage.
|
|
62
|
+
|
|
63
|
+
Suchanfrage: "{query}"
|
|
64
|
+
|
|
65
|
+
Generiere ein JSON-Objekt mit:
|
|
66
|
+
- "lexicalQueries": Array mit 2-3 Keyword-Variationen (für BM25-Suche)
|
|
67
|
+
- "vectorQueries": Array mit 2-3 semantischen Umformulierungen (für Vektor-Suche)
|
|
68
|
+
- "hyde": Ein kurzer hypothetischer Dokumentenausschnitt, der die Anfrage beantworten würde (optional)
|
|
69
|
+
|
|
70
|
+
Antworte NUR mit validem JSON, keine Erklärung.`;
|
|
71
|
+
|
|
72
|
+
const EXPANSION_PROMPT_MULTILINGUAL = `You are a query expansion assistant. Generate alternative phrasings for the search query in the same language as the query.
|
|
73
|
+
|
|
74
|
+
Input query: "{query}"
|
|
75
|
+
|
|
76
|
+
Generate a JSON object with:
|
|
77
|
+
- "lexicalQueries": array of 2-3 keyword-based variations
|
|
78
|
+
- "vectorQueries": array of 2-3 semantic rephrasing
|
|
79
|
+
- "hyde": a short hypothetical document passage (optional)
|
|
80
|
+
|
|
81
|
+
Respond ONLY with valid JSON.`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get prompt template for language.
|
|
85
|
+
*/
|
|
86
|
+
function getPromptTemplate(lang?: string): string {
|
|
87
|
+
switch (lang?.toLowerCase()) {
|
|
88
|
+
case 'en':
|
|
89
|
+
case 'en-us':
|
|
90
|
+
case 'en-gb':
|
|
91
|
+
return EXPANSION_PROMPT_EN;
|
|
92
|
+
case 'de':
|
|
93
|
+
case 'de-de':
|
|
94
|
+
case 'de-at':
|
|
95
|
+
case 'de-ch':
|
|
96
|
+
return EXPANSION_PROMPT_DE;
|
|
97
|
+
default:
|
|
98
|
+
return EXPANSION_PROMPT_MULTILINGUAL;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Schema Validation
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate and parse expansion result from LLM output.
|
|
108
|
+
*/
|
|
109
|
+
function parseExpansionResult(output: string): ExpansionResult | null {
|
|
110
|
+
try {
|
|
111
|
+
// Try to extract JSON from output (model may include extra text)
|
|
112
|
+
const jsonMatch = output.match(JSON_EXTRACT_PATTERN);
|
|
113
|
+
if (!jsonMatch) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
|
118
|
+
|
|
119
|
+
// Validate required fields
|
|
120
|
+
if (!Array.isArray(parsed.lexicalQueries)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(parsed.vectorQueries)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Validate array contents are strings
|
|
128
|
+
const lexicalQueries = parsed.lexicalQueries.filter(
|
|
129
|
+
(q): q is string => typeof q === 'string' && q.length > 0
|
|
130
|
+
);
|
|
131
|
+
const vectorQueries = parsed.vectorQueries.filter(
|
|
132
|
+
(q): q is string => typeof q === 'string' && q.length > 0
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Limit array sizes
|
|
136
|
+
const result: ExpansionResult = {
|
|
137
|
+
lexicalQueries: lexicalQueries.slice(0, 5),
|
|
138
|
+
vectorQueries: vectorQueries.slice(0, 5),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Optional fields
|
|
142
|
+
if (typeof parsed.hyde === 'string' && parsed.hyde.length > 0) {
|
|
143
|
+
result.hyde = parsed.hyde;
|
|
144
|
+
}
|
|
145
|
+
if (typeof parsed.notes === 'string') {
|
|
146
|
+
result.notes = parsed.notes;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return result;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
// Expansion Function
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export interface ExpansionOptions {
|
|
160
|
+
/** Language hint for prompt selection */
|
|
161
|
+
lang?: string;
|
|
162
|
+
/** Timeout in milliseconds */
|
|
163
|
+
timeout?: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Expand query using generation model.
|
|
168
|
+
* Returns null on timeout or parse failure (graceful degradation).
|
|
169
|
+
*/
|
|
170
|
+
export async function expandQuery(
|
|
171
|
+
genPort: GenerationPort,
|
|
172
|
+
query: string,
|
|
173
|
+
options: ExpansionOptions = {}
|
|
174
|
+
): Promise<StoreResult<ExpansionResult | null>> {
|
|
175
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
176
|
+
|
|
177
|
+
// Build prompt
|
|
178
|
+
const template = getPromptTemplate(options.lang);
|
|
179
|
+
const prompt = template.replace('{query}', query);
|
|
180
|
+
|
|
181
|
+
// Run with timeout
|
|
182
|
+
const timeoutPromise = new Promise<null>((resolve) => {
|
|
183
|
+
setTimeout(() => resolve(null), timeout);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const result = await Promise.race([
|
|
188
|
+
genPort.generate(prompt, {
|
|
189
|
+
temperature: 0,
|
|
190
|
+
seed: 42,
|
|
191
|
+
maxTokens: 512,
|
|
192
|
+
}),
|
|
193
|
+
timeoutPromise,
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
// Timeout
|
|
197
|
+
if (result === null) {
|
|
198
|
+
return ok(null);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Generation failed
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
return ok(null); // Graceful degradation
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Parse result
|
|
207
|
+
const parsed = parseExpansionResult(result.value);
|
|
208
|
+
return ok(parsed);
|
|
209
|
+
} catch {
|
|
210
|
+
return ok(null); // Graceful degradation
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// Cached Expansion
|
|
216
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
export interface CachedExpansionDeps {
|
|
219
|
+
genPort: GenerationPort;
|
|
220
|
+
getCache: (key: string) => Promise<string | null>;
|
|
221
|
+
setCache: (key: string, value: string) => Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Expand query with caching.
|
|
226
|
+
*/
|
|
227
|
+
export async function expandQueryCached(
|
|
228
|
+
deps: CachedExpansionDeps,
|
|
229
|
+
query: string,
|
|
230
|
+
options: ExpansionOptions = {}
|
|
231
|
+
): Promise<StoreResult<ExpansionResult | null>> {
|
|
232
|
+
const lang = options.lang ?? 'auto';
|
|
233
|
+
const cacheKey = generateCacheKey(deps.genPort.modelUri, query, lang);
|
|
234
|
+
|
|
235
|
+
// Check cache
|
|
236
|
+
const cached = await deps.getCache(cacheKey);
|
|
237
|
+
if (cached) {
|
|
238
|
+
const parsed = parseExpansionResult(cached);
|
|
239
|
+
if (parsed) {
|
|
240
|
+
return ok(parsed);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Generate
|
|
245
|
+
const result = await expandQuery(deps.genPort, query, options);
|
|
246
|
+
if (!result.ok) {
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Cache result
|
|
251
|
+
if (result.value) {
|
|
252
|
+
await deps.setCache(cacheKey, JSON.stringify(result.value));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explainability formatter for search pipeline.
|
|
3
|
+
* Outputs pipeline details to stderr.
|
|
4
|
+
*
|
|
5
|
+
* @module src/pipeline/explain
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ExpansionResult,
|
|
10
|
+
ExplainLine,
|
|
11
|
+
ExplainResult,
|
|
12
|
+
RerankedCandidate,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// Formatter
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Format explain lines for stderr output.
|
|
21
|
+
*/
|
|
22
|
+
export function formatExplain(lines: ExplainLine[]): string {
|
|
23
|
+
return lines.map((l) => `[explain] ${l.stage}: ${l.message}`).join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format result explain for --explain output.
|
|
28
|
+
*/
|
|
29
|
+
export function formatResultExplain(results: ExplainResult[]): string {
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
for (const r of results.slice(0, 10)) {
|
|
32
|
+
let msg = `score=${r.score.toFixed(2)}`;
|
|
33
|
+
if (r.bm25Score !== undefined) {
|
|
34
|
+
msg += ` (bm25=${r.bm25Score.toFixed(2)}`;
|
|
35
|
+
if (r.vecScore !== undefined) {
|
|
36
|
+
msg += `, vec=${r.vecScore.toFixed(2)}`;
|
|
37
|
+
}
|
|
38
|
+
if (r.rerankScore !== undefined) {
|
|
39
|
+
msg += `, rerank=${r.rerankScore.toFixed(2)}`;
|
|
40
|
+
}
|
|
41
|
+
msg += ')';
|
|
42
|
+
}
|
|
43
|
+
lines.push(`[explain] result ${r.rank}: ${r.docid} ${msg}`);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Explain Line Builders
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function explainExpansion(
|
|
53
|
+
enabled: boolean,
|
|
54
|
+
result: ExpansionResult | null
|
|
55
|
+
): ExplainLine {
|
|
56
|
+
if (!enabled) {
|
|
57
|
+
return { stage: 'expansion', message: 'disabled' };
|
|
58
|
+
}
|
|
59
|
+
if (!result) {
|
|
60
|
+
return { stage: 'expansion', message: 'skipped (strong BM25 or timeout)' };
|
|
61
|
+
}
|
|
62
|
+
const lex = result.lexicalQueries.length;
|
|
63
|
+
const sem = result.vectorQueries.length;
|
|
64
|
+
const hyde = result.hyde ? ', 1 HyDE' : '';
|
|
65
|
+
return {
|
|
66
|
+
stage: 'expansion',
|
|
67
|
+
message: `enabled (${lex} lexical, ${sem} semantic variants${hyde})`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function explainBm25(count: number): ExplainLine {
|
|
72
|
+
return { stage: 'bm25', message: `${count} candidates` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function explainVector(count: number, available: boolean): ExplainLine {
|
|
76
|
+
if (!available) {
|
|
77
|
+
return { stage: 'vector', message: 'unavailable (sqlite-vec not loaded)' };
|
|
78
|
+
}
|
|
79
|
+
return { stage: 'vector', message: `${count} candidates` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function explainFusion(k: number, uniqueCount: number): ExplainLine {
|
|
83
|
+
return {
|
|
84
|
+
stage: 'fusion',
|
|
85
|
+
message: `RRF k=${k}, ${uniqueCount} unique candidates`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function explainRerank(enabled: boolean, count: number): ExplainLine {
|
|
90
|
+
if (!enabled) {
|
|
91
|
+
return { stage: 'rerank', message: 'disabled' };
|
|
92
|
+
}
|
|
93
|
+
return { stage: 'rerank', message: `top ${count} reranked` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
// Build ExplainResult from RerankedCandidate
|
|
98
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function buildExplainResults(
|
|
101
|
+
candidates: RerankedCandidate[],
|
|
102
|
+
docidMap: Map<string, string>
|
|
103
|
+
): ExplainResult[] {
|
|
104
|
+
return candidates.slice(0, 20).map((c, i) => {
|
|
105
|
+
const key = `${c.mirrorHash}:${c.seq}`;
|
|
106
|
+
return {
|
|
107
|
+
rank: i + 1,
|
|
108
|
+
docid: docidMap.get(key) ?? '#unknown',
|
|
109
|
+
score: c.blendedScore,
|
|
110
|
+
bm25Score: c.bm25Rank !== null ? 1 / (60 + c.bm25Rank) : undefined,
|
|
111
|
+
vecScore: c.vecRank !== null ? 1 / (60 + c.vecRank) : undefined,
|
|
112
|
+
rerankScore: c.rerankScore ?? undefined,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RRF (Reciprocal Rank Fusion) implementation.
|
|
3
|
+
* Combines BM25 and vector search results.
|
|
4
|
+
*
|
|
5
|
+
* @module src/pipeline/fusion
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FusionCandidate, FusionSource, RrfConfig } from './types';
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Types
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Input for fusion - ranked results from a single source */
|
|
15
|
+
export interface RankedInput {
|
|
16
|
+
source: FusionSource;
|
|
17
|
+
results: Array<{
|
|
18
|
+
mirrorHash: string;
|
|
19
|
+
seq: number;
|
|
20
|
+
rank: number; // 1-based rank
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// RRF Score Calculation
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Calculate RRF contribution for a single rank.
|
|
30
|
+
* Formula: weight / (k + rank)
|
|
31
|
+
*/
|
|
32
|
+
function rrfContribution(rank: number, k: number, weight: number): number {
|
|
33
|
+
return weight / (k + rank);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Fusion Implementation
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fuse multiple ranked lists using RRF.
|
|
42
|
+
*
|
|
43
|
+
* @param inputs - Array of ranked inputs from different sources
|
|
44
|
+
* @param config - RRF configuration
|
|
45
|
+
* @returns Fused and sorted candidates
|
|
46
|
+
*/
|
|
47
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: RRF fusion with deduplication and scoring
|
|
48
|
+
export function rrfFuse(
|
|
49
|
+
inputs: RankedInput[],
|
|
50
|
+
config: RrfConfig
|
|
51
|
+
): FusionCandidate[] {
|
|
52
|
+
// Map: "mirrorHash:seq" -> FusionCandidate
|
|
53
|
+
const candidates = new Map<string, FusionCandidate>();
|
|
54
|
+
|
|
55
|
+
// Separate inputs by type for weight assignment
|
|
56
|
+
const bm25Inputs = inputs.filter(
|
|
57
|
+
(i) => i.source === 'bm25' || i.source === 'bm25_variant'
|
|
58
|
+
);
|
|
59
|
+
const vectorInputs = inputs.filter(
|
|
60
|
+
(i) =>
|
|
61
|
+
i.source === 'vector' ||
|
|
62
|
+
i.source === 'vector_variant' ||
|
|
63
|
+
i.source === 'hyde'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Process BM25 sources
|
|
67
|
+
for (const input of bm25Inputs) {
|
|
68
|
+
const weight =
|
|
69
|
+
input.source === 'bm25' ? config.bm25Weight : config.bm25Weight * 0.5;
|
|
70
|
+
|
|
71
|
+
for (const result of input.results) {
|
|
72
|
+
const key = `${result.mirrorHash}:${result.seq}`;
|
|
73
|
+
let candidate = candidates.get(key);
|
|
74
|
+
|
|
75
|
+
if (!candidate) {
|
|
76
|
+
candidate = {
|
|
77
|
+
mirrorHash: result.mirrorHash,
|
|
78
|
+
seq: result.seq,
|
|
79
|
+
bm25Rank: null,
|
|
80
|
+
vecRank: null,
|
|
81
|
+
fusionScore: 0,
|
|
82
|
+
sources: [],
|
|
83
|
+
};
|
|
84
|
+
candidates.set(key, candidate);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Track best BM25 rank
|
|
88
|
+
if (candidate.bm25Rank === null || result.rank < candidate.bm25Rank) {
|
|
89
|
+
candidate.bm25Rank = result.rank;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Add RRF contribution
|
|
93
|
+
candidate.fusionScore += rrfContribution(result.rank, config.k, weight);
|
|
94
|
+
if (!candidate.sources.includes(input.source)) {
|
|
95
|
+
candidate.sources.push(input.source);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Process vector sources
|
|
101
|
+
for (const input of vectorInputs) {
|
|
102
|
+
let weight = config.vecWeight;
|
|
103
|
+
if (input.source === 'vector_variant') {
|
|
104
|
+
weight = config.vecWeight * 0.5;
|
|
105
|
+
} else if (input.source === 'hyde') {
|
|
106
|
+
weight = config.vecWeight * 0.7;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const result of input.results) {
|
|
110
|
+
const key = `${result.mirrorHash}:${result.seq}`;
|
|
111
|
+
let candidate = candidates.get(key);
|
|
112
|
+
|
|
113
|
+
if (!candidate) {
|
|
114
|
+
candidate = {
|
|
115
|
+
mirrorHash: result.mirrorHash,
|
|
116
|
+
seq: result.seq,
|
|
117
|
+
bm25Rank: null,
|
|
118
|
+
vecRank: null,
|
|
119
|
+
fusionScore: 0,
|
|
120
|
+
sources: [],
|
|
121
|
+
};
|
|
122
|
+
candidates.set(key, candidate);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Track best vector rank
|
|
126
|
+
if (candidate.vecRank === null || result.rank < candidate.vecRank) {
|
|
127
|
+
candidate.vecRank = result.rank;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Add RRF contribution
|
|
131
|
+
candidate.fusionScore += rrfContribution(result.rank, config.k, weight);
|
|
132
|
+
if (!candidate.sources.includes(input.source)) {
|
|
133
|
+
candidate.sources.push(input.source);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Apply top-rank bonus
|
|
139
|
+
for (const candidate of candidates.values()) {
|
|
140
|
+
if (
|
|
141
|
+
candidate.bm25Rank !== null &&
|
|
142
|
+
candidate.bm25Rank <= config.topRankThreshold &&
|
|
143
|
+
candidate.vecRank !== null &&
|
|
144
|
+
candidate.vecRank <= config.topRankThreshold
|
|
145
|
+
) {
|
|
146
|
+
candidate.fusionScore += config.topRankBonus;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sort by fusion score (descending), then by mirrorHash:seq for determinism
|
|
151
|
+
const sorted = Array.from(candidates.values()).sort((a, b) => {
|
|
152
|
+
const scoreDiff = b.fusionScore - a.fusionScore;
|
|
153
|
+
if (Math.abs(scoreDiff) > 1e-9) {
|
|
154
|
+
return scoreDiff;
|
|
155
|
+
}
|
|
156
|
+
// Deterministic tie-breaking
|
|
157
|
+
const aKey = `${a.mirrorHash}:${a.seq}`;
|
|
158
|
+
const bKey = `${b.mirrorHash}:${b.seq}`;
|
|
159
|
+
return aKey.localeCompare(bKey);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return sorted;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
// Helper: Convert search results to ranked input
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Convert array of (mirrorHash, seq) tuples to RankedInput.
|
|
171
|
+
* Results are assumed to be in rank order (first = rank 1).
|
|
172
|
+
*/
|
|
173
|
+
export function toRankedInput(
|
|
174
|
+
source: FusionSource,
|
|
175
|
+
results: Array<{ mirrorHash: string; seq: number }>
|
|
176
|
+
): RankedInput {
|
|
177
|
+
return {
|
|
178
|
+
source,
|
|
179
|
+
results: results.map((r, i) => ({
|
|
180
|
+
mirrorHash: r.mirrorHash,
|
|
181
|
+
seq: r.seq,
|
|
182
|
+
rank: i + 1,
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
}
|