@gmickel/gno 0.17.0 → 0.18.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 +19 -1
- package/package.json +1 -1
- package/src/cli/commands/ask.ts +4 -0
- package/src/cli/commands/models/use.ts +1 -0
- package/src/cli/program.ts +18 -0
- package/src/config/types.ts +2 -0
- package/src/llm/nodeLlamaCpp/generation.ts +3 -1
- package/src/llm/registry.ts +1 -0
- package/src/llm/types.ts +2 -0
- package/src/mcp/tools/index.ts +4 -0
- package/src/mcp/tools/query.ts +4 -0
- package/src/mcp/tools/search.ts +2 -0
- package/src/mcp/tools/vsearch.ts +2 -0
- package/src/pipeline/expansion.ts +39 -4
- package/src/pipeline/hybrid.ts +38 -18
- package/src/pipeline/intent.ts +152 -0
- package/src/pipeline/rerank.ts +81 -44
- package/src/pipeline/search.ts +17 -1
- package/src/pipeline/types.ts +9 -0
- package/src/pipeline/vsearch.ts +14 -1
- package/src/serve/public/lib/retrieval-filters.ts +7 -0
- package/src/serve/public/pages/Ask.tsx +39 -0
- package/src/serve/public/pages/Search.tsx +56 -2
- package/src/serve/routes/api.ts +45 -0
package/README.md
CHANGED
|
@@ -32,7 +32,22 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
## What's New in v0.
|
|
35
|
+
## What's New in v0.18
|
|
36
|
+
|
|
37
|
+
- **Intent Steering**: optional `intent` control for ambiguous queries across CLI, API, Web, and MCP query flows
|
|
38
|
+
- **Rerank Controls**: `candidateLimit` lets you tune rerank cost vs. recall on slower or memory-constrained machines
|
|
39
|
+
- **Stability**: query expansion now uses a bounded configurable context size (`models.expandContextSize`, default `2048`)
|
|
40
|
+
- **Rerank Efficiency**: identical chunk texts are deduplicated before scoring and expanded back out deterministically
|
|
41
|
+
|
|
42
|
+
### v0.17
|
|
43
|
+
|
|
44
|
+
- **Structured Query Modes**: `term`, `intent`, and `hyde` controls across CLI, API, MCP, and Web
|
|
45
|
+
- **Temporal Retrieval Upgrades**: `since`/`until`, date-range parsing, and recency sorting with frontmatter-date fallback
|
|
46
|
+
- **Web Retrieval UX Polish**: richer advanced controls in Search and Ask (collection/date/category/author/tags + query modes)
|
|
47
|
+
- **Metadata-Aware Retrieval**: ingestion now materializes document metadata/date fields for better filtering and ranking
|
|
48
|
+
- **Migration Reliability**: SQLite-compatible migration path for existing indexes (including older SQLite engines)
|
|
49
|
+
|
|
50
|
+
### v0.15
|
|
36
51
|
|
|
37
52
|
- **HTTP Backends**: Offload embedding, reranking, and generation to remote GPU servers
|
|
38
53
|
- Simple URI config: `http://host:port/path#modelname`
|
|
@@ -146,6 +161,7 @@ gno search "handleAuth" # Find exact matches
|
|
|
146
161
|
gno vsearch "error handling patterns" # Semantic similarity
|
|
147
162
|
gno query "database optimization" # Full pipeline
|
|
148
163
|
gno query "meeting decisions" --since "last month" --category "meeting,notes" --author "gordon"
|
|
164
|
+
gno query "performance" --intent "web performance and latency"
|
|
149
165
|
gno ask "what did we decide" --answer # AI synthesis
|
|
150
166
|
```
|
|
151
167
|
|
|
@@ -161,6 +177,8 @@ gno query "auth flow" --thorough
|
|
|
161
177
|
|
|
162
178
|
# Structured retrieval intent
|
|
163
179
|
gno query "auth flow" \
|
|
180
|
+
--intent "web authentication and token lifecycle" \
|
|
181
|
+
--candidate-limit 12 \
|
|
164
182
|
--query-mode term:"jwt refresh token -oauth1" \
|
|
165
183
|
--query-mode intent:"how refresh token rotation works" \
|
|
166
184
|
--query-mode hyde:"Refresh tokens rotate on each use and previous tokens are revoked." \
|
package/package.json
CHANGED
package/src/cli/commands/ask.ts
CHANGED
|
@@ -192,6 +192,7 @@ export async function ask(
|
|
|
192
192
|
limit,
|
|
193
193
|
collection: options.collection,
|
|
194
194
|
lang: options.lang,
|
|
195
|
+
intent: options.intent,
|
|
195
196
|
since: options.since,
|
|
196
197
|
until: options.until,
|
|
197
198
|
categories: options.categories,
|
|
@@ -200,6 +201,7 @@ export async function ask(
|
|
|
200
201
|
tagsAny: options.tagsAny,
|
|
201
202
|
noExpand: options.noExpand,
|
|
202
203
|
noRerank: options.noRerank,
|
|
204
|
+
candidateLimit: options.candidateLimit,
|
|
203
205
|
});
|
|
204
206
|
|
|
205
207
|
if (!searchResult.ok) {
|
|
@@ -258,6 +260,8 @@ export async function ask(
|
|
|
258
260
|
expanded: searchResult.value.meta.expanded ?? false,
|
|
259
261
|
reranked: searchResult.value.meta.reranked ?? false,
|
|
260
262
|
vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
|
|
263
|
+
intent: searchResult.value.meta.intent,
|
|
264
|
+
candidateLimit: searchResult.value.meta.candidateLimit,
|
|
261
265
|
answerGenerated,
|
|
262
266
|
totalResults: results.length,
|
|
263
267
|
answerContext,
|
|
@@ -58,6 +58,7 @@ export async function modelsUse(
|
|
|
58
58
|
presets: config.models?.presets ?? [],
|
|
59
59
|
loadTimeout: config.models?.loadTimeout ?? 60_000,
|
|
60
60
|
inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
|
|
61
|
+
expandContextSize: config.models?.expandContextSize ?? 2_048,
|
|
61
62
|
warmModelTtl: config.models?.warmModelTtl ?? 300_000,
|
|
62
63
|
},
|
|
63
64
|
};
|
package/src/cli/program.ts
CHANGED
|
@@ -224,6 +224,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
224
224
|
)
|
|
225
225
|
.option("--category <values>", "require category match (comma-separated)")
|
|
226
226
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
227
|
+
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
227
228
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
228
229
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
229
230
|
.option("--full", "include full content")
|
|
@@ -280,6 +281,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
280
281
|
until: cmdOpts.until as string | undefined,
|
|
281
282
|
categories,
|
|
282
283
|
author: cmdOpts.author as string | undefined,
|
|
284
|
+
intent: cmdOpts.intent as string | undefined,
|
|
283
285
|
tagsAll,
|
|
284
286
|
tagsAny,
|
|
285
287
|
full: Boolean(cmdOpts.full),
|
|
@@ -329,6 +331,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
329
331
|
)
|
|
330
332
|
.option("--category <values>", "require category match (comma-separated)")
|
|
331
333
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
334
|
+
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
332
335
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
333
336
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
334
337
|
.option("--full", "include full content")
|
|
@@ -385,6 +388,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
385
388
|
until: cmdOpts.until as string | undefined,
|
|
386
389
|
categories,
|
|
387
390
|
author: cmdOpts.author as string | undefined,
|
|
391
|
+
intent: cmdOpts.intent as string | undefined,
|
|
388
392
|
tagsAll,
|
|
389
393
|
tagsAny,
|
|
390
394
|
full: Boolean(cmdOpts.full),
|
|
@@ -429,6 +433,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
429
433
|
)
|
|
430
434
|
.option("--category <values>", "require category match (comma-separated)")
|
|
431
435
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
436
|
+
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
432
437
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
433
438
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
434
439
|
.option("--full", "include full content")
|
|
@@ -443,6 +448,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
443
448
|
(value: string, previous: string[] = []) => [...previous, value],
|
|
444
449
|
[]
|
|
445
450
|
)
|
|
451
|
+
.option("-C, --candidate-limit <num>", "max candidates passed to reranking")
|
|
446
452
|
.option("--explain", "include scoring explanation")
|
|
447
453
|
.option("--json", "JSON output")
|
|
448
454
|
.option("--md", "Markdown output")
|
|
@@ -495,6 +501,9 @@ function wireSearchCommands(program: Command): void {
|
|
|
495
501
|
const limit = cmdOpts.limit
|
|
496
502
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
497
503
|
: getDefaultLimit(format);
|
|
504
|
+
const candidateLimit = cmdOpts.candidateLimit
|
|
505
|
+
? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
|
|
506
|
+
: undefined;
|
|
498
507
|
const categories = parseCsvValues(cmdOpts.category);
|
|
499
508
|
|
|
500
509
|
// Determine expansion/rerank settings based on flags
|
|
@@ -531,12 +540,14 @@ function wireSearchCommands(program: Command): void {
|
|
|
531
540
|
until: cmdOpts.until as string | undefined,
|
|
532
541
|
categories,
|
|
533
542
|
author: cmdOpts.author as string | undefined,
|
|
543
|
+
intent: cmdOpts.intent as string | undefined,
|
|
534
544
|
tagsAll,
|
|
535
545
|
tagsAny,
|
|
536
546
|
full: Boolean(cmdOpts.full),
|
|
537
547
|
lineNumbers: Boolean(cmdOpts.lineNumbers),
|
|
538
548
|
noExpand,
|
|
539
549
|
noRerank,
|
|
550
|
+
candidateLimit,
|
|
540
551
|
queryModes,
|
|
541
552
|
explain: Boolean(cmdOpts.explain),
|
|
542
553
|
json: format === "json",
|
|
@@ -574,8 +585,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
574
585
|
)
|
|
575
586
|
.option("--category <values>", "require category match (comma-separated)")
|
|
576
587
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
588
|
+
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
577
589
|
.option("--fast", "skip expansion and reranking (fastest)")
|
|
578
590
|
.option("--thorough", "enable query expansion (slower)")
|
|
591
|
+
.option("-C, --candidate-limit <num>", "max candidates passed to reranking")
|
|
579
592
|
.option("--answer", "generate short grounded answer")
|
|
580
593
|
.option("--no-answer", "force retrieval-only output")
|
|
581
594
|
.option("--max-answer-tokens <num>", "max answer tokens")
|
|
@@ -594,6 +607,9 @@ function wireSearchCommands(program: Command): void {
|
|
|
594
607
|
const limit = cmdOpts.limit
|
|
595
608
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
596
609
|
: getDefaultLimit(format);
|
|
610
|
+
const candidateLimit = cmdOpts.candidateLimit
|
|
611
|
+
? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
|
|
612
|
+
: undefined;
|
|
597
613
|
|
|
598
614
|
// Parse max-answer-tokens (optional, defaults to 512 in command impl)
|
|
599
615
|
const maxAnswerTokens = cmdOpts.maxAnswerTokens
|
|
@@ -624,8 +640,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
624
640
|
until: cmdOpts.until as string | undefined,
|
|
625
641
|
categories,
|
|
626
642
|
author: cmdOpts.author as string | undefined,
|
|
643
|
+
intent: cmdOpts.intent as string | undefined,
|
|
627
644
|
noExpand,
|
|
628
645
|
noRerank,
|
|
646
|
+
candidateLimit,
|
|
629
647
|
// Per spec: --answer defaults to false, --no-answer forces retrieval-only
|
|
630
648
|
// Commander creates separate cmdOpts.noAnswer for --no-answer flag
|
|
631
649
|
answer: Boolean(cmdOpts.answer),
|
package/src/config/types.ts
CHANGED
|
@@ -209,6 +209,8 @@ export const ModelConfigSchema = z.object({
|
|
|
209
209
|
loadTimeout: z.number().default(60_000),
|
|
210
210
|
/** Inference timeout in ms */
|
|
211
211
|
inferenceTimeout: z.number().default(30_000),
|
|
212
|
+
/** Context size used for query expansion generation */
|
|
213
|
+
expandContextSize: z.number().int().min(256).default(2_048),
|
|
212
214
|
/** Keep warm model TTL in ms (5 min) */
|
|
213
215
|
warmModelTtl: z.number().default(300_000),
|
|
214
216
|
});
|
|
@@ -56,7 +56,9 @@ export class NodeLlamaCppGeneration implements GenerationPort {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const llamaModel = model.value.model as LlamaModel;
|
|
59
|
-
const context = await llamaModel.createContext(
|
|
59
|
+
const context = await llamaModel.createContext(
|
|
60
|
+
params?.contextSize ? { contextSize: params.contextSize } : undefined
|
|
61
|
+
);
|
|
60
62
|
|
|
61
63
|
try {
|
|
62
64
|
// Import LlamaChatSession dynamically
|
package/src/llm/registry.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function getModelConfig(config: Config): ModelConfig {
|
|
|
25
25
|
: DEFAULT_MODEL_PRESETS,
|
|
26
26
|
loadTimeout: config.models?.loadTimeout ?? 60_000,
|
|
27
27
|
inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
|
|
28
|
+
expandContextSize: config.models?.expandContextSize ?? 2_048,
|
|
28
29
|
warmModelTtl: config.models?.warmModelTtl ?? 300_000,
|
|
29
30
|
};
|
|
30
31
|
}
|
package/src/llm/types.ts
CHANGED
package/src/mcp/tools/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ const searchInputSchema = z.object({
|
|
|
56
56
|
limit: z.number().int().min(1).max(100).default(5),
|
|
57
57
|
minScore: z.number().min(0).max(1).optional(),
|
|
58
58
|
lang: z.string().optional(),
|
|
59
|
+
intent: z.string().optional(),
|
|
59
60
|
since: z.string().optional(),
|
|
60
61
|
until: z.string().optional(),
|
|
61
62
|
categories: z.array(z.string()).optional(),
|
|
@@ -105,6 +106,7 @@ const vsearchInputSchema = z.object({
|
|
|
105
106
|
limit: z.number().int().min(1).max(100).default(5),
|
|
106
107
|
minScore: z.number().min(0).max(1).optional(),
|
|
107
108
|
lang: z.string().optional(),
|
|
109
|
+
intent: z.string().optional(),
|
|
108
110
|
since: z.string().optional(),
|
|
109
111
|
until: z.string().optional(),
|
|
110
112
|
categories: z.array(z.string()).optional(),
|
|
@@ -124,6 +126,8 @@ export const queryInputSchema = z.object({
|
|
|
124
126
|
limit: z.number().int().min(1).max(100).default(5),
|
|
125
127
|
minScore: z.number().min(0).max(1).optional(),
|
|
126
128
|
lang: z.string().optional(),
|
|
129
|
+
intent: z.string().optional(),
|
|
130
|
+
candidateLimit: z.number().int().min(1).max(100).optional(),
|
|
127
131
|
since: z.string().optional(),
|
|
128
132
|
until: z.string().optional(),
|
|
129
133
|
categories: z.array(z.string()).optional(),
|
package/src/mcp/tools/query.ts
CHANGED
|
@@ -36,6 +36,8 @@ interface QueryInput {
|
|
|
36
36
|
limit?: number;
|
|
37
37
|
minScore?: number;
|
|
38
38
|
lang?: string;
|
|
39
|
+
intent?: string;
|
|
40
|
+
candidateLimit?: number;
|
|
39
41
|
since?: string;
|
|
40
42
|
until?: string;
|
|
41
43
|
categories?: string[];
|
|
@@ -247,6 +249,8 @@ export function handleQuery(
|
|
|
247
249
|
minScore: args.minScore,
|
|
248
250
|
collection: args.collection,
|
|
249
251
|
queryLanguageHint: args.lang, // Affects expansion prompt, not retrieval
|
|
252
|
+
intent: args.intent,
|
|
253
|
+
candidateLimit: args.candidateLimit,
|
|
250
254
|
since: args.since,
|
|
251
255
|
until: args.until,
|
|
252
256
|
categories: args.categories,
|
package/src/mcp/tools/search.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface SearchInput {
|
|
|
19
19
|
limit?: number;
|
|
20
20
|
minScore?: number;
|
|
21
21
|
lang?: string;
|
|
22
|
+
intent?: string;
|
|
22
23
|
since?: string;
|
|
23
24
|
until?: string;
|
|
24
25
|
categories?: string[];
|
|
@@ -108,6 +109,7 @@ export function handleSearch(
|
|
|
108
109
|
minScore: args.minScore,
|
|
109
110
|
collection: args.collection,
|
|
110
111
|
lang: args.lang,
|
|
112
|
+
intent: args.intent,
|
|
111
113
|
since: args.since,
|
|
112
114
|
until: args.until,
|
|
113
115
|
categories: args.categories,
|
package/src/mcp/tools/vsearch.ts
CHANGED
|
@@ -28,6 +28,7 @@ interface VsearchInput {
|
|
|
28
28
|
limit?: number;
|
|
29
29
|
minScore?: number;
|
|
30
30
|
lang?: string;
|
|
31
|
+
intent?: string;
|
|
31
32
|
since?: string;
|
|
32
33
|
until?: string;
|
|
33
34
|
categories?: string[];
|
|
@@ -192,6 +193,7 @@ export function handleVsearch(
|
|
|
192
193
|
limit: args.limit ?? 5,
|
|
193
194
|
minScore: args.minScore,
|
|
194
195
|
collection: args.collection,
|
|
196
|
+
intent: args.intent,
|
|
195
197
|
since: args.since,
|
|
196
198
|
until: args.until,
|
|
197
199
|
categories: args.categories,
|
|
@@ -67,9 +67,16 @@ const STOPWORDS = new Set([
|
|
|
67
67
|
export function generateCacheKey(
|
|
68
68
|
modelUri: string,
|
|
69
69
|
query: string,
|
|
70
|
-
lang: string
|
|
70
|
+
lang: string,
|
|
71
|
+
intent?: string
|
|
71
72
|
): string {
|
|
72
|
-
const data = [
|
|
73
|
+
const data = [
|
|
74
|
+
EXPANSION_PROMPT_VERSION,
|
|
75
|
+
modelUri,
|
|
76
|
+
query,
|
|
77
|
+
lang,
|
|
78
|
+
intent?.trim() ?? "",
|
|
79
|
+
].join("\0");
|
|
73
80
|
return createHash("sha256").update(data).digest("hex");
|
|
74
81
|
}
|
|
75
82
|
|
|
@@ -150,6 +157,24 @@ function getPromptTemplate(lang?: string): string {
|
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
function buildPrompt(query: string, template: string, intent?: string): string {
|
|
161
|
+
const basePrompt = template.replace("{query}", query);
|
|
162
|
+
const trimmedIntent = intent?.trim();
|
|
163
|
+
if (!trimmedIntent) {
|
|
164
|
+
return basePrompt;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return basePrompt
|
|
168
|
+
.replace(
|
|
169
|
+
`Query: "${query}"\n`,
|
|
170
|
+
`Query: "${query}"\nQuery intent: "${trimmedIntent}"\n`
|
|
171
|
+
)
|
|
172
|
+
.replace(
|
|
173
|
+
`Anfrage: "${query}"\n`,
|
|
174
|
+
`Anfrage: "${query}"\nQuery intent: "${trimmedIntent}"\n`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
153
178
|
interface QuerySignals {
|
|
154
179
|
quotedPhrases: string[];
|
|
155
180
|
negations: string[];
|
|
@@ -405,6 +430,10 @@ export interface ExpansionOptions {
|
|
|
405
430
|
lang?: string;
|
|
406
431
|
/** Timeout in milliseconds */
|
|
407
432
|
timeout?: number;
|
|
433
|
+
/** Optional context that steers expansion for ambiguous queries */
|
|
434
|
+
intent?: string;
|
|
435
|
+
/** Optional bounded context size override for expansion generation */
|
|
436
|
+
contextSize?: number;
|
|
408
437
|
}
|
|
409
438
|
|
|
410
439
|
/**
|
|
@@ -420,7 +449,7 @@ export async function expandQuery(
|
|
|
420
449
|
|
|
421
450
|
// Build prompt
|
|
422
451
|
const template = getPromptTemplate(options.lang);
|
|
423
|
-
const prompt =
|
|
452
|
+
const prompt = buildPrompt(query, template, options.intent);
|
|
424
453
|
|
|
425
454
|
// Run with timeout (clear timer to avoid resource leak)
|
|
426
455
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -434,6 +463,7 @@ export async function expandQuery(
|
|
|
434
463
|
temperature: 0,
|
|
435
464
|
seed: 42,
|
|
436
465
|
maxTokens: 512,
|
|
466
|
+
contextSize: options.contextSize,
|
|
437
467
|
}),
|
|
438
468
|
timeoutPromise,
|
|
439
469
|
]);
|
|
@@ -486,7 +516,12 @@ export async function expandQueryCached(
|
|
|
486
516
|
options: ExpansionOptions = {}
|
|
487
517
|
): Promise<StoreResult<ExpansionResult | null>> {
|
|
488
518
|
const lang = options.lang ?? "auto";
|
|
489
|
-
const cacheKey = generateCacheKey(
|
|
519
|
+
const cacheKey = generateCacheKey(
|
|
520
|
+
deps.genPort.modelUri,
|
|
521
|
+
query,
|
|
522
|
+
lang,
|
|
523
|
+
options.intent
|
|
524
|
+
);
|
|
490
525
|
|
|
491
526
|
// Check cache
|
|
492
527
|
const cached = await deps.getCache(cacheKey);
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
explainVector,
|
|
36
36
|
} from "./explain";
|
|
37
37
|
import { type RankedInput, rrfFuse, toRankedInput } from "./fusion";
|
|
38
|
+
import { selectBestChunkForSteering } from "./intent";
|
|
38
39
|
import { detectQueryLanguage } from "./query-language";
|
|
39
40
|
import {
|
|
40
41
|
buildExpansionFromQueryModes,
|
|
@@ -329,16 +330,18 @@ export async function searchHybrid(
|
|
|
329
330
|
}
|
|
330
331
|
|
|
331
332
|
if (expansionStatus !== "provided" && shouldExpand) {
|
|
332
|
-
const hasStrongSignal =
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
333
|
+
const hasStrongSignal = options.intent?.trim()
|
|
334
|
+
? false
|
|
335
|
+
: await checkBm25Strength(store, query, {
|
|
336
|
+
collection: options.collection,
|
|
337
|
+
lang: options.lang,
|
|
338
|
+
tagsAll: options.tagsAll,
|
|
339
|
+
tagsAny: options.tagsAny,
|
|
340
|
+
since: temporalRange.since,
|
|
341
|
+
until: temporalRange.until,
|
|
342
|
+
categories: options.categories,
|
|
343
|
+
author: options.author,
|
|
344
|
+
});
|
|
342
345
|
|
|
343
346
|
if (hasStrongSignal) {
|
|
344
347
|
expansionStatus = "skipped_strong";
|
|
@@ -349,6 +352,8 @@ export async function searchHybrid(
|
|
|
349
352
|
// Use queryLanguage for prompt selection, NOT options.lang (retrieval filter)
|
|
350
353
|
lang: queryLanguage,
|
|
351
354
|
timeout: pipelineConfig.expansionTimeout,
|
|
355
|
+
intent: options.intent,
|
|
356
|
+
contextSize: deps.config.models?.expandContextSize,
|
|
352
357
|
});
|
|
353
358
|
if (expandResult.ok) {
|
|
354
359
|
expansion = expandResult.value;
|
|
@@ -496,13 +501,16 @@ export async function searchHybrid(
|
|
|
496
501
|
// 4. Reranking
|
|
497
502
|
// ─────────────────────────────────────────────────────────────────────────
|
|
498
503
|
const rerankStartedAt = performance.now();
|
|
504
|
+
const candidateLimit =
|
|
505
|
+
options.candidateLimit ?? pipelineConfig.rerankCandidates;
|
|
499
506
|
const rerankResult = await rerankCandidates(
|
|
500
507
|
{ rerankPort: options.noRerank ? null : rerankPort, store },
|
|
501
508
|
query,
|
|
502
509
|
fusedCandidates,
|
|
503
510
|
{
|
|
504
|
-
maxCandidates:
|
|
511
|
+
maxCandidates: candidateLimit,
|
|
505
512
|
blendingSchedule: pipelineConfig.blendingSchedule,
|
|
513
|
+
intent: options.intent,
|
|
506
514
|
}
|
|
507
515
|
);
|
|
508
516
|
if (rerankResult.fallbackReason === "disabled") {
|
|
@@ -513,10 +521,7 @@ export async function searchHybrid(
|
|
|
513
521
|
timings.rerankMs = performance.now() - rerankStartedAt;
|
|
514
522
|
|
|
515
523
|
explainLines.push(
|
|
516
|
-
explainRerank(
|
|
517
|
-
!options.noRerank && rerankPort !== null,
|
|
518
|
-
pipelineConfig.rerankCandidates
|
|
519
|
-
)
|
|
524
|
+
explainRerank(!options.noRerank && rerankPort !== null, candidateLimit)
|
|
520
525
|
);
|
|
521
526
|
|
|
522
527
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -692,10 +697,23 @@ export async function searchHybrid(
|
|
|
692
697
|
const collectionPath = collectionPaths.get(doc.collection);
|
|
693
698
|
|
|
694
699
|
// For --full mode, fetch full mirror content
|
|
695
|
-
|
|
700
|
+
const snippetChunk =
|
|
701
|
+
options.full || !options.intent?.trim()
|
|
702
|
+
? chunk
|
|
703
|
+
: (selectBestChunkForSteering(
|
|
704
|
+
chunksMap.get(candidate.mirrorHash) ?? [],
|
|
705
|
+
query,
|
|
706
|
+
options.intent,
|
|
707
|
+
{
|
|
708
|
+
preferredSeq: chunk.seq,
|
|
709
|
+
intentWeight: 0.3,
|
|
710
|
+
}
|
|
711
|
+
) ?? chunk);
|
|
712
|
+
|
|
713
|
+
let snippet = snippetChunk.text;
|
|
696
714
|
let snippetRange: { startLine: number; endLine: number } | undefined = {
|
|
697
|
-
startLine:
|
|
698
|
-
endLine:
|
|
715
|
+
startLine: snippetChunk.startLine,
|
|
716
|
+
endLine: snippetChunk.endLine,
|
|
699
717
|
};
|
|
700
718
|
|
|
701
719
|
if (options.full) {
|
|
@@ -791,12 +809,14 @@ export async function searchHybrid(
|
|
|
791
809
|
reranked: rerankResult.reranked,
|
|
792
810
|
vectorsUsed: vectorAvailable,
|
|
793
811
|
totalResults: finalResults.length,
|
|
812
|
+
intent: options.intent,
|
|
794
813
|
collection: options.collection,
|
|
795
814
|
lang: options.lang,
|
|
796
815
|
since: temporalRange.since,
|
|
797
816
|
until: temporalRange.until,
|
|
798
817
|
categories: options.categories,
|
|
799
818
|
author: options.author,
|
|
819
|
+
candidateLimit,
|
|
800
820
|
queryLanguage,
|
|
801
821
|
queryModes: queryModeSummary,
|
|
802
822
|
explain: explainData,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent-aware retrieval helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module src/pipeline/intent
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChunkRow } from "../store/types";
|
|
8
|
+
|
|
9
|
+
const TOKEN_PATTERN = /[A-Za-z0-9][A-Za-z0-9.+#/_-]*/g;
|
|
10
|
+
|
|
11
|
+
const STOPWORDS = new Set([
|
|
12
|
+
"a",
|
|
13
|
+
"an",
|
|
14
|
+
"and",
|
|
15
|
+
"are",
|
|
16
|
+
"as",
|
|
17
|
+
"at",
|
|
18
|
+
"be",
|
|
19
|
+
"by",
|
|
20
|
+
"for",
|
|
21
|
+
"from",
|
|
22
|
+
"how",
|
|
23
|
+
"in",
|
|
24
|
+
"is",
|
|
25
|
+
"it",
|
|
26
|
+
"of",
|
|
27
|
+
"on",
|
|
28
|
+
"or",
|
|
29
|
+
"that",
|
|
30
|
+
"the",
|
|
31
|
+
"this",
|
|
32
|
+
"to",
|
|
33
|
+
"what",
|
|
34
|
+
"when",
|
|
35
|
+
"where",
|
|
36
|
+
"which",
|
|
37
|
+
"who",
|
|
38
|
+
"why",
|
|
39
|
+
"with",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const MATCH_ANCHOR_BONUS = 0.2;
|
|
43
|
+
|
|
44
|
+
function dedupe(values: string[]): string[] {
|
|
45
|
+
return [...new Set(values)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeToken(token: string): string {
|
|
49
|
+
return token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "").toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract meaningful steering terms from query/intent text.
|
|
54
|
+
* Keeps short domain tokens like API/SQL/LLM while dropping common stop words.
|
|
55
|
+
*/
|
|
56
|
+
export function extractSteeringTerms(text: string): string[] {
|
|
57
|
+
const matches = text.match(TOKEN_PATTERN) ?? [];
|
|
58
|
+
const terms: string[] = [];
|
|
59
|
+
|
|
60
|
+
for (const rawToken of matches) {
|
|
61
|
+
const token = normalizeToken(rawToken);
|
|
62
|
+
if (token.length < 2) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (STOPWORDS.has(token)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
terms.push(token);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return dedupe(terms);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scoreTextForTerms(text: string, terms: string[]): number {
|
|
75
|
+
if (terms.length === 0 || text.length === 0) {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const haystack = text.toLowerCase();
|
|
80
|
+
let score = 0;
|
|
81
|
+
|
|
82
|
+
for (const term of terms) {
|
|
83
|
+
if (haystack.includes(term)) {
|
|
84
|
+
score += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return score;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ChunkSelectionOptions {
|
|
92
|
+
preferredSeq?: number | null;
|
|
93
|
+
intentWeight: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Choose the most query-relevant chunk in a document, with intent as a softer steer.
|
|
98
|
+
*/
|
|
99
|
+
export function selectBestChunkForSteering(
|
|
100
|
+
chunks: ChunkRow[],
|
|
101
|
+
query: string,
|
|
102
|
+
intent: string | undefined,
|
|
103
|
+
options: ChunkSelectionOptions
|
|
104
|
+
): ChunkRow | null {
|
|
105
|
+
if (chunks.length === 0) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const queryTerms = extractSteeringTerms(query);
|
|
110
|
+
const intentTerms = extractSteeringTerms(intent ?? "");
|
|
111
|
+
const preferredSeq = options.preferredSeq ?? null;
|
|
112
|
+
let bestChunk: ChunkRow | null = null;
|
|
113
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
114
|
+
|
|
115
|
+
for (const chunk of chunks) {
|
|
116
|
+
const queryScore = scoreTextForTerms(chunk.text, queryTerms);
|
|
117
|
+
const intentScore =
|
|
118
|
+
scoreTextForTerms(chunk.text, intentTerms) * options.intentWeight;
|
|
119
|
+
const preferredBonus =
|
|
120
|
+
preferredSeq !== null && chunk.seq === preferredSeq
|
|
121
|
+
? MATCH_ANCHOR_BONUS
|
|
122
|
+
: 0;
|
|
123
|
+
const score = queryScore + intentScore + preferredBonus;
|
|
124
|
+
|
|
125
|
+
if (score > bestScore) {
|
|
126
|
+
bestScore = score;
|
|
127
|
+
bestChunk = chunk;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (score === bestScore && bestChunk && chunk.seq < bestChunk.seq) {
|
|
132
|
+
bestChunk = chunk;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return bestChunk ?? chunks[0] ?? null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a rerank query that provides intent as context without becoming a search term.
|
|
141
|
+
*/
|
|
142
|
+
export function buildIntentAwareRerankQuery(
|
|
143
|
+
query: string,
|
|
144
|
+
intent?: string
|
|
145
|
+
): string {
|
|
146
|
+
const trimmedIntent = intent?.trim();
|
|
147
|
+
if (!trimmedIntent) {
|
|
148
|
+
return query;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `Intent: ${trimmedIntent}\nQuery: ${query}`;
|
|
152
|
+
}
|
package/src/pipeline/rerank.ts
CHANGED
|
@@ -9,6 +9,10 @@ import type { RerankPort } from "../llm/types";
|
|
|
9
9
|
import type { ChunkRow, StorePort } from "../store/types";
|
|
10
10
|
import type { BlendingTier, FusionCandidate, RerankedCandidate } from "./types";
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
buildIntentAwareRerankQuery,
|
|
14
|
+
selectBestChunkForSteering,
|
|
15
|
+
} from "./intent";
|
|
12
16
|
import { DEFAULT_BLENDING_SCHEDULE } from "./types";
|
|
13
17
|
|
|
14
18
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -20,6 +24,8 @@ export interface RerankOptions {
|
|
|
20
24
|
maxCandidates?: number;
|
|
21
25
|
/** Blending schedule */
|
|
22
26
|
blendingSchedule?: BlendingTier[];
|
|
27
|
+
/** Optional disambiguating context for reranking */
|
|
28
|
+
intent?: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface RerankResult {
|
|
@@ -75,27 +81,6 @@ function blend(
|
|
|
75
81
|
const MAX_CHUNK_CHARS = 4000;
|
|
76
82
|
const PROTECT_BM25_TOP_RANK = 1;
|
|
77
83
|
|
|
78
|
-
interface BestChunkInfo {
|
|
79
|
-
candidate: FusionCandidate;
|
|
80
|
-
seq: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Extract best chunk per document for efficient reranking.
|
|
85
|
-
*/
|
|
86
|
-
function selectBestChunks(
|
|
87
|
-
toRerank: FusionCandidate[]
|
|
88
|
-
): Map<string, BestChunkInfo> {
|
|
89
|
-
const bestChunkPerDoc = new Map<string, BestChunkInfo>();
|
|
90
|
-
for (const c of toRerank) {
|
|
91
|
-
const existing = bestChunkPerDoc.get(c.mirrorHash);
|
|
92
|
-
if (!existing || c.fusionScore > existing.candidate.fusionScore) {
|
|
93
|
-
bestChunkPerDoc.set(c.mirrorHash, { candidate: c, seq: c.seq });
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return bestChunkPerDoc;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
84
|
function isProtectedLexicalTopHit(candidate: FusionCandidate): boolean {
|
|
100
85
|
return (
|
|
101
86
|
candidate.bm25Rank === PROTECT_BM25_TOP_RANK &&
|
|
@@ -108,31 +93,50 @@ function isProtectedLexicalTopHit(candidate: FusionCandidate): boolean {
|
|
|
108
93
|
*/
|
|
109
94
|
async function fetchChunkTexts(
|
|
110
95
|
store: StorePort,
|
|
111
|
-
|
|
96
|
+
toRerank: FusionCandidate[],
|
|
97
|
+
query: string,
|
|
98
|
+
intent: string | undefined
|
|
112
99
|
): Promise<{ texts: string[]; hashToIndex: Map<string, number> }> {
|
|
113
|
-
const uniqueHashes = [
|
|
100
|
+
const uniqueHashes = [
|
|
101
|
+
...new Set(toRerank.map((candidate) => candidate.mirrorHash)),
|
|
102
|
+
];
|
|
114
103
|
const chunksBatchResult = await store.getChunksBatch(uniqueHashes);
|
|
115
104
|
const chunksByHash: Map<string, ChunkRow[]> = chunksBatchResult.ok
|
|
116
105
|
? chunksBatchResult.value
|
|
117
106
|
: new Map();
|
|
118
|
-
const
|
|
107
|
+
const preferredSeqByHash = new Map<string, number>();
|
|
108
|
+
|
|
109
|
+
for (const candidate of toRerank) {
|
|
110
|
+
const existingSeq = preferredSeqByHash.get(candidate.mirrorHash);
|
|
111
|
+
if (existingSeq !== undefined) {
|
|
112
|
+
const existingCandidate = toRerank.find(
|
|
113
|
+
(entry) =>
|
|
114
|
+
entry.mirrorHash === candidate.mirrorHash && entry.seq === existingSeq
|
|
115
|
+
);
|
|
116
|
+
if (
|
|
117
|
+
existingCandidate &&
|
|
118
|
+
existingCandidate.fusionScore >= candidate.fusionScore
|
|
119
|
+
) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
preferredSeqByHash.set(candidate.mirrorHash, candidate.seq);
|
|
124
|
+
}
|
|
119
125
|
|
|
126
|
+
const chunkTexts = new Map<string, string>();
|
|
120
127
|
for (const hash of uniqueHashes) {
|
|
121
|
-
const bestInfo = bestChunkPerDoc.get(hash);
|
|
122
128
|
const chunks = chunksByHash.get(hash);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
chunkTexts.set(hash, "");
|
|
135
|
-
}
|
|
129
|
+
const bestChunk = selectBestChunkForSteering(chunks ?? [], query, intent, {
|
|
130
|
+
preferredSeq: preferredSeqByHash.get(hash) ?? null,
|
|
131
|
+
intentWeight: 0.5,
|
|
132
|
+
});
|
|
133
|
+
const text = bestChunk?.text ?? "";
|
|
134
|
+
chunkTexts.set(
|
|
135
|
+
hash,
|
|
136
|
+
text.length > MAX_CHUNK_CHARS
|
|
137
|
+
? `${text.slice(0, MAX_CHUNK_CHARS)}...`
|
|
138
|
+
: text
|
|
139
|
+
);
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
const hashToIndex = new Map<string, number>();
|
|
@@ -198,11 +202,40 @@ export async function rerankCandidates(
|
|
|
198
202
|
const remaining = candidates.slice(maxCandidates);
|
|
199
203
|
|
|
200
204
|
// Extract best chunk per document for efficient reranking
|
|
201
|
-
const
|
|
202
|
-
|
|
205
|
+
const { texts, hashToIndex } = await fetchChunkTexts(
|
|
206
|
+
store,
|
|
207
|
+
toRerank,
|
|
208
|
+
query,
|
|
209
|
+
options.intent
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const uniqueTexts: string[] = [];
|
|
213
|
+
const docIndexToUniqueIndex = new Map<number, number>();
|
|
214
|
+
const uniqueIndexToDocIndices = new Map<number, number[]>();
|
|
215
|
+
const textToUniqueIndex = new Map<string, number>();
|
|
216
|
+
|
|
217
|
+
for (const [docIndex, text] of texts.entries()) {
|
|
218
|
+
const existingIndex = textToUniqueIndex.get(text);
|
|
219
|
+
if (existingIndex !== undefined) {
|
|
220
|
+
docIndexToUniqueIndex.set(docIndex, existingIndex);
|
|
221
|
+
const mapped = uniqueIndexToDocIndices.get(existingIndex) ?? [];
|
|
222
|
+
mapped.push(docIndex);
|
|
223
|
+
uniqueIndexToDocIndices.set(existingIndex, mapped);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const uniqueIndex = uniqueTexts.length;
|
|
228
|
+
uniqueTexts.push(text);
|
|
229
|
+
textToUniqueIndex.set(text, uniqueIndex);
|
|
230
|
+
docIndexToUniqueIndex.set(docIndex, uniqueIndex);
|
|
231
|
+
uniqueIndexToDocIndices.set(uniqueIndex, [docIndex]);
|
|
232
|
+
}
|
|
203
233
|
|
|
204
234
|
// Run reranking on best chunks (much faster than full docs)
|
|
205
|
-
const rerankResult = await rerankPort.rerank(
|
|
235
|
+
const rerankResult = await rerankPort.rerank(
|
|
236
|
+
buildIntentAwareRerankQuery(query, options.intent),
|
|
237
|
+
uniqueTexts
|
|
238
|
+
);
|
|
206
239
|
|
|
207
240
|
if (!rerankResult.ok) {
|
|
208
241
|
return {
|
|
@@ -217,9 +250,13 @@ export async function rerankCandidates(
|
|
|
217
250
|
}
|
|
218
251
|
|
|
219
252
|
// Normalize rerank scores using min-max
|
|
220
|
-
const scoreByDocIndex = new Map(
|
|
221
|
-
|
|
222
|
-
|
|
253
|
+
const scoreByDocIndex = new Map<number, number>();
|
|
254
|
+
for (const score of rerankResult.value) {
|
|
255
|
+
const docIndices = uniqueIndexToDocIndices.get(score.index) ?? [];
|
|
256
|
+
for (const docIndex of docIndices) {
|
|
257
|
+
scoreByDocIndex.set(docIndex, score.score);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
223
260
|
const rerankScores = rerankResult.value.map((s) => s.score);
|
|
224
261
|
const minRerank = Math.min(...rerankScores);
|
|
225
262
|
const maxRerank = Math.max(...rerankScores);
|
package/src/pipeline/search.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
|
|
18
18
|
import { err, ok } from "../store/types";
|
|
19
19
|
import { createChunkLookup } from "./chunk-lookup";
|
|
20
|
+
import { selectBestChunkForSteering } from "./intent";
|
|
20
21
|
import { detectQueryLanguage } from "./query-language";
|
|
21
22
|
import {
|
|
22
23
|
resolveRecencyTimestamp,
|
|
@@ -218,9 +219,23 @@ export async function searchBm25(
|
|
|
218
219
|
seenUriSeq.add(uriSeqKey);
|
|
219
220
|
|
|
220
221
|
// Get chunk via O(1) lookup
|
|
221
|
-
const
|
|
222
|
+
const rawChunk = fts.mirrorHash
|
|
222
223
|
? (getChunk(fts.mirrorHash, fts.seq) ?? null)
|
|
223
224
|
: null;
|
|
225
|
+
const chunk =
|
|
226
|
+
options.intent && fts.mirrorHash
|
|
227
|
+
? (selectBestChunkForSteering(
|
|
228
|
+
chunksMapResult.ok
|
|
229
|
+
? (chunksMapResult.value.get(fts.mirrorHash) ?? [])
|
|
230
|
+
: [],
|
|
231
|
+
query,
|
|
232
|
+
options.intent,
|
|
233
|
+
{
|
|
234
|
+
preferredSeq: rawChunk?.seq ?? fts.seq,
|
|
235
|
+
intentWeight: 0.3,
|
|
236
|
+
}
|
|
237
|
+
) ?? rawChunk)
|
|
238
|
+
: rawChunk;
|
|
224
239
|
|
|
225
240
|
// For --full, de-dupe by docid (keep best scoring chunk per doc)
|
|
226
241
|
// Raw BM25: smaller (more negative) is better
|
|
@@ -293,6 +308,7 @@ export async function searchBm25(
|
|
|
293
308
|
query,
|
|
294
309
|
mode: "bm25",
|
|
295
310
|
totalResults: Math.min(filteredResults.length, limit),
|
|
311
|
+
intent: options.intent,
|
|
296
312
|
collection: options.collection,
|
|
297
313
|
lang: options.lang,
|
|
298
314
|
since: temporalRange.since,
|
package/src/pipeline/types.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface SearchMeta {
|
|
|
62
62
|
reranked?: boolean;
|
|
63
63
|
vectorsUsed?: boolean;
|
|
64
64
|
totalResults: number;
|
|
65
|
+
intent?: string;
|
|
65
66
|
collection?: string;
|
|
66
67
|
lang?: string;
|
|
67
68
|
/** Detected/overridden query language for prompt selection (typically BCP-47; may be user-provided via --lang) */
|
|
@@ -76,6 +77,8 @@ export interface SearchMeta {
|
|
|
76
77
|
categories?: string[];
|
|
77
78
|
/** Author filter applied */
|
|
78
79
|
author?: string;
|
|
80
|
+
/** Rerank candidate limit used */
|
|
81
|
+
candidateLimit?: number;
|
|
79
82
|
/** Explain data (when --explain is used) */
|
|
80
83
|
explain?: {
|
|
81
84
|
lines: ExplainLine[];
|
|
@@ -119,6 +122,8 @@ export interface SearchOptions {
|
|
|
119
122
|
categories?: string[];
|
|
120
123
|
/** Filter by author value */
|
|
121
124
|
author?: string;
|
|
125
|
+
/** Optional disambiguating context that steers scoring/snippets, but is not searched directly */
|
|
126
|
+
intent?: string;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/** Structured query mode identifier */
|
|
@@ -145,6 +150,8 @@ export type HybridSearchOptions = SearchOptions & {
|
|
|
145
150
|
noRerank?: boolean;
|
|
146
151
|
/** Optional structured mode entries; when set, used as expansion inputs */
|
|
147
152
|
queryModes?: QueryModeInput[];
|
|
153
|
+
/** Max candidates passed to reranking */
|
|
154
|
+
candidateLimit?: number;
|
|
148
155
|
/** Enable explain output */
|
|
149
156
|
explain?: boolean;
|
|
150
157
|
/** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
|
|
@@ -308,6 +315,8 @@ export interface AskMeta {
|
|
|
308
315
|
expanded: boolean;
|
|
309
316
|
reranked: boolean;
|
|
310
317
|
vectorsUsed: boolean;
|
|
318
|
+
intent?: string;
|
|
319
|
+
candidateLimit?: number;
|
|
311
320
|
answerGenerated?: boolean;
|
|
312
321
|
totalResults?: number;
|
|
313
322
|
answerContext?: AnswerContextExplain;
|
package/src/pipeline/vsearch.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { SearchOptions, SearchResult, SearchResults } from "./types";
|
|
|
14
14
|
import { err, ok } from "../store/types";
|
|
15
15
|
import { createChunkLookup } from "./chunk-lookup";
|
|
16
16
|
import { formatQueryForEmbedding } from "./contextual";
|
|
17
|
+
import { selectBestChunkForSteering } from "./intent";
|
|
17
18
|
import { detectQueryLanguage } from "./query-language";
|
|
18
19
|
import {
|
|
19
20
|
resolveRecencyTimestamp,
|
|
@@ -146,7 +147,18 @@ export async function searchVectorWithEmbedding(
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// Get chunk via O(1) lookup
|
|
149
|
-
const
|
|
150
|
+
const rawChunk = getChunk(vec.mirrorHash, vec.seq);
|
|
151
|
+
const chunk = options.intent
|
|
152
|
+
? (selectBestChunkForSteering(
|
|
153
|
+
chunksMap.get(vec.mirrorHash) ?? [],
|
|
154
|
+
query,
|
|
155
|
+
options.intent,
|
|
156
|
+
{
|
|
157
|
+
preferredSeq: rawChunk?.seq ?? vec.seq,
|
|
158
|
+
intentWeight: 0.3,
|
|
159
|
+
}
|
|
160
|
+
) ?? rawChunk)
|
|
161
|
+
: rawChunk;
|
|
150
162
|
if (!chunk) {
|
|
151
163
|
continue;
|
|
152
164
|
}
|
|
@@ -288,6 +300,7 @@ export async function searchVectorWithEmbedding(
|
|
|
288
300
|
mode: "vector",
|
|
289
301
|
vectorsUsed: true,
|
|
290
302
|
totalResults: finalResults.length,
|
|
303
|
+
intent: options.intent,
|
|
291
304
|
collection: options.collection,
|
|
292
305
|
lang: options.lang,
|
|
293
306
|
since: temporalRange.since,
|
|
@@ -9,6 +9,8 @@ export interface QueryModeEntry {
|
|
|
9
9
|
|
|
10
10
|
export interface RetrievalFiltersState {
|
|
11
11
|
collection: string;
|
|
12
|
+
intent: string;
|
|
13
|
+
candidateLimit: string;
|
|
12
14
|
since: string;
|
|
13
15
|
until: string;
|
|
14
16
|
category: string;
|
|
@@ -121,6 +123,9 @@ export function parseFiltersFromSearch(
|
|
|
121
123
|
|
|
122
124
|
return {
|
|
123
125
|
collection: params.get("collection") ?? defaults.collection ?? "",
|
|
126
|
+
intent: params.get("intent") ?? defaults.intent ?? "",
|
|
127
|
+
candidateLimit:
|
|
128
|
+
params.get("candidateLimit") ?? defaults.candidateLimit ?? "",
|
|
124
129
|
since: params.get("since") ?? defaults.since ?? "",
|
|
125
130
|
until: params.get("until") ?? defaults.until ?? "",
|
|
126
131
|
category: params.get("category") ?? defaults.category ?? "",
|
|
@@ -145,6 +150,8 @@ export function applyFiltersToUrl(
|
|
|
145
150
|
};
|
|
146
151
|
|
|
147
152
|
setOrDelete("collection", filters.collection);
|
|
153
|
+
setOrDelete("intent", filters.intent);
|
|
154
|
+
setOrDelete("candidateLimit", filters.candidateLimit);
|
|
148
155
|
setOrDelete("since", filters.since);
|
|
149
156
|
setOrDelete("until", filters.until);
|
|
150
157
|
setOrDelete("category", filters.category);
|
|
@@ -164,6 +164,8 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
164
164
|
|
|
165
165
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
166
166
|
const [selectedCollection, setSelectedCollection] = useState("");
|
|
167
|
+
const [intent, setIntent] = useState("");
|
|
168
|
+
const [candidateLimit, setCandidateLimit] = useState("");
|
|
167
169
|
const [since, setSince] = useState("");
|
|
168
170
|
const [until, setUntil] = useState("");
|
|
169
171
|
const [category, setCategory] = useState("");
|
|
@@ -242,6 +244,12 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
242
244
|
if (selectedCollection) {
|
|
243
245
|
requestBody.collection = selectedCollection;
|
|
244
246
|
}
|
|
247
|
+
if (intent.trim()) {
|
|
248
|
+
requestBody.intent = intent.trim();
|
|
249
|
+
}
|
|
250
|
+
if (candidateLimit.trim()) {
|
|
251
|
+
requestBody.candidateLimit = Number(candidateLimit);
|
|
252
|
+
}
|
|
245
253
|
if (since) {
|
|
246
254
|
requestBody.since = since;
|
|
247
255
|
}
|
|
@@ -295,7 +303,9 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
295
303
|
},
|
|
296
304
|
[
|
|
297
305
|
author,
|
|
306
|
+
candidateLimit,
|
|
298
307
|
category,
|
|
308
|
+
intent,
|
|
299
309
|
query,
|
|
300
310
|
selectedCollection,
|
|
301
311
|
since,
|
|
@@ -315,6 +325,8 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
315
325
|
|
|
316
326
|
const clearFilters = () => {
|
|
317
327
|
setSelectedCollection("");
|
|
328
|
+
setIntent("");
|
|
329
|
+
setCandidateLimit("");
|
|
318
330
|
setSince("");
|
|
319
331
|
setUntil("");
|
|
320
332
|
setCategory("");
|
|
@@ -327,6 +339,8 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
327
339
|
|
|
328
340
|
const activeFilterPills = [
|
|
329
341
|
selectedCollection ? `collection:${selectedCollection}` : null,
|
|
342
|
+
intent.trim() ? `intent:${intent.trim()}` : null,
|
|
343
|
+
candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
|
|
330
344
|
since ? `since:${since}` : null,
|
|
331
345
|
until ? `until:${until}` : null,
|
|
332
346
|
category.trim() ? `category:${category.trim()}` : null,
|
|
@@ -441,6 +455,17 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
441
455
|
/>
|
|
442
456
|
</div>
|
|
443
457
|
|
|
458
|
+
<div className="md:col-span-2">
|
|
459
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
460
|
+
Intent
|
|
461
|
+
</p>
|
|
462
|
+
<Input
|
|
463
|
+
onChange={(e) => setIntent(e.target.value)}
|
|
464
|
+
placeholder="Disambiguate ambiguous questions without searching on this text"
|
|
465
|
+
value={intent}
|
|
466
|
+
/>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
444
469
|
<div>
|
|
445
470
|
<p className="mb-1 text-muted-foreground text-xs">
|
|
446
471
|
Category
|
|
@@ -475,6 +500,20 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
475
500
|
</div>
|
|
476
501
|
</div>
|
|
477
502
|
|
|
503
|
+
<div>
|
|
504
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
505
|
+
Candidate limit
|
|
506
|
+
</p>
|
|
507
|
+
<Input
|
|
508
|
+
inputMode="numeric"
|
|
509
|
+
min="1"
|
|
510
|
+
onChange={(e) => setCandidateLimit(e.target.value)}
|
|
511
|
+
placeholder="20"
|
|
512
|
+
type="number"
|
|
513
|
+
value={candidateLimit}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
|
|
478
517
|
<div className="md:col-span-2">
|
|
479
518
|
<p className="mb-1 text-muted-foreground text-xs">
|
|
480
519
|
Tags (comma separated)
|
|
@@ -153,6 +153,8 @@ export default function Search({ navigate }: PageProps) {
|
|
|
153
153
|
const [showAdvanced, setShowAdvanced] = useState(
|
|
154
154
|
Boolean(
|
|
155
155
|
initialFilters.collection ||
|
|
156
|
+
initialFilters.intent ||
|
|
157
|
+
initialFilters.candidateLimit ||
|
|
156
158
|
initialFilters.since ||
|
|
157
159
|
initialFilters.until ||
|
|
158
160
|
initialFilters.category ||
|
|
@@ -166,6 +168,10 @@ export default function Search({ navigate }: PageProps) {
|
|
|
166
168
|
const [selectedCollection, setSelectedCollection] = useState(
|
|
167
169
|
initialFilters.collection
|
|
168
170
|
);
|
|
171
|
+
const [intent, setIntent] = useState(initialFilters.intent);
|
|
172
|
+
const [candidateLimit, setCandidateLimit] = useState(
|
|
173
|
+
initialFilters.candidateLimit
|
|
174
|
+
);
|
|
169
175
|
const [since, setSince] = useState(initialFilters.since);
|
|
170
176
|
const [until, setUntil] = useState(initialFilters.until);
|
|
171
177
|
const [category, setCategory] = useState(initialFilters.category);
|
|
@@ -179,13 +185,17 @@ export default function Search({ navigate }: PageProps) {
|
|
|
179
185
|
const [showMobileTags, setShowMobileTags] = useState(false);
|
|
180
186
|
|
|
181
187
|
const hybridAvailable = capabilities?.hybrid ?? false;
|
|
182
|
-
const forceHybridForModes =
|
|
188
|
+
const forceHybridForModes =
|
|
189
|
+
thoroughness === "fast" &&
|
|
190
|
+
(queryModes.length > 0 || intent.trim().length > 0);
|
|
183
191
|
|
|
184
192
|
// Sync URL as filter state changes.
|
|
185
193
|
useEffect(() => {
|
|
186
194
|
const url = new URL(window.location.href);
|
|
187
195
|
applyFiltersToUrl(url, {
|
|
188
196
|
collection: selectedCollection,
|
|
197
|
+
intent,
|
|
198
|
+
candidateLimit,
|
|
189
199
|
since,
|
|
190
200
|
until,
|
|
191
201
|
category,
|
|
@@ -198,7 +208,9 @@ export default function Search({ navigate }: PageProps) {
|
|
|
198
208
|
}, [
|
|
199
209
|
activeTags,
|
|
200
210
|
author,
|
|
211
|
+
candidateLimit,
|
|
201
212
|
category,
|
|
213
|
+
intent,
|
|
202
214
|
queryModes,
|
|
203
215
|
selectedCollection,
|
|
204
216
|
since,
|
|
@@ -283,7 +295,10 @@ export default function Search({ navigate }: PageProps) {
|
|
|
283
295
|
setError(null);
|
|
284
296
|
setSearched(true);
|
|
285
297
|
|
|
286
|
-
const useBm25 =
|
|
298
|
+
const useBm25 =
|
|
299
|
+
thoroughness === "fast" &&
|
|
300
|
+
queryModes.length === 0 &&
|
|
301
|
+
intent.trim().length === 0;
|
|
287
302
|
const endpoint = useBm25 ? "/api/search" : "/api/query";
|
|
288
303
|
const body: Record<string, unknown> = {
|
|
289
304
|
query,
|
|
@@ -293,6 +308,12 @@ export default function Search({ navigate }: PageProps) {
|
|
|
293
308
|
if (selectedCollection) {
|
|
294
309
|
body.collection = selectedCollection;
|
|
295
310
|
}
|
|
311
|
+
if (intent.trim()) {
|
|
312
|
+
body.intent = intent.trim();
|
|
313
|
+
}
|
|
314
|
+
if (candidateLimit.trim()) {
|
|
315
|
+
body.candidateLimit = Number(candidateLimit);
|
|
316
|
+
}
|
|
296
317
|
if (since) {
|
|
297
318
|
body.since = since;
|
|
298
319
|
}
|
|
@@ -350,7 +371,9 @@ export default function Search({ navigate }: PageProps) {
|
|
|
350
371
|
[
|
|
351
372
|
activeTags,
|
|
352
373
|
author,
|
|
374
|
+
candidateLimit,
|
|
353
375
|
category,
|
|
376
|
+
intent,
|
|
354
377
|
query,
|
|
355
378
|
queryModes,
|
|
356
379
|
selectedCollection,
|
|
@@ -370,7 +393,9 @@ export default function Search({ navigate }: PageProps) {
|
|
|
370
393
|
}, [
|
|
371
394
|
activeTags,
|
|
372
395
|
author,
|
|
396
|
+
candidateLimit,
|
|
373
397
|
category,
|
|
398
|
+
intent,
|
|
374
399
|
queryModes,
|
|
375
400
|
selectedCollection,
|
|
376
401
|
since,
|
|
@@ -387,6 +412,8 @@ export default function Search({ navigate }: PageProps) {
|
|
|
387
412
|
|
|
388
413
|
const activeFilterPills = [
|
|
389
414
|
selectedCollection ? `collection:${selectedCollection}` : null,
|
|
415
|
+
intent.trim() ? `intent:${intent.trim()}` : null,
|
|
416
|
+
candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
|
|
390
417
|
since ? `since:${since}` : null,
|
|
391
418
|
until ? `until:${until}` : null,
|
|
392
419
|
category.trim() ? `category:${category.trim()}` : null,
|
|
@@ -399,6 +426,8 @@ export default function Search({ navigate }: PageProps) {
|
|
|
399
426
|
|
|
400
427
|
const clearAdvancedFilters = () => {
|
|
401
428
|
setSelectedCollection("");
|
|
429
|
+
setIntent("");
|
|
430
|
+
setCandidateLimit("");
|
|
402
431
|
setSince("");
|
|
403
432
|
setUntil("");
|
|
404
433
|
setCategory("");
|
|
@@ -551,6 +580,17 @@ export default function Search({ navigate }: PageProps) {
|
|
|
551
580
|
/>
|
|
552
581
|
</div>
|
|
553
582
|
|
|
583
|
+
<div className="md:col-span-2">
|
|
584
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
585
|
+
Intent
|
|
586
|
+
</p>
|
|
587
|
+
<Input
|
|
588
|
+
onChange={(e) => setIntent(e.target.value)}
|
|
589
|
+
placeholder="Disambiguate ambiguous queries without searching on this text"
|
|
590
|
+
value={intent}
|
|
591
|
+
/>
|
|
592
|
+
</div>
|
|
593
|
+
|
|
554
594
|
<div>
|
|
555
595
|
<p className="mb-1 text-muted-foreground text-xs">
|
|
556
596
|
Category
|
|
@@ -584,6 +624,20 @@ export default function Search({ navigate }: PageProps) {
|
|
|
584
624
|
/>
|
|
585
625
|
</div>
|
|
586
626
|
</div>
|
|
627
|
+
|
|
628
|
+
<div>
|
|
629
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
630
|
+
Candidate limit
|
|
631
|
+
</p>
|
|
632
|
+
<Input
|
|
633
|
+
inputMode="numeric"
|
|
634
|
+
min="1"
|
|
635
|
+
onChange={(e) => setCandidateLimit(e.target.value)}
|
|
636
|
+
placeholder="20"
|
|
637
|
+
type="number"
|
|
638
|
+
value={candidateLimit}
|
|
639
|
+
/>
|
|
640
|
+
</div>
|
|
587
641
|
</div>
|
|
588
642
|
|
|
589
643
|
<div className="flex flex-wrap items-center gap-2">
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface SearchRequestBody {
|
|
|
66
66
|
limit?: number;
|
|
67
67
|
minScore?: number;
|
|
68
68
|
collection?: string;
|
|
69
|
+
intent?: string;
|
|
69
70
|
since?: string;
|
|
70
71
|
until?: string;
|
|
71
72
|
/** Comma-separated category filters */
|
|
@@ -83,6 +84,8 @@ export interface QueryRequestBody {
|
|
|
83
84
|
minScore?: number;
|
|
84
85
|
collection?: string;
|
|
85
86
|
lang?: string;
|
|
87
|
+
intent?: string;
|
|
88
|
+
candidateLimit?: number;
|
|
86
89
|
since?: string;
|
|
87
90
|
until?: string;
|
|
88
91
|
/** Comma-separated category filters */
|
|
@@ -102,6 +105,8 @@ export interface AskRequestBody {
|
|
|
102
105
|
limit?: number;
|
|
103
106
|
collection?: string;
|
|
104
107
|
lang?: string;
|
|
108
|
+
intent?: string;
|
|
109
|
+
candidateLimit?: number;
|
|
105
110
|
since?: string;
|
|
106
111
|
until?: string;
|
|
107
112
|
/** Comma-separated category filters */
|
|
@@ -1093,6 +1098,9 @@ export async function handleSearch(
|
|
|
1093
1098
|
if (body.until !== undefined && typeof body.until !== "string") {
|
|
1094
1099
|
return errorResponse("VALIDATION", "until must be a string");
|
|
1095
1100
|
}
|
|
1101
|
+
if (body.intent !== undefined && typeof body.intent !== "string") {
|
|
1102
|
+
return errorResponse("VALIDATION", "intent must be a string");
|
|
1103
|
+
}
|
|
1096
1104
|
if (body.category !== undefined && typeof body.category !== "string") {
|
|
1097
1105
|
return errorResponse(
|
|
1098
1106
|
"VALIDATION",
|
|
@@ -1139,6 +1147,7 @@ export async function handleSearch(
|
|
|
1139
1147
|
limit: Math.min(body.limit || 10, 50),
|
|
1140
1148
|
minScore: body.minScore,
|
|
1141
1149
|
collection: body.collection,
|
|
1150
|
+
intent: body.intent?.trim() || undefined,
|
|
1142
1151
|
tagsAll,
|
|
1143
1152
|
tagsAny,
|
|
1144
1153
|
since: body.since,
|
|
@@ -1208,6 +1217,18 @@ export async function handleQuery(
|
|
|
1208
1217
|
if (body.until !== undefined && typeof body.until !== "string") {
|
|
1209
1218
|
return errorResponse("VALIDATION", "until must be a string");
|
|
1210
1219
|
}
|
|
1220
|
+
if (body.intent !== undefined && typeof body.intent !== "string") {
|
|
1221
|
+
return errorResponse("VALIDATION", "intent must be a string");
|
|
1222
|
+
}
|
|
1223
|
+
if (
|
|
1224
|
+
body.candidateLimit !== undefined &&
|
|
1225
|
+
(typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
|
|
1226
|
+
) {
|
|
1227
|
+
return errorResponse(
|
|
1228
|
+
"VALIDATION",
|
|
1229
|
+
"candidateLimit must be a positive integer"
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1211
1232
|
if (body.category !== undefined && typeof body.category !== "string") {
|
|
1212
1233
|
return errorResponse(
|
|
1213
1234
|
"VALIDATION",
|
|
@@ -1314,6 +1335,11 @@ export async function handleQuery(
|
|
|
1314
1335
|
minScore: body.minScore,
|
|
1315
1336
|
collection: body.collection,
|
|
1316
1337
|
lang: body.lang,
|
|
1338
|
+
intent: body.intent?.trim() || undefined,
|
|
1339
|
+
candidateLimit:
|
|
1340
|
+
body.candidateLimit !== undefined
|
|
1341
|
+
? Math.min(body.candidateLimit, 100)
|
|
1342
|
+
: undefined,
|
|
1317
1343
|
queryModes,
|
|
1318
1344
|
noExpand: body.noExpand,
|
|
1319
1345
|
noRerank: body.noRerank,
|
|
@@ -1377,6 +1403,18 @@ export async function handleAsk(
|
|
|
1377
1403
|
if (body.until !== undefined && typeof body.until !== "string") {
|
|
1378
1404
|
return errorResponse("VALIDATION", "until must be a string");
|
|
1379
1405
|
}
|
|
1406
|
+
if (body.intent !== undefined && typeof body.intent !== "string") {
|
|
1407
|
+
return errorResponse("VALIDATION", "intent must be a string");
|
|
1408
|
+
}
|
|
1409
|
+
if (
|
|
1410
|
+
body.candidateLimit !== undefined &&
|
|
1411
|
+
(typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
|
|
1412
|
+
) {
|
|
1413
|
+
return errorResponse(
|
|
1414
|
+
"VALIDATION",
|
|
1415
|
+
"candidateLimit must be a positive integer"
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1380
1418
|
if (body.category !== undefined && typeof body.category !== "string") {
|
|
1381
1419
|
return errorResponse(
|
|
1382
1420
|
"VALIDATION",
|
|
@@ -1431,8 +1469,13 @@ export async function handleAsk(
|
|
|
1431
1469
|
limit,
|
|
1432
1470
|
collection: body.collection,
|
|
1433
1471
|
lang: body.lang,
|
|
1472
|
+
intent: body.intent?.trim() || undefined,
|
|
1434
1473
|
noExpand: body.noExpand,
|
|
1435
1474
|
noRerank: body.noRerank,
|
|
1475
|
+
candidateLimit:
|
|
1476
|
+
body.candidateLimit !== undefined
|
|
1477
|
+
? Math.min(body.candidateLimit, 100)
|
|
1478
|
+
: undefined,
|
|
1436
1479
|
tagsAll,
|
|
1437
1480
|
tagsAny,
|
|
1438
1481
|
since: body.since,
|
|
@@ -1483,6 +1526,8 @@ export async function handleAsk(
|
|
|
1483
1526
|
expanded: searchResult.value.meta.expanded ?? false,
|
|
1484
1527
|
reranked: searchResult.value.meta.reranked ?? false,
|
|
1485
1528
|
vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
|
|
1529
|
+
intent: searchResult.value.meta.intent,
|
|
1530
|
+
candidateLimit: searchResult.value.meta.candidateLimit,
|
|
1486
1531
|
answerGenerated,
|
|
1487
1532
|
totalResults: results.length,
|
|
1488
1533
|
answerContext,
|