@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 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.15
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -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
  };
@@ -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),
@@ -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
@@ -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
@@ -54,6 +54,8 @@ export interface GenParams {
54
54
  seed?: number;
55
55
  /** Max tokens to generate. Default: 256 */
56
56
  maxTokens?: number;
57
+ /** Optional context size override for the generation context */
58
+ contextSize?: number;
57
59
  /** Stop sequences */
58
60
  stop?: string[];
59
61
  }
@@ -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(),
@@ -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,
@@ -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,
@@ -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 = [EXPANSION_PROMPT_VERSION, modelUri, query, lang].join("\0");
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 = template.replace("{query}", query);
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(deps.genPort.modelUri, query, lang);
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);
@@ -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 = await checkBm25Strength(store, query, {
333
- collection: options.collection,
334
- lang: options.lang,
335
- tagsAll: options.tagsAll,
336
- tagsAny: options.tagsAny,
337
- since: temporalRange.since,
338
- until: temporalRange.until,
339
- categories: options.categories,
340
- author: options.author,
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: pipelineConfig.rerankCandidates,
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
- let snippet = chunk.text;
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: chunk.startLine,
698
- endLine: chunk.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
+ }
@@ -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
- bestChunkPerDoc: Map<string, BestChunkInfo>
96
+ toRerank: FusionCandidate[],
97
+ query: string,
98
+ intent: string | undefined
112
99
  ): Promise<{ texts: string[]; hashToIndex: Map<string, number> }> {
113
- const uniqueHashes = [...bestChunkPerDoc.keys()];
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 chunkTexts = new Map<string, string>();
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
- if (chunks && bestInfo) {
125
- const chunk = chunks.find((c) => c.seq === bestInfo.seq);
126
- const text = chunk?.text ?? "";
127
- chunkTexts.set(
128
- hash,
129
- text.length > MAX_CHUNK_CHARS
130
- ? `${text.slice(0, MAX_CHUNK_CHARS)}...`
131
- : text
132
- );
133
- } else {
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 bestChunkPerDoc = selectBestChunks(toRerank);
202
- const { texts, hashToIndex } = await fetchChunkTexts(store, bestChunkPerDoc);
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(query, texts);
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
- rerankResult.value.map((s) => [s.index, s.score])
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);
@@ -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 chunk = fts.mirrorHash
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,
@@ -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;
@@ -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 chunk = getChunk(vec.mirrorHash, vec.seq);
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 = thoroughness === "fast" && queryModes.length > 0;
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 = thoroughness === "fast" && queryModes.length === 0;
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">
@@ -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,