瀏覽代碼

feat: add intent parameter for query disambiguation

Add optional `intent` parameter that steers query expansion, reranking,
chunk selection, and snippet extraction without searching on its own.

When a query like "performance" is ambiguous (web-perf vs team health vs
fitness), intent provides background context that disambiguates results
across all pipeline stages:

- expandQuery: includes intent in LLM prompt ("Query intent: {intent}")
- rerank: prepends intent to rerank query for Qwen3-Reranker
- chunk selection: intent terms scored at 0.5x weight vs query terms
- snippet extraction: intent terms scored at 0.3x weight
- strong-signal bypass: disabled when intent provided

Available via CLI (--intent flag or intent: line in query documents),
MCP (intent field on query tool), and programmatic API.

Adapted from PR #180 (thanks @vyalamar).
Tobi Lutke 2 月之前
父節點
當前提交
ad38c1f698
共有 8 個文件被更改,包括 721 次插入43 次删除
  1. 37 2
      docs/SYNTAX.md
  2. 17 0
      skills/qmd/SKILL.md
  3. 5 4
      src/formatter.ts
  4. 5 2
      src/llm.ts
  5. 9 2
      src/mcp.ts
  6. 52 13
      src/qmd.ts
  7. 83 20
      src/store.ts
  8. 513 0
      test/intent.test.ts

+ 37 - 2
docs/SYNTAX.md

@@ -8,7 +8,8 @@ QMD queries are structured documents with typed sub-queries. Each line specifies
 query          = expand_query | query_document ;
 query          = expand_query | query_document ;
 expand_query   = text | explicit_expand ;
 expand_query   = text | explicit_expand ;
 explicit_expand= "expand:" text ;
 explicit_expand= "expand:" text ;
-query_document = { typed_line } ;
+query_document = [ intent_line ] { typed_line } ;
+intent_line    = "intent:" text newline ;
 typed_line     = type ":" text newline ;
 typed_line     = type ":" text newline ;
 type           = "lex" | "vec" | "hyde" ;
 type           = "lex" | "vec" | "hyde" ;
 text           = quoted_phrase | plain_text ;
 text           = quoted_phrase | plain_text ;
@@ -101,11 +102,28 @@ error handling best practices
 
 
 Both forms call the local query expansion model, which generates lex, vec, and hyde variations automatically.
 Both forms call the local query expansion model, which generates lex, vec, and hyde variations automatically.
 
 
+## Intent
+
+An optional `intent:` line provides background context to disambiguate ambiguous queries. It steers query expansion, reranking, and snippet extraction but does not search on its own.
+
+- At most one `intent:` line per query document
+- `intent:` cannot appear alone — at least one `lex:`, `vec:`, or `hyde:` line is required
+- Intent is also available via the `--intent` CLI flag or MCP `intent` parameter
+
+```
+intent: web page load times and Core Web Vitals
+lex: performance
+vec: how to improve performance
+```
+
+Without intent, "performance" is ambiguous (web-perf? team health? fitness?). With intent, the search pipeline preferentially selects and ranks web-performance content.
+
 ## Constraints
 ## Constraints
 
 
 - Top-level query must be either a standalone expand query or a multi-line document
 - Top-level query must be either a standalone expand query or a multi-line document
-- Query documents allow only `lex`, `vec`, and `hyde` typed lines (no `expand:` inside)
+- Query documents allow only `lex`, `vec`, `hyde`, and `intent` typed lines (no `expand:` inside)
 - `lex` syntax (`-term`, `"phrase"`) only works in lex queries
 - `lex` syntax (`-term`, `"phrase"`) only works in lex queries
+- At most one `intent:` line per query document; cannot appear alone
 - Empty lines are ignored
 - Empty lines are ignored
 - Leading/trailing whitespace is trimmed
 - Leading/trailing whitespace is trimmed
 
 
@@ -132,6 +150,17 @@ Or structured format:
 }
 }
 ```
 ```
 
 
+With intent:
+
+```json
+{
+  "searches": [
+    { "type": "lex", "query": "performance" }
+  ],
+  "intent": "web page load times and Core Web Vitals"
+}
+```
+
 ## CLI
 ## CLI
 
 
 ```bash
 ```bash
@@ -143,4 +172,10 @@ qmd query $'lex: auth token\nvec: how does authentication work'
 
 
 # Structured
 # Structured
 qmd query $'lex: keywords\nvec: question\nhyde: hypothetical answer...'
 qmd query $'lex: keywords\nvec: question\nhyde: hypothetical answer...'
+
+# With intent (inline)
+qmd query $'intent: web performance and latency\nlex: performance\nvec: how to improve performance'
+
+# With intent (flag)
+qmd query --intent "web performance and latency" "performance"
 ```
 ```

+ 17 - 0
skills/qmd/SKILL.md

@@ -60,6 +60,21 @@ Local search engine for markdown content.
 - Lets the local LLM generate lex/vec/hyde variations
 - Lets the local LLM generate lex/vec/hyde variations
 - Do not mix `expand:` with other typed lines — it's either a standalone expand query or a full query document
 - Do not mix `expand:` with other typed lines — it's either a standalone expand query or a full query document
 
 
+### Intent (Disambiguation)
+
+When a query term is ambiguous, add `intent` to steer results:
+
+```json
+{
+  "searches": [
+    { "type": "lex", "query": "performance" }
+  ],
+  "intent": "web page load times and Core Web Vitals"
+}
+```
+
+Intent affects expansion, reranking, chunk selection, and snippet extraction. It does not search on its own — it's a steering signal that disambiguates queries like "performance" (web-perf vs team health vs fitness).
+
 ### Combining Types
 ### Combining Types
 
 
 | Goal | Approach |
 | Goal | Approach |
@@ -68,6 +83,7 @@ Local search engine for markdown content.
 | Don't know vocabulary | Use a single-line query (implicit `expand:`) or `vec` |
 | Don't know vocabulary | Use a single-line query (implicit `expand:`) or `vec` |
 | Best recall | `lex` + `vec` |
 | Best recall | `lex` + `vec` |
 | Complex topic | `lex` + `vec` + `hyde` |
 | Complex topic | `lex` + `vec` + `hyde` |
+| Ambiguous query | Add `intent` to any combination above |
 
 
 First query gets 2x weight in fusion — put your best guess first.
 First query gets 2x weight in fusion — put your best guess first.
 
 
@@ -104,6 +120,7 @@ Omit to search all collections.
 qmd query "question"              # Auto-expand + rerank
 qmd query "question"              # Auto-expand + rerank
 qmd query $'lex: X\nvec: Y'       # Structured
 qmd query $'lex: X\nvec: Y'       # Structured
 qmd query $'expand: question'     # Explicit expand
 qmd query $'expand: question'     # Explicit expand
+qmd query --json --explain "q"    # Show score traces (RRF + rerank blend)
 qmd search "keywords"             # BM25 only (no LLM)
 qmd search "keywords"             # BM25 only (no LLM)
 qmd get "#abc123"                 # By docid
 qmd get "#abc123"                 # By docid
 qmd multi-get "journals/2026-*.md" -l 40  # Batch pull snippets by glob
 qmd multi-get "journals/2026-*.md" -l 40  # Batch pull snippets by glob

+ 5 - 4
src/formatter.ts

@@ -40,6 +40,7 @@ export type FormatOptions = {
   query?: string;       // Query for snippet extraction and highlighting
   query?: string;       // Query for snippet extraction and highlighting
   useColor?: boolean;   // Enable terminal colors (default: false for non-CLI)
   useColor?: boolean;   // Enable terminal colors (default: false for non-CLI)
   lineNumbers?: boolean;// Add line numbers to output
   lineNumbers?: boolean;// Add line numbers to output
+  intent?: string;      // Domain intent for snippet extraction disambiguation
 };
 };
 
 
 // =============================================================================
 // =============================================================================
@@ -101,7 +102,7 @@ export function searchResultsToJson(
   const output = results.map(row => {
   const output = results.map(row => {
     const bodyStr = row.body || "";
     const bodyStr = row.body || "";
     let body = opts.full ? bodyStr : undefined;
     let body = opts.full ? bodyStr : undefined;
-    let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos).snippet : undefined;
+    let snippet = !opts.full ? extractSnippet(bodyStr, query, 300, row.chunkPos, undefined, opts.intent).snippet : undefined;
 
 
     if (opts.lineNumbers) {
     if (opts.lineNumbers) {
       if (body) body = addLineNumbers(body);
       if (body) body = addLineNumbers(body);
@@ -132,7 +133,7 @@ export function searchResultsToCsv(
   const header = "docid,score,file,title,context,line,snippet";
   const header = "docid,score,file,title,context,line,snippet";
   const rows = results.map(row => {
   const rows = results.map(row => {
     const bodyStr = row.body || "";
     const bodyStr = row.body || "";
-    const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos);
+    const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent);
     let content = opts.full ? bodyStr : snippet;
     let content = opts.full ? bodyStr : snippet;
     if (opts.lineNumbers && content) {
     if (opts.lineNumbers && content) {
       content = addLineNumbers(content);
       content = addLineNumbers(content);
@@ -175,7 +176,7 @@ export function searchResultsToMarkdown(
     if (opts.full) {
     if (opts.full) {
       content = bodyStr;
       content = bodyStr;
     } else {
     } else {
-      content = extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
+      content = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet;
     }
     }
     if (opts.lineNumbers) {
     if (opts.lineNumbers) {
       content = addLineNumbers(content);
       content = addLineNumbers(content);
@@ -196,7 +197,7 @@ export function searchResultsToXml(
   const items = results.map(row => {
   const items = results.map(row => {
     const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
     const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
     const bodyStr = row.body || "";
     const bodyStr = row.body || "";
-    let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos).snippet;
+    let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet;
     if (opts.lineNumbers) {
     if (opts.lineNumbers) {
       content = addLineNumbers(content);
       content = addLineNumbers(content);
     }
     }

+ 5 - 2
src/llm.ts

@@ -970,7 +970,7 @@ export class LlamaCpp implements LLM {
   // High-level abstractions
   // High-level abstractions
   // ==========================================================================
   // ==========================================================================
 
 
-  async expandQuery(query: string, options: { context?: string, includeLexical?: boolean } = {}): Promise<Queryable[]> {
+  async expandQuery(query: string, options: { context?: string, includeLexical?: boolean, intent?: string } = {}): Promise<Queryable[]> {
     // Ping activity at start to keep models alive during this operation
     // Ping activity at start to keep models alive during this operation
     this.touchActivity();
     this.touchActivity();
 
 
@@ -989,7 +989,10 @@ export class LlamaCpp implements LLM {
       `
       `
     });
     });
 
 
-    const prompt = `/no_think Expand this search query: ${query}`;
+    const intent = options.intent;
+    const prompt = intent
+      ? `/no_think Expand this search query: ${query}\nQuery intent: ${intent}`
+      : `/no_think Expand this search query: ${query}`;
 
 
     // Create a bounded context for expansion to prevent large default VRAM allocations.
     // Create a bounded context for expansion to prevent large default VRAM allocations.
     const genContext = await this.generateModel!.createContext({
     const genContext = await this.generateModel!.createContext({

+ 9 - 2
src/mcp.ts

@@ -126,10 +126,13 @@ function buildInstructions(store: Store): string {
   lines.push("  - type:'vec' — semantic vector search (meaning-based)");
   lines.push("  - type:'vec' — semantic vector search (meaning-based)");
   lines.push("  - type:'hyde' — hypothetical document (write what the answer looks like)");
   lines.push("  - type:'hyde' — hypothetical document (write what the answer looks like)");
   lines.push("");
   lines.push("");
+  lines.push("  Always provide `intent` on every search call to disambiguate and improve snippets.");
+  lines.push("");
   lines.push("Examples:");
   lines.push("Examples:");
   lines.push("  Quick keyword lookup: [{type:'lex', query:'error handling'}]");
   lines.push("  Quick keyword lookup: [{type:'lex', query:'error handling'}]");
   lines.push("  Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]");
   lines.push("  Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]");
   lines.push("  Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]");
   lines.push("  Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]");
+  lines.push("  With intent: searches=[{type:'lex', query:'performance'}], intent='web page load times'");
 
 
   // --- Retrieval workflow ---
   // --- Retrieval workflow ---
   lines.push("");
   lines.push("");
@@ -312,9 +315,12 @@ Intent-aware lex (C++ performance, not sports):
           "Maximum candidates to rerank (default: 40, lower = faster but may miss results)"
           "Maximum candidates to rerank (default: 40, lower = faster but may miss results)"
         ),
         ),
         collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
         collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
+        intent: z.string().optional().describe(
+          "Background context to disambiguate the query. Example: query='performance', intent='web page load times and Core Web Vitals'. Does not search on its own."
+        ),
       },
       },
     },
     },
-    async ({ searches, limit, minScore, candidateLimit, collections }) => {
+    async ({ searches, limit, minScore, candidateLimit, collections, intent }) => {
       // Map to internal format
       // Map to internal format
       const subSearches: StructuredSubSearch[] = searches.map(s => ({
       const subSearches: StructuredSubSearch[] = searches.map(s => ({
         type: s.type,
         type: s.type,
@@ -329,6 +335,7 @@ Intent-aware lex (C++ performance, not sports):
         limit,
         limit,
         minScore,
         minScore,
         candidateLimit,
         candidateLimit,
+        intent,
       });
       });
 
 
       // Use first lex or vec query for snippet extraction
       // Use first lex or vec query for snippet extraction
@@ -337,7 +344,7 @@ Intent-aware lex (C++ performance, not sports):
         || searches[0]?.query || "";
         || searches[0]?.query || "";
 
 
       const filtered: SearchResultItem[] = results.map(r => {
       const filtered: SearchResultItem[] = results.map(r => {
-        const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
+        const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300, undefined, undefined, intent);
         return {
         return {
           docid: `#${r.docid}`,
           docid: `#${r.docid}`,
           file: r.displayPath,
           file: r.displayPath,

+ 52 - 13
src/qmd.ts

@@ -1771,6 +1771,7 @@ type OutputOptions = {
   explain?: boolean;     // Include retrieval score traces (query only)
   explain?: boolean;     // Include retrieval score traces (query only)
   context?: string;      // Optional context for query expansion
   context?: string;      // Optional context for query expansion
   candidateLimit?: number;  // Max candidates to rerank (default: 40)
   candidateLimit?: number;  // Max candidates to rerank (default: 40)
+  intent?: string;       // Domain intent for disambiguation
 };
 };
 
 
 // Highlight query terms in text (skip short words < 3 chars)
 // Highlight query terms in text (skip short words < 3 chars)
@@ -1863,7 +1864,7 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
     const output = filtered.map(row => {
     const output = filtered.map(row => {
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
       let body = opts.full ? row.body : undefined;
       let body = opts.full ? row.body : undefined;
-      let snippet = !opts.full ? extractSnippet(row.body, query, 300, row.chunkPos).snippet : undefined;
+      let snippet = !opts.full ? extractSnippet(row.body, query, 300, row.chunkPos, undefined, opts.intent).snippet : undefined;
       if (opts.lineNumbers) {
       if (opts.lineNumbers) {
         if (body) body = addLineNumbers(body);
         if (body) body = addLineNumbers(body);
         if (snippet) snippet = addLineNumbers(snippet);
         if (snippet) snippet = addLineNumbers(snippet);
@@ -1891,7 +1892,7 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
     for (let i = 0; i < filtered.length; i++) {
     for (let i = 0; i < filtered.length; i++) {
       const row = filtered[i];
       const row = filtered[i];
       if (!row) continue;
       if (!row) continue;
-      const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
+      const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
 
 
       // Line 1: filepath with docid
       // Line 1: filepath with docid
@@ -1954,7 +1955,7 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
       if (!row) continue;
       if (!row) continue;
       const heading = row.title || row.displayPath;
       const heading = row.title || row.displayPath;
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
-      let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
+      let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent).snippet;
       if (opts.lineNumbers) {
       if (opts.lineNumbers) {
         content = addLineNumbers(content);
         content = addLineNumbers(content);
       }
       }
@@ -1967,7 +1968,7 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
       const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
       const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
       const contextAttr = row.context ? ` context="${row.context.replace(/"/g, '&quot;')}"` : "";
       const contextAttr = row.context ? ` context="${row.context.replace(/"/g, '&quot;')}"` : "";
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
       const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
-      let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos).snippet;
+      let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent).snippet;
       if (opts.lineNumbers) {
       if (opts.lineNumbers) {
         content = addLineNumbers(content);
         content = addLineNumbers(content);
       }
       }
@@ -1977,7 +1978,7 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
     // CSV format
     // CSV format
     console.log("docid,score,file,title,context,line,snippet");
     console.log("docid,score,file,title,context,line,snippet");
     for (const row of filtered) {
     for (const row of filtered) {
-      const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
+      const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
       let content = opts.full ? row.body : snippet;
       let content = opts.full ? row.body : snippet;
       if (opts.lineNumbers) {
       if (opts.lineNumbers) {
         content = addLineNumbers(content, line);
         content = addLineNumbers(content, line);
@@ -2036,7 +2037,12 @@ function filterByCollections<T extends { filepath?: string; file?: string }>(res
  *   "lex: CAP\nvec: consistency"     -> [{ type: 'lex', ... }, { type: 'vec', ... }]
  *   "lex: CAP\nvec: consistency"     -> [{ type: 'lex', ... }, { type: 'vec', ... }]
  *   "CAP\nconsistency"               -> throws (multiple plain lines)
  *   "CAP\nconsistency"               -> throws (multiple plain lines)
  */
  */
-function parseStructuredQuery(query: string): StructuredSubSearch[] | null {
+interface ParsedStructuredQuery {
+  searches: StructuredSubSearch[];
+  intent?: string;
+}
+
+function parseStructuredQuery(query: string): ParsedStructuredQuery | null {
   const rawLines = query.split('\n').map((line, idx) => ({
   const rawLines = query.split('\n').map((line, idx) => ({
     raw: line,
     raw: line,
     trimmed: line.trim(),
     trimmed: line.trim(),
@@ -2047,7 +2053,9 @@ function parseStructuredQuery(query: string): StructuredSubSearch[] | null {
 
 
   const prefixRe = /^(lex|vec|hyde):\s*/i;
   const prefixRe = /^(lex|vec|hyde):\s*/i;
   const expandRe = /^expand:\s*/i;
   const expandRe = /^expand:\s*/i;
+  const intentRe = /^intent:\s*/i;
   const typed: StructuredSubSearch[] = [];
   const typed: StructuredSubSearch[] = [];
+  let intent: string | undefined;
 
 
   for (const line of rawLines) {
   for (const line of rawLines) {
     if (expandRe.test(line.trimmed)) {
     if (expandRe.test(line.trimmed)) {
@@ -2061,6 +2069,19 @@ function parseStructuredQuery(query: string): StructuredSubSearch[] | null {
       return null; // treat as standalone expand query
       return null; // treat as standalone expand query
     }
     }
 
 
+    // Parse intent: lines
+    if (intentRe.test(line.trimmed)) {
+      if (intent !== undefined) {
+        throw new Error(`Line ${line.number}: only one intent: line is allowed per query document.`);
+      }
+      const text = line.trimmed.replace(intentRe, '').trim();
+      if (!text) {
+        throw new Error(`Line ${line.number}: intent: must include text.`);
+      }
+      intent = text;
+      continue;
+    }
+
     const match = line.trimmed.match(prefixRe);
     const match = line.trimmed.match(prefixRe);
     if (match) {
     if (match) {
       const type = match[1]!.toLowerCase() as 'lex' | 'vec' | 'hyde';
       const type = match[1]!.toLowerCase() as 'lex' | 'vec' | 'hyde';
@@ -2080,10 +2101,15 @@ function parseStructuredQuery(query: string): StructuredSubSearch[] | null {
       return null;
       return null;
     }
     }
 
 
-    throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde: prefix. Each line in a query document must start with one.`);
+    throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix. Each line in a query document must start with one.`);
+  }
+
+  // intent: alone is not a valid query — must have at least one search
+  if (intent && typed.length === 0) {
+    throw new Error('intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.');
   }
   }
 
 
-  return typed.length > 0 ? typed : null;
+  return typed.length > 0 ? { searches: typed, intent } : null;
 }
 }
 
 
 function search(query: string, opts: OutputOptions): void {
 function search(query: string, opts: OutputOptions): void {
@@ -2152,6 +2178,7 @@ async function vectorSearch(query: string, opts: OutputOptions, _model: string =
       collection: singleCollection,
       collection: singleCollection,
       limit: opts.all ? 500 : (opts.limit || 10),
       limit: opts.all ? 500 : (opts.limit || 10),
       minScore: opts.minScore || 0.3,
       minScore: opts.minScore || 0.3,
+      intent: opts.intent,
       hooks: {
       hooks: {
         onExpand: (original, expanded) => {
         onExpand: (original, expanded) => {
           logExpansionTree(original, expanded);
           logExpansionTree(original, expanded);
@@ -2197,17 +2224,23 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
 
 
   checkIndexHealth(store.db);
   checkIndexHealth(store.db);
 
 
-  // Check for structured query syntax (lex:/vec:/hyde: prefixes)
-  const structuredQueries = parseStructuredQuery(query);
+  // Check for structured query syntax (lex:/vec:/hyde:/intent: prefixes)
+  const parsed = parseStructuredQuery(query);
+  // Intent can come from --intent flag or from intent: line in query document
+  const intent = opts.intent || parsed?.intent;
 
 
   await withLLMSession(async () => {
   await withLLMSession(async () => {
     let results;
     let results;
 
 
-    if (structuredQueries) {
+    if (parsed) {
+      const structuredQueries = parsed.searches;
       // Structured search — user provided their own query expansions
       // Structured search — user provided their own query expansions
       const typeLabels = structuredQueries.map(s => s.type).join('+');
       const typeLabels = structuredQueries.map(s => s.type).join('+');
       process.stderr.write(`${c.dim}Structured search: ${structuredQueries.length} queries (${typeLabels})${c.reset}\n`);
       process.stderr.write(`${c.dim}Structured search: ${structuredQueries.length} queries (${typeLabels})${c.reset}\n`);
-      
+      if (intent) {
+        process.stderr.write(`${c.dim}├─ intent: ${intent}${c.reset}\n`);
+      }
+
       // Log each sub-query
       // Log each sub-query
       for (const s of structuredQueries) {
       for (const s of structuredQueries) {
         let preview = s.query.replace(/\n/g, ' ');
         let preview = s.query.replace(/\n/g, ' ');
@@ -2222,6 +2255,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
         minScore: opts.minScore || 0,
         minScore: opts.minScore || 0,
         candidateLimit: opts.candidateLimit,
         candidateLimit: opts.candidateLimit,
         explain: !!opts.explain,
         explain: !!opts.explain,
+        intent,
         hooks: {
         hooks: {
           onEmbedStart: (count) => {
           onEmbedStart: (count) => {
             process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
             process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
@@ -2247,6 +2281,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
         minScore: opts.minScore || 0,
         minScore: opts.minScore || 0,
         candidateLimit: opts.candidateLimit,
         candidateLimit: opts.candidateLimit,
         explain: !!opts.explain,
         explain: !!opts.explain,
+        intent,
         hooks: {
         hooks: {
           onStrongSignal: (score) => {
           onStrongSignal: (score) => {
             process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
             process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
@@ -2293,6 +2328,7 @@ async function querySearch(query: string, opts: OutputOptions, _embedModel: stri
     }
     }
 
 
     // Use first lex/vec query for output context, or original query
     // Use first lex/vec query for output context, or original query
+    const structuredQueries = parsed?.searches;
     const displayQuery = structuredQueries
     const displayQuery = structuredQueries
       ? (structuredQueries.find(s => s.type === 'lex')?.query || structuredQueries.find(s => s.type === 'vec')?.query || query)
       ? (structuredQueries.find(s => s.type === 'lex')?.query || structuredQueries.find(s => s.type === 'vec')?.query || query)
       : query;
       : query;
@@ -2354,6 +2390,7 @@ function parseCLI() {
       "line-numbers": { type: "boolean" },  // add line numbers to output
       "line-numbers": { type: "boolean" },  // add line numbers to output
       // Query options
       // Query options
       "candidate-limit": { type: "string", short: "C" },
       "candidate-limit": { type: "string", short: "C" },
+      intent: { type: "string" },
       // MCP HTTP transport options
       // MCP HTTP transport options
       http: { type: "boolean" },
       http: { type: "boolean" },
       daemon: { type: "boolean" },
       daemon: { type: "boolean" },
@@ -2393,6 +2430,7 @@ function parseCLI() {
     lineNumbers: !!values["line-numbers"],
     lineNumbers: !!values["line-numbers"],
     candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
     candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
     explain: !!values.explain,
     explain: !!values.explain,
+    intent: values.intent as string | undefined,
   };
   };
 
 
   return {
   return {
@@ -2457,7 +2495,8 @@ function showHelp(): void {
     `query          = expand_query | query_document ;`,
     `query          = expand_query | query_document ;`,
     `expand_query   = text | explicit_expand ;`,
     `expand_query   = text | explicit_expand ;`,
     `explicit_expand= "expand:" text ;`,
     `explicit_expand= "expand:" text ;`,
-    `query_document = { typed_line } ;`,
+    `query_document = [ intent_line ] { typed_line } ;`,
+    `intent_line    = "intent:" text newline ;`,
     `typed_line     = type ":" text newline ;`,
     `typed_line     = type ":" text newline ;`,
     `type           = "lex" | "vec" | "hyde" ;`,
     `type           = "lex" | "vec" | "hyde" ;`,
     `text           = quoted_phrase | plain_text ;`,
     `text           = quoted_phrase | plain_text ;`,

+ 83 - 20
src/store.ts

@@ -892,8 +892,8 @@ export function createStore(dbPath?: string): Store {
     searchVec: (query: string, model: string, limit?: number, collectionName?: string, session?: ILLMSession, precomputedEmbedding?: number[]) => searchVec(db, query, model, limit, collectionName, session, precomputedEmbedding),
     searchVec: (query: string, model: string, limit?: number, collectionName?: string, session?: ILLMSession, precomputedEmbedding?: number[]) => searchVec(db, query, model, limit, collectionName, session, precomputedEmbedding),
 
 
     // Query expansion & reranking
     // Query expansion & reranking
-    expandQuery: (query: string, model?: string) => expandQuery(query, model, db),
-    rerank: (query: string, documents: { file: string; text: string }[], model?: string) => rerank(query, documents, model, db),
+    expandQuery: (query: string, model?: string, intent?: string) => expandQuery(query, model, db, intent),
+    rerank: (query: string, documents: { file: string; text: string }[], model?: string, intent?: string) => rerank(query, documents, model, db, intent),
 
 
     // Document retrieval
     // Document retrieval
     findDocument: (filename: string, options?: { includeBody?: boolean }) => findDocument(db, filename, options),
     findDocument: (filename: string, options?: { includeBody?: boolean }) => findDocument(db, filename, options),
@@ -2346,9 +2346,9 @@ export function insertEmbedding(
 // Query expansion
 // Query expansion
 // =============================================================================
 // =============================================================================
 
 
-export async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db: Database): Promise<ExpandedQuery[]> {
+export async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db: Database, intent?: string): Promise<ExpandedQuery[]> {
   // Check cache first — stored as JSON preserving types
   // Check cache first — stored as JSON preserving types
-  const cacheKey = getCacheKey("expandQuery", { query, model });
+  const cacheKey = getCacheKey("expandQuery", { query, model, ...(intent && { intent }) });
   const cached = getCachedResult(db, cacheKey);
   const cached = getCachedResult(db, cacheKey);
   if (cached) {
   if (cached) {
     try {
     try {
@@ -2360,7 +2360,7 @@ export async function expandQuery(query: string, model: string = DEFAULT_QUERY_M
 
 
   const llm = getDefaultLlamaCpp();
   const llm = getDefaultLlamaCpp();
   // Note: LlamaCpp uses hardcoded model, model parameter is ignored
   // Note: LlamaCpp uses hardcoded model, model parameter is ignored
-  const results = await llm.expandQuery(query);
+  const results = await llm.expandQuery(query, { intent });
 
 
   // Map Queryable[] → ExpandedQuery[] (same shape, decoupled from llm.ts internals).
   // Map Queryable[] → ExpandedQuery[] (same shape, decoupled from llm.ts internals).
   // Filter out entries that duplicate the original query text.
   // Filter out entries that duplicate the original query text.
@@ -2379,7 +2379,10 @@ export async function expandQuery(query: string, model: string = DEFAULT_QUERY_M
 // Reranking
 // Reranking
 // =============================================================================
 // =============================================================================
 
 
-export async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db: Database): Promise<{ file: string; score: number }[]> {
+export async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db: Database, intent?: string): Promise<{ file: string; score: number }[]> {
+  // Prepend intent to rerank query so the reranker scores with domain context
+  const rerankQuery = intent ? `${intent}\n\n${query}` : query;
+
   const cachedResults: Map<string, number> = new Map();
   const cachedResults: Map<string, number> = new Map();
   const uncachedDocsByChunk: Map<string, RerankDocument> = new Map();
   const uncachedDocsByChunk: Map<string, RerankDocument> = new Map();
 
 
@@ -2389,7 +2392,7 @@ export async function rerank(query: string, documents: { file: string; text: str
   // File path is excluded from the new cache key because the reranker score
   // File path is excluded from the new cache key because the reranker score
   // depends on the chunk content, not where it came from.
   // depends on the chunk content, not where it came from.
   for (const doc of documents) {
   for (const doc of documents) {
-    const cacheKey = getCacheKey("rerank", { query, model, chunk: doc.text });
+    const cacheKey = getCacheKey("rerank", { query: rerankQuery, model, chunk: doc.text });
     const legacyCacheKey = getCacheKey("rerank", { query, file: doc.file, model, chunk: doc.text });
     const legacyCacheKey = getCacheKey("rerank", { query, file: doc.file, model, chunk: doc.text });
     const cached = getCachedResult(db, cacheKey) ?? getCachedResult(db, legacyCacheKey);
     const cached = getCachedResult(db, cacheKey) ?? getCachedResult(db, legacyCacheKey);
     if (cached !== null) {
     if (cached !== null) {
@@ -2403,13 +2406,13 @@ export async function rerank(query: string, documents: { file: string; text: str
   if (uncachedDocsByChunk.size > 0) {
   if (uncachedDocsByChunk.size > 0) {
     const llm = getDefaultLlamaCpp();
     const llm = getDefaultLlamaCpp();
     const uncachedDocs = [...uncachedDocsByChunk.values()];
     const uncachedDocs = [...uncachedDocsByChunk.values()];
-    const rerankResult = await llm.rerank(query, uncachedDocs, { model });
+    const rerankResult = await llm.rerank(rerankQuery, uncachedDocs, { model });
 
 
     // Cache results by chunk text so identical chunks across files are scored once.
     // Cache results by chunk text so identical chunks across files are scored once.
     const textByFile = new Map(uncachedDocs.map(d => [d.file, d.text]));
     const textByFile = new Map(uncachedDocs.map(d => [d.file, d.text]));
     for (const result of rerankResult.results) {
     for (const result of rerankResult.results) {
       const chunk = textByFile.get(result.file) || "";
       const chunk = textByFile.get(result.file) || "";
-      const cacheKey = getCacheKey("rerank", { query, model, chunk });
+      const cacheKey = getCacheKey("rerank", { query: rerankQuery, model, chunk });
       setCachedResult(db, cacheKey, result.score.toString());
       setCachedResult(db, cacheKey, result.score.toString());
       cachedResults.set(chunk, result.score);
       cachedResults.set(chunk, result.score);
     }
     }
@@ -2885,7 +2888,45 @@ export type SnippetResult = {
   snippetLines: number;   // Number of lines in snippet
   snippetLines: number;   // Number of lines in snippet
 };
 };
 
 
-export function extractSnippet(body: string, query: string, maxLen = 500, chunkPos?: number, chunkLen?: number): SnippetResult {
+/** Weight for intent terms relative to query terms (1.0) in snippet scoring */
+export const INTENT_WEIGHT_SNIPPET = 0.3;
+
+/** Weight for intent terms relative to query terms (1.0) in chunk selection */
+export const INTENT_WEIGHT_CHUNK = 0.5;
+
+// Common stop words filtered from intent strings before tokenization.
+// Seeded from finetune/reward.py KEY_TERM_STOPWORDS, extended with common
+// 2-3 char function words so the length threshold can drop to >1 and let
+// short domain terms (API, SQL, LLM, CPU, CDN, …) survive.
+const INTENT_STOP_WORDS = new Set([
+  // 2-char function words
+  "am", "an", "as", "at", "be", "by", "do", "he", "if",
+  "in", "is", "it", "me", "my", "no", "of", "on", "or", "so",
+  "to", "up", "us", "we",
+  // 3-char function words
+  "all", "and", "any", "are", "but", "can", "did", "for", "get",
+  "has", "her", "him", "his", "how", "its", "let", "may", "not",
+  "our", "out", "the", "too", "was", "who", "why", "you",
+  // 4+ char common words
+  "also", "does", "find", "from", "have", "into", "more", "need",
+  "show", "some", "tell", "that", "them", "this", "want", "what",
+  "when", "will", "with", "your",
+  // Search-context noise
+  "about", "looking", "notes", "search", "where", "which",
+]);
+
+/**
+ * Extract meaningful terms from an intent string, filtering stop words and punctuation.
+ * Uses Unicode-aware punctuation stripping so domain terms like "API" survive.
+ * Returns lowercase terms suitable for text matching.
+ */
+export function extractIntentTerms(intent: string): string[] {
+  return intent.toLowerCase().split(/\s+/)
+    .map(t => t.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
+    .filter(t => t.length > 1 && !INTENT_STOP_WORDS.has(t));
+}
+
+export function extractSnippet(body: string, query: string, maxLen = 500, chunkPos?: number, chunkLen?: number, intent?: string): SnippetResult {
   const totalLines = body.split('\n').length;
   const totalLines = body.split('\n').length;
   let searchBody = body;
   let searchBody = body;
   let lineOffset = 0;
   let lineOffset = 0;
@@ -2904,13 +2945,17 @@ export function extractSnippet(body: string, query: string, maxLen = 500, chunkP
 
 
   const lines = searchBody.split('\n');
   const lines = searchBody.split('\n');
   const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
   const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
+  const intentTerms = intent ? extractIntentTerms(intent) : [];
   let bestLine = 0, bestScore = -1;
   let bestLine = 0, bestScore = -1;
 
 
   for (let i = 0; i < lines.length; i++) {
   for (let i = 0; i < lines.length; i++) {
     const lineLower = (lines[i] ?? "").toLowerCase();
     const lineLower = (lines[i] ?? "").toLowerCase();
     let score = 0;
     let score = 0;
     for (const term of queryTerms) {
     for (const term of queryTerms) {
-      if (lineLower.includes(term)) score++;
+      if (lineLower.includes(term)) score += 1.0;
+    }
+    for (const term of intentTerms) {
+      if (lineLower.includes(term)) score += INTENT_WEIGHT_SNIPPET;
     }
     }
     if (score > bestScore) {
     if (score > bestScore) {
       bestScore = score;
       bestScore = score;
@@ -2926,7 +2971,7 @@ export function extractSnippet(body: string, query: string, maxLen = 500, chunkP
   // If we focused on a chunk window and it produced an empty/whitespace-only snippet,
   // If we focused on a chunk window and it produced an empty/whitespace-only snippet,
   // fall back to a full-document snippet so we always show something useful.
   // fall back to a full-document snippet so we always show something useful.
   if (chunkPos && chunkPos > 0 && snippetText.trim().length === 0) {
   if (chunkPos && chunkPos > 0 && snippetText.trim().length === 0) {
-    return extractSnippet(body, query, maxLen, undefined);
+    return extractSnippet(body, query, maxLen, undefined, undefined, intent);
   }
   }
 
 
   if (snippetText.length > maxLen) snippetText = snippetText.substring(0, maxLen - 3) + "...";
   if (snippetText.length > maxLen) snippetText = snippetText.substring(0, maxLen - 3) + "...";
@@ -2998,6 +3043,7 @@ export interface HybridQueryOptions {
   minScore?: number;        // default 0
   minScore?: number;        // default 0
   candidateLimit?: number;  // default RERANK_CANDIDATE_LIMIT
   candidateLimit?: number;  // default RERANK_CANDIDATE_LIMIT
   explain?: boolean;        // include backend/RRF/rerank score traces
   explain?: boolean;        // include backend/RRF/rerank score traces
+  intent?: string;          // domain intent hint for disambiguation
   hooks?: SearchHooks;
   hooks?: SearchHooks;
 }
 }
 
 
@@ -3043,6 +3089,7 @@ export async function hybridQuery(
   const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
   const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
   const collection = options?.collection;
   const collection = options?.collection;
   const explain = options?.explain ?? false;
   const explain = options?.explain ?? false;
+  const intent = options?.intent;
   const hooks = options?.hooks;
   const hooks = options?.hooks;
 
 
   const rankedLists: RankedResult[][] = [];
   const rankedLists: RankedResult[][] = [];
@@ -3053,11 +3100,14 @@ export async function hybridQuery(
   ).get();
   ).get();
 
 
   // Step 1: BM25 probe — strong signal skips expensive LLM expansion
   // Step 1: BM25 probe — strong signal skips expensive LLM expansion
+  // When intent is provided, disable strong-signal bypass — the obvious BM25
+  // match may not be what the caller wants (e.g. "performance" with intent
+  // "web page load times" should NOT shortcut to a sports-performance doc).
   // Pass collection directly into FTS query (filter at SQL level, not post-hoc)
   // Pass collection directly into FTS query (filter at SQL level, not post-hoc)
   const initialFts = store.searchFTS(query, 20, collection);
   const initialFts = store.searchFTS(query, 20, collection);
   const topScore = initialFts[0]?.score ?? 0;
   const topScore = initialFts[0]?.score ?? 0;
   const secondScore = initialFts[1]?.score ?? 0;
   const secondScore = initialFts[1]?.score ?? 0;
-  const hasStrongSignal = initialFts.length > 0
+  const hasStrongSignal = !intent && initialFts.length > 0
     && topScore >= STRONG_SIGNAL_MIN_SCORE
     && topScore >= STRONG_SIGNAL_MIN_SCORE
     && (topScore - secondScore) >= STRONG_SIGNAL_MIN_GAP;
     && (topScore - secondScore) >= STRONG_SIGNAL_MIN_GAP;
 
 
@@ -3068,7 +3118,7 @@ export async function hybridQuery(
   const expandStart = Date.now();
   const expandStart = Date.now();
   const expanded = hasStrongSignal
   const expanded = hasStrongSignal
     ? []
     ? []
-    : await store.expandQuery(query);
+    : await store.expandQuery(query, undefined, intent);
 
 
   hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
   hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
 
 
@@ -3157,6 +3207,7 @@ export async function hybridQuery(
   // Step 5: Chunk documents, pick best chunk per doc for reranking.
   // Step 5: Chunk documents, pick best chunk per doc for reranking.
   // Reranking full bodies is O(tokens) — the critical perf lesson that motivated this refactor.
   // Reranking full bodies is O(tokens) — the critical perf lesson that motivated this refactor.
   const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
   const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
+  const intentTerms = intent ? extractIntentTerms(intent) : [];
   const chunksToRerank: { file: string; text: string }[] = [];
   const chunksToRerank: { file: string; text: string }[] = [];
   const docChunkMap = new Map<string, { chunks: { text: string; pos: number }[]; bestIdx: number }>();
   const docChunkMap = new Map<string, { chunks: { text: string; pos: number }[]; bestIdx: number }>();
 
 
@@ -3165,11 +3216,15 @@ export async function hybridQuery(
     if (chunks.length === 0) continue;
     if (chunks.length === 0) continue;
 
 
     // Pick chunk with most keyword overlap (fallback: first chunk)
     // Pick chunk with most keyword overlap (fallback: first chunk)
+    // Intent terms contribute at INTENT_WEIGHT_CHUNK (0.5) relative to query terms (1.0)
     let bestIdx = 0;
     let bestIdx = 0;
     let bestScore = -1;
     let bestScore = -1;
     for (let i = 0; i < chunks.length; i++) {
     for (let i = 0; i < chunks.length; i++) {
       const chunkLower = chunks[i]!.text.toLowerCase();
       const chunkLower = chunks[i]!.text.toLowerCase();
-      const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
+      let score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
+      for (const term of intentTerms) {
+        if (chunkLower.includes(term)) score += INTENT_WEIGHT_CHUNK;
+      }
       if (score > bestScore) { bestScore = score; bestIdx = i; }
       if (score > bestScore) { bestScore = score; bestIdx = i; }
     }
     }
 
 
@@ -3180,7 +3235,7 @@ export async function hybridQuery(
   // Step 6: Rerank chunks (NOT full bodies)
   // Step 6: Rerank chunks (NOT full bodies)
   hooks?.onRerankStart?.(chunksToRerank.length);
   hooks?.onRerankStart?.(chunksToRerank.length);
   const rerankStart = Date.now();
   const rerankStart = Date.now();
-  const reranked = await store.rerank(query, chunksToRerank);
+  const reranked = await store.rerank(query, chunksToRerank, undefined, intent);
   hooks?.onRerankDone?.(Date.now() - rerankStart);
   hooks?.onRerankDone?.(Date.now() - rerankStart);
 
 
   // Step 7: Blend RRF position score with reranker score
   // Step 7: Blend RRF position score with reranker score
@@ -3251,6 +3306,7 @@ export interface VectorSearchOptions {
   collection?: string;
   collection?: string;
   limit?: number;           // default 10
   limit?: number;           // default 10
   minScore?: number;        // default 0.3
   minScore?: number;        // default 0.3
+  intent?: string;          // domain intent hint for disambiguation
   hooks?: Pick<SearchHooks, 'onExpand'>;
   hooks?: Pick<SearchHooks, 'onExpand'>;
 }
 }
 
 
@@ -3281,6 +3337,7 @@ export async function vectorSearchQuery(
   const limit = options?.limit ?? 10;
   const limit = options?.limit ?? 10;
   const minScore = options?.minScore ?? 0.3;
   const minScore = options?.minScore ?? 0.3;
   const collection = options?.collection;
   const collection = options?.collection;
+  const intent = options?.intent;
 
 
   const hasVectors = !!store.db.prepare(
   const hasVectors = !!store.db.prepare(
     `SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`
     `SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`
@@ -3289,7 +3346,7 @@ export async function vectorSearchQuery(
 
 
   // Expand query — filter to vec/hyde only (lex queries target FTS, not vector)
   // Expand query — filter to vec/hyde only (lex queries target FTS, not vector)
   const expandStart = Date.now();
   const expandStart = Date.now();
-  const allExpanded = await store.expandQuery(query);
+  const allExpanded = await store.expandQuery(query, undefined, intent);
   const vecExpanded = allExpanded.filter(q => q.type !== 'lex');
   const vecExpanded = allExpanded.filter(q => q.type !== 'lex');
   options?.hooks?.onExpand?.(query, vecExpanded, Date.now() - expandStart);
   options?.hooks?.onExpand?.(query, vecExpanded, Date.now() - expandStart);
 
 
@@ -3343,7 +3400,7 @@ export interface StructuredSearchOptions {
   minScore?: number;        // default 0
   minScore?: number;        // default 0
   candidateLimit?: number;  // default RERANK_CANDIDATE_LIMIT
   candidateLimit?: number;  // default RERANK_CANDIDATE_LIMIT
   explain?: boolean;        // include backend/RRF/rerank score traces
   explain?: boolean;        // include backend/RRF/rerank score traces
-  /** Future: domain intent hint for routing/boosting */
+  /** Domain intent hint for disambiguation — steers reranking and chunk selection */
   intent?: string;
   intent?: string;
   hooks?: SearchHooks;
   hooks?: SearchHooks;
 }
 }
@@ -3375,6 +3432,7 @@ export async function structuredSearch(
   const minScore = options?.minScore ?? 0;
   const minScore = options?.minScore ?? 0;
   const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
   const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
   const explain = options?.explain ?? false;
   const explain = options?.explain ?? false;
+  const intent = options?.intent;
   const hooks = options?.hooks;
   const hooks = options?.hooks;
 
 
   const collections = options?.collections;
   const collections = options?.collections;
@@ -3489,6 +3547,7 @@ export async function structuredSearch(
     || searches.find(s => s.type === 'vec')?.query
     || searches.find(s => s.type === 'vec')?.query
     || searches[0]?.query || "";
     || searches[0]?.query || "";
   const queryTerms = primaryQuery.toLowerCase().split(/\s+/).filter(t => t.length > 2);
   const queryTerms = primaryQuery.toLowerCase().split(/\s+/).filter(t => t.length > 2);
+  const intentTerms = intent ? extractIntentTerms(intent) : [];
   const chunksToRerank: { file: string; text: string }[] = [];
   const chunksToRerank: { file: string; text: string }[] = [];
   const docChunkMap = new Map<string, { chunks: { text: string; pos: number }[]; bestIdx: number }>();
   const docChunkMap = new Map<string, { chunks: { text: string; pos: number }[]; bestIdx: number }>();
 
 
@@ -3497,11 +3556,15 @@ export async function structuredSearch(
     if (chunks.length === 0) continue;
     if (chunks.length === 0) continue;
 
 
     // Pick chunk with most keyword overlap
     // Pick chunk with most keyword overlap
+    // Intent terms contribute at INTENT_WEIGHT_CHUNK (0.5) relative to query terms (1.0)
     let bestIdx = 0;
     let bestIdx = 0;
     let bestScore = -1;
     let bestScore = -1;
     for (let i = 0; i < chunks.length; i++) {
     for (let i = 0; i < chunks.length; i++) {
       const chunkLower = chunks[i]!.text.toLowerCase();
       const chunkLower = chunks[i]!.text.toLowerCase();
-      const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
+      let score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
+      for (const term of intentTerms) {
+        if (chunkLower.includes(term)) score += INTENT_WEIGHT_CHUNK;
+      }
       if (score > bestScore) { bestScore = score; bestIdx = i; }
       if (score > bestScore) { bestScore = score; bestIdx = i; }
     }
     }
 
 
@@ -3512,7 +3575,7 @@ export async function structuredSearch(
   // Step 5: Rerank chunks
   // Step 5: Rerank chunks
   hooks?.onRerankStart?.(chunksToRerank.length);
   hooks?.onRerankStart?.(chunksToRerank.length);
   const rerankStart2 = Date.now();
   const rerankStart2 = Date.now();
-  const reranked = await store.rerank(primaryQuery, chunksToRerank);
+  const reranked = await store.rerank(primaryQuery, chunksToRerank, undefined, intent);
   hooks?.onRerankDone?.(Date.now() - rerankStart2);
   hooks?.onRerankDone?.(Date.now() - rerankStart2);
 
 
   // Step 6: Blend RRF position score with reranker score
   // Step 6: Blend RRF position score with reranker score

+ 513 - 0
test/intent.test.ts

@@ -0,0 +1,513 @@
+/**
+ * intent.test.ts - Tests for the intent feature
+ *
+ * Tests cover:
+ * - extractIntentTerms: stop word filtering, punctuation, acronyms, edge cases
+ * - extractSnippet with intent: disambiguation across multiple document sections
+ * - parseStructuredQuery with intent: lines (parsing, validation, error cases)
+ * - Chunk selection scoring with intent
+ * - Strong-signal bypass when intent is present
+ * - Intent constants
+ *
+ * Run with: npx vitest run test/intent.test.ts
+ */
+
+import { describe, test, expect } from "vitest";
+import {
+  extractSnippet,
+  extractIntentTerms,
+  INTENT_WEIGHT_SNIPPET,
+  INTENT_WEIGHT_CHUNK,
+  type StructuredSubSearch,
+} from "../src/store.js";
+
+// =============================================================================
+// parseStructuredQuery — duplicated from src/qmd.ts for unit testing
+// (qmd.ts doesn't export it since it's a CLI internal)
+// =============================================================================
+
+interface ParsedStructuredQuery {
+  searches: StructuredSubSearch[];
+  intent?: string;
+}
+
+function parseStructuredQuery(query: string): ParsedStructuredQuery | null {
+  const rawLines = query.split('\n').map((line, idx) => ({
+    raw: line,
+    trimmed: line.trim(),
+    number: idx + 1,
+  })).filter(line => line.trimmed.length > 0);
+
+  if (rawLines.length === 0) return null;
+
+  const prefixRe = /^(lex|vec|hyde):\s*/i;
+  const expandRe = /^expand:\s*/i;
+  const intentRe = /^intent:\s*/i;
+  const typed: StructuredSubSearch[] = [];
+  let intent: string | undefined;
+
+  for (const line of rawLines) {
+    if (expandRe.test(line.trimmed)) {
+      if (rawLines.length > 1) {
+        throw new Error(`Line ${line.number} starts with expand:, but query documents cannot mix expand with typed lines. Submit a single expand query instead.`);
+      }
+      const text = line.trimmed.replace(expandRe, '').trim();
+      if (!text) {
+        throw new Error('expand: query must include text.');
+      }
+      return null;
+    }
+
+    if (intentRe.test(line.trimmed)) {
+      if (intent !== undefined) {
+        throw new Error(`Line ${line.number}: only one intent: line is allowed per query document.`);
+      }
+      const text = line.trimmed.replace(intentRe, '').trim();
+      if (!text) {
+        throw new Error(`Line ${line.number}: intent: must include text.`);
+      }
+      intent = text;
+      continue;
+    }
+
+    const match = line.trimmed.match(prefixRe);
+    if (match) {
+      const type = match[1]!.toLowerCase() as 'lex' | 'vec' | 'hyde';
+      const text = line.trimmed.slice(match[0].length).trim();
+      if (!text) {
+        throw new Error(`Line ${line.number} (${type}:) must include text.`);
+      }
+      if (/\r|\n/.test(text)) {
+        throw new Error(`Line ${line.number} (${type}:) contains a newline. Keep each query on a single line.`);
+      }
+      typed.push({ type, query: text, line: line.number });
+      continue;
+    }
+
+    if (rawLines.length === 1) {
+      return null;
+    }
+
+    throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix. Each line in a query document must start with one.`);
+  }
+
+  if (intent && typed.length === 0) {
+    throw new Error('intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.');
+  }
+
+  return typed.length > 0 ? { searches: typed, intent } : null;
+}
+
+// =============================================================================
+// extractIntentTerms
+// =============================================================================
+
+describe("extractIntentTerms", () => {
+  test("filters stop words", () => {
+    // "looking", "for", "notes", "about" are stop words
+    expect(extractIntentTerms("looking for notes about latency optimization"))
+      .toEqual(["latency", "optimization"]);
+  });
+
+  test("filters common function words", () => {
+    // "what", "is", "the", "to", "find" are stop words; "best", "way" survive
+    expect(extractIntentTerms("what is the best way to find"))
+      .toEqual(["best", "way"]);
+  });
+
+  test("preserves domain terms", () => {
+    expect(extractIntentTerms("web performance latency page load times"))
+      .toEqual(["web", "performance", "latency", "page", "load", "times"]);
+  });
+
+  test("handles surrounding punctuation with Unicode awareness", () => {
+    expect(extractIntentTerms("personal health, fitness, and endurance"))
+      .toEqual(["personal", "health", "fitness", "endurance"]);
+  });
+
+  test("preserves internal hyphens", () => {
+    expect(extractIntentTerms("self-hosted real-time (decision-making)"))
+      .toEqual(["self-hosted", "real-time", "decision-making"]);
+  });
+
+  test("short domain terms survive (API, SQL, LLM)", () => {
+    expect(extractIntentTerms("API design for LLM agents"))
+      .toEqual(["api", "design", "llm", "agents"]);
+  });
+
+  test("returns empty for empty input", () => {
+    expect(extractIntentTerms("")).toEqual([]);
+    expect(extractIntentTerms("  ")).toEqual([]);
+  });
+
+  test("filters single-char terms", () => {
+    const terms = extractIntentTerms("a b c web");
+    expect(terms).toEqual(["web"]);
+  });
+
+  test("all stop words returns empty", () => {
+    const terms = extractIntentTerms("the and or but in on at to for of with by");
+    expect(terms).toEqual([]);
+  });
+
+  test("preserves 2-char domain terms (CI, CD, DB)", () => {
+    const terms = extractIntentTerms("SQL CI CD DB");
+    expect(terms).toContain("sql");
+    expect(terms).toContain("ci");
+    expect(terms).toContain("cd");
+    expect(terms).toContain("db");
+  });
+
+  test("lowercases all terms", () => {
+    const terms = extractIntentTerms("WebSocket HTTP REST");
+    expect(terms).toContain("websocket");
+    expect(terms).toContain("http");
+    expect(terms).toContain("rest");
+  });
+
+  test("handles C++ style punctuation", () => {
+    const terms = extractIntentTerms("C++, performance! optimization.");
+    expect(terms).toContain("performance");
+    expect(terms).toContain("optimization");
+  });
+});
+
+// =============================================================================
+// extractSnippet with intent — disambiguation
+// =============================================================================
+
+describe("extractSnippet with intent", () => {
+  // Each section contains "performance" so the query score is tied (1.0 each).
+  // Intent terms (INTENT_WEIGHT_SNIPPET) then break the tie toward the relevant section.
+  const body = [
+    "# Notes on Various Topics",
+    "",
+    "## Web Performance Section",
+    "Web performance means optimizing page load times and Core Web Vitals.",
+    "Reduce latency, improve rendering speed, and measure performance budgets.",
+    "",
+    "## Team Performance Section",
+    "Team performance depends on trust, psychological safety, and feedback.",
+    "Build culture where performance reviews drive growth not fear.",
+    "",
+    "## Health Performance Section",
+    "Health performance comes from consistent exercise, sleep, and endurance.",
+    "Track fitness metrics, optimize recovery, and monitor healthspan.",
+  ].join("\n");
+
+  test("without intent, anchors on query terms only", () => {
+    const result = extractSnippet(body, "performance", 500);
+    // "performance" appears in title and multiple sections — should anchor on first match
+    expect(result.snippet).toContain("Performance");
+  });
+
+  test("with web-perf intent, prefers web performance section", () => {
+    const result = extractSnippet(
+      body, "performance", 500,
+      undefined, undefined,
+      "Looking for notes about web performance, latency, and page load times"
+    );
+    expect(result.snippet).toMatch(/latency|page.*load|Core Web Vitals/i);
+  });
+
+  test("with health intent, prefers health section", () => {
+    const result = extractSnippet(
+      body, "performance", 500,
+      undefined, undefined,
+      "Looking for notes about personal health, fitness, and endurance"
+    );
+    expect(result.snippet).toMatch(/health|fitness|endurance|exercise/i);
+  });
+
+  test("with team intent, prefers team section", () => {
+    const result = extractSnippet(
+      body, "performance", 500,
+      undefined, undefined,
+      "Looking for notes about building high-performing teams and culture"
+    );
+    expect(result.snippet).toMatch(/team|culture|trust|feedback/i);
+  });
+
+  test("intent does not override strong query match", () => {
+    // Query "Core Web Vitals" is very specific — intent shouldn't pull away from it
+    const result = extractSnippet(
+      body, "Core Web Vitals", 500,
+      undefined, undefined,
+      "Looking for notes about health and fitness"
+    );
+    expect(result.snippet).toContain("Core Web Vitals");
+  });
+
+  test("absent intent produces same result as undefined", () => {
+    const withoutIntent = extractSnippet(body, "performance", 500);
+    const withUndefined = extractSnippet(body, "performance", 500, undefined, undefined, undefined);
+    expect(withoutIntent.line).toBe(withUndefined.line);
+    expect(withoutIntent.snippet).toBe(withUndefined.snippet);
+  });
+
+  test("intent with no matching terms falls back to query-only scoring", () => {
+    const result = extractSnippet(
+      body, "performance", 500,
+      undefined, undefined,
+      "quantum computing and entanglement"
+    );
+    expect(result.snippet).toContain("Performance");
+    expect(result.snippet.length).toBeGreaterThan(0);
+  });
+
+  test("intent works with chunk position", () => {
+    const webPerfStart = body.indexOf("## Web Performance");
+    const result = extractSnippet(
+      body, "performance", 500,
+      webPerfStart, 200,
+      "web page load times"
+    );
+    expect(result.snippet).toMatch(/Web Performance|Core Web Vitals|Page load/i);
+  });
+});
+
+// =============================================================================
+// extractSnippet — intent weight verification
+// =============================================================================
+
+describe("extractSnippet intent weight behavior", () => {
+  // Document where query term appears on every line but intent terms differ
+  const body = [
+    "performance metrics for team velocity",
+    "performance metrics for web latency",
+    "performance metrics for athletic endurance",
+  ].join("\n");
+
+  test("intent breaks tie when query matches all lines equally", () => {
+    const noIntent = extractSnippet(body, "performance metrics", 500);
+    // Without intent, first line wins (all equal score)
+    expect(noIntent.line).toBe(1);
+
+    const withIntent = extractSnippet(
+      body, "performance metrics", 500,
+      undefined, undefined,
+      "web latency and page speed"
+    );
+    // Intent terms "web", "latency" match line 2
+    expect(withIntent.snippet).toContain("web latency");
+  });
+});
+
+// =============================================================================
+// Chunk selection scoring with intent
+// =============================================================================
+
+describe("intent keyword extraction logic", () => {
+  // Mirrors the chunk selection scoring in hybridQuery, using the shared
+  // extractIntentTerms helper and INTENT_WEIGHT_CHUNK constant.
+  function scoreChunk(text: string, query: string, intent?: string): number {
+    const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
+    const intentTerms = intent ? extractIntentTerms(intent) : [];
+    const lower = text.toLowerCase();
+    const qScore = queryTerms.reduce((acc, term) => acc + (lower.includes(term) ? 1 : 0), 0);
+    const iScore = intentTerms.reduce((acc, term) => acc + (lower.includes(term) ? INTENT_WEIGHT_CHUNK : 0), 0);
+    return qScore + iScore;
+  }
+
+  const chunks = [
+    "Web performance: optimize page load times, reduce latency, improve rendering pipeline.",
+    "Team performance: build trust, give feedback, set clear expectations for the group.",
+    "Health performance: exercise regularly, sleep 8 hours, manage stress for endurance.",
+  ];
+
+  test("without intent, all chunks score equally on 'performance'", () => {
+    const scores = chunks.map(c => scoreChunk(c, "performance"));
+    // All contain "performance", so all score 1
+    expect(scores[0]).toBe(scores[1]);
+    expect(scores[1]).toBe(scores[2]);
+  });
+
+  test("with web intent, web chunk scores highest", () => {
+    const intent = "looking for notes about page load times and latency optimization";
+    const scores = chunks.map(c => scoreChunk(c, "performance", intent));
+    expect(scores[0]).toBeGreaterThan(scores[1]!);
+    expect(scores[0]).toBeGreaterThan(scores[2]!);
+  });
+
+  test("with health intent, health chunk scores highest", () => {
+    const intent = "looking for notes about exercise, sleep, and endurance";
+    const scores = chunks.map(c => scoreChunk(c, "performance", intent));
+    expect(scores[2]).toBeGreaterThan(scores[0]!);
+    expect(scores[2]).toBeGreaterThan(scores[1]!);
+  });
+
+  test("intent terms have lower weight than query terms (1.0)", () => {
+    const intent = "looking for latency";
+    // Chunk 0 has "performance" (query: 1.0) + "latency" (intent: INTENT_WEIGHT_CHUNK) = 1.5
+    const withBoth = scoreChunk(chunks[0]!, "performance", intent);
+    const queryOnly = scoreChunk(chunks[0]!, "performance");
+    expect(withBoth).toBe(queryOnly + INTENT_WEIGHT_CHUNK);
+  });
+
+  test("stop words are filtered, short domain terms survive", () => {
+    const intent = "the art of web performance";
+    // "the" (stop word), "art" (survives), "of" (stop word),
+    // "web" (survives), "performance" (survives)
+    // intent terms after filtering: ["art", "web", "performance"]
+    // Chunk 0 has "web" + "performance" → 2 intent hits (no "art")
+    // Chunks 1,2 have "performance" only → 1 intent hit
+    const scores = chunks.map(c => scoreChunk(c, "test", intent));
+    expect(scores[0]).toBe(INTENT_WEIGHT_CHUNK * 2); // "web" + "performance"
+    expect(scores[1]).toBe(INTENT_WEIGHT_CHUNK);      // "performance" only
+    expect(scores[2]).toBe(INTENT_WEIGHT_CHUNK);      // "performance" only
+  });
+});
+
+// =============================================================================
+// Strong-signal bypass with intent
+// =============================================================================
+
+describe("strong-signal bypass logic", () => {
+  // Mirrors the logic in hybridQuery:
+  // const hasStrongSignal = !intent && topScore >= STRONG_SIGNAL_MIN_SCORE && gap >= STRONG_SIGNAL_MIN_GAP
+  function hasStrongSignal(topScore: number, secondScore: number, intent?: string): boolean {
+    return !intent
+      && topScore >= 0.85
+      && (topScore - secondScore) >= 0.15;
+  }
+
+  test("strong signal detected without intent", () => {
+    expect(hasStrongSignal(0.90, 0.70)).toBe(true);
+  });
+
+  test("strong signal bypassed when intent provided", () => {
+    expect(hasStrongSignal(0.90, 0.70, "looking for health performance")).toBe(false);
+  });
+
+  test("weak signal not affected by intent", () => {
+    expect(hasStrongSignal(0.50, 0.45)).toBe(false);
+    expect(hasStrongSignal(0.50, 0.45, "some intent")).toBe(false);
+  });
+
+  test("close scores not strong even without intent", () => {
+    expect(hasStrongSignal(0.90, 0.80)).toBe(false); // gap < 0.15
+  });
+});
+
+// =============================================================================
+// parseStructuredQuery with intent
+// =============================================================================
+
+describe("parseStructuredQuery with intent", () => {
+  test("parses intent + lex query", () => {
+    const result = parseStructuredQuery("intent: web performance\nlex: performance");
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBe("web performance");
+    expect(result!.searches).toHaveLength(1);
+    expect(result!.searches[0]!.type).toBe("lex");
+    expect(result!.searches[0]!.query).toBe("performance");
+  });
+
+  test("parses intent + multiple typed lines", () => {
+    const result = parseStructuredQuery(
+      "intent: web page load times\nlex: performance\nvec: how to improve performance"
+    );
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBe("web page load times");
+    expect(result!.searches).toHaveLength(2);
+    expect(result!.searches[0]!.type).toBe("lex");
+    expect(result!.searches[1]!.type).toBe("vec");
+  });
+
+  test("intent can appear after typed lines", () => {
+    const result = parseStructuredQuery(
+      "lex: performance\nintent: web page load times\nvec: latency"
+    );
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBe("web page load times");
+    expect(result!.searches).toHaveLength(2);
+  });
+
+  test("intent is case-insensitive prefix", () => {
+    const result = parseStructuredQuery("Intent: web perf\nlex: performance");
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBe("web perf");
+  });
+
+  test("no intent returns undefined", () => {
+    const result = parseStructuredQuery("lex: performance\nvec: speed");
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBeUndefined();
+  });
+
+  test("intent alone throws error", () => {
+    expect(() => parseStructuredQuery("intent: web performance")).toThrow(
+      /intent: cannot appear alone/
+    );
+  });
+
+  test("multiple intent lines throw error", () => {
+    expect(() =>
+      parseStructuredQuery("intent: web perf\nintent: team health\nlex: performance")
+    ).toThrow(/only one intent: line is allowed/);
+  });
+
+  test("empty intent text throws error", () => {
+    expect(() =>
+      parseStructuredQuery("intent:\nlex: performance")
+    ).toThrow(/intent: must include text/);
+  });
+
+  test("intent with whitespace-only text throws error", () => {
+    expect(() =>
+      parseStructuredQuery("intent:   \nlex: performance")
+    ).toThrow(/intent: must include text/);
+  });
+
+  test("single plain line still returns null (expand mode)", () => {
+    const result = parseStructuredQuery("how does auth work");
+    expect(result).toBeNull();
+  });
+
+  test("expand: line still returns null", () => {
+    const result = parseStructuredQuery("expand: auth stuff");
+    expect(result).toBeNull();
+  });
+
+  test("intent with expand throws error (expand can't mix)", () => {
+    expect(() =>
+      parseStructuredQuery("intent: web\nexpand: performance")
+    ).toThrow(/cannot mix expand/);
+  });
+
+  test("empty query returns null", () => {
+    expect(parseStructuredQuery("")).toBeNull();
+    expect(parseStructuredQuery("  \n  \n  ")).toBeNull();
+  });
+
+  test("intent with blank lines is fine", () => {
+    const result = parseStructuredQuery(
+      "intent: web perf\n\nlex: performance\n\nvec: speed"
+    );
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBe("web perf");
+    expect(result!.searches).toHaveLength(2);
+  });
+
+  test("intent preserves full text including colons", () => {
+    const result = parseStructuredQuery(
+      "intent: web performance: LCP, FID, CLS\nlex: performance"
+    );
+    expect(result).not.toBeNull();
+    expect(result!.intent).toBe("web performance: LCP, FID, CLS");
+  });
+});
+
+// =============================================================================
+// Constants exported
+// =============================================================================
+
+describe("intent constants", () => {
+  test("INTENT_WEIGHT_SNIPPET is 0.3", () => {
+    expect(INTENT_WEIGHT_SNIPPET).toBe(0.3);
+  });
+
+  test("INTENT_WEIGHT_CHUNK is 0.5", () => {
+    expect(INTENT_WEIGHT_CHUNK).toBe(0.5);
+  });
+});