@gmickel/gno 0.17.0 → 0.19.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.
@@ -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,8 @@ import type {
17
17
 
18
18
  import { err, ok } from "../store/types";
19
19
  import { createChunkLookup } from "./chunk-lookup";
20
+ import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
21
+ import { selectBestChunkForSteering } from "./intent";
20
22
  import { detectQueryLanguage } from "./query-language";
21
23
  import {
22
24
  resolveRecencyTimestamp,
@@ -218,9 +220,38 @@ export async function searchBm25(
218
220
  seenUriSeq.add(uriSeqKey);
219
221
 
220
222
  // Get chunk via O(1) lookup
221
- const chunk = fts.mirrorHash
223
+ const rawChunk = fts.mirrorHash
222
224
  ? (getChunk(fts.mirrorHash, fts.seq) ?? null)
223
225
  : null;
226
+ const chunk =
227
+ options.intent && fts.mirrorHash
228
+ ? (selectBestChunkForSteering(
229
+ chunksMapResult.ok
230
+ ? (chunksMapResult.value.get(fts.mirrorHash) ?? [])
231
+ : [],
232
+ query,
233
+ options.intent,
234
+ {
235
+ preferredSeq: rawChunk?.seq ?? fts.seq,
236
+ intentWeight: 0.3,
237
+ }
238
+ ) ?? rawChunk)
239
+ : rawChunk;
240
+
241
+ const excluded =
242
+ matchesExcludedText(
243
+ [fts.title ?? "", fts.relPath ?? "", fts.snippet ?? ""],
244
+ options.exclude
245
+ ) ||
246
+ matchesExcludedChunks(
247
+ chunksMapResult.ok && fts.mirrorHash
248
+ ? (chunksMapResult.value.get(fts.mirrorHash) ?? [])
249
+ : [],
250
+ options.exclude
251
+ );
252
+ if (excluded) {
253
+ continue;
254
+ }
224
255
 
225
256
  // For --full, de-dupe by docid (keep best scoring chunk per doc)
226
257
  // Raw BM25: smaller (more negative) is better
@@ -293,6 +324,8 @@ export async function searchBm25(
293
324
  query,
294
325
  mode: "bm25",
295
326
  totalResults: Math.min(filteredResults.length, limit),
327
+ intent: options.intent,
328
+ exclude: options.exclude,
296
329
  collection: options.collection,
297
330
  lang: options.lang,
298
331
  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,10 @@ export interface SearchMeta {
76
77
  categories?: string[];
77
78
  /** Author filter applied */
78
79
  author?: string;
80
+ /** Rerank candidate limit used */
81
+ candidateLimit?: number;
82
+ /** Explicit exclusion terms applied */
83
+ exclude?: string[];
79
84
  /** Explain data (when --explain is used) */
80
85
  explain?: {
81
86
  lines: ExplainLine[];
@@ -119,6 +124,10 @@ export interface SearchOptions {
119
124
  categories?: string[];
120
125
  /** Filter by author value */
121
126
  author?: string;
127
+ /** Optional disambiguating context that steers scoring/snippets, but is not searched directly */
128
+ intent?: string;
129
+ /** Explicit exclusion terms for hard candidate pruning */
130
+ exclude?: string[];
122
131
  }
123
132
 
124
133
  /** Structured query mode identifier */
@@ -145,6 +154,8 @@ export type HybridSearchOptions = SearchOptions & {
145
154
  noRerank?: boolean;
146
155
  /** Optional structured mode entries; when set, used as expansion inputs */
147
156
  queryModes?: QueryModeInput[];
157
+ /** Max candidates passed to reranking */
158
+ candidateLimit?: number;
148
159
  /** Enable explain output */
149
160
  explain?: boolean;
150
161
  /** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
@@ -308,6 +319,10 @@ export interface AskMeta {
308
319
  expanded: boolean;
309
320
  reranked: boolean;
310
321
  vectorsUsed: boolean;
322
+ intent?: string;
323
+ candidateLimit?: number;
324
+ exclude?: string[];
325
+ queryModes?: QueryModeSummary;
311
326
  answerGenerated?: boolean;
312
327
  totalResults?: number;
313
328
  answerContext?: AnswerContextExplain;
@@ -14,6 +14,8 @@ 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 { matchesExcludedChunks, matchesExcludedText } from "./exclude";
18
+ import { selectBestChunkForSteering } from "./intent";
17
19
  import { detectQueryLanguage } from "./query-language";
18
20
  import {
19
21
  resolveRecencyTimestamp,
@@ -146,7 +148,18 @@ export async function searchVectorWithEmbedding(
146
148
  }
147
149
 
148
150
  // Get chunk via O(1) lookup
149
- const chunk = getChunk(vec.mirrorHash, vec.seq);
151
+ const rawChunk = getChunk(vec.mirrorHash, vec.seq);
152
+ const chunk = options.intent
153
+ ? (selectBestChunkForSteering(
154
+ chunksMap.get(vec.mirrorHash) ?? [],
155
+ query,
156
+ options.intent,
157
+ {
158
+ preferredSeq: rawChunk?.seq ?? vec.seq,
159
+ intentWeight: 0.3,
160
+ }
161
+ ) ?? rawChunk)
162
+ : rawChunk;
150
163
  if (!chunk) {
151
164
  continue;
152
165
  }
@@ -162,6 +175,25 @@ export async function searchVectorWithEmbedding(
162
175
  continue;
163
176
  }
164
177
 
178
+ const excluded =
179
+ matchesExcludedText(
180
+ [
181
+ doc.title ?? "",
182
+ doc.relPath,
183
+ doc.author ?? "",
184
+ doc.contentType ?? "",
185
+ ...(doc.categories ?? []),
186
+ ],
187
+ options.exclude
188
+ ) ||
189
+ matchesExcludedChunks(
190
+ chunksMap.get(vec.mirrorHash) ?? [],
191
+ options.exclude
192
+ );
193
+ if (excluded) {
194
+ continue;
195
+ }
196
+
165
197
  // For --full, de-dupe by docid (keep best scoring chunk per doc)
166
198
  if (options.full) {
167
199
  const existing = bestByDocid.get(doc.docid);
@@ -288,6 +320,8 @@ export async function searchVectorWithEmbedding(
288
320
  mode: "vector",
289
321
  vectorsUsed: true,
290
322
  totalResults: finalResults.length,
323
+ intent: options.intent,
324
+ exclude: options.exclude,
291
325
  collection: options.collection,
292
326
  lang: options.lang,
293
327
  since: temporalRange.since,
@@ -349,6 +383,9 @@ interface DocumentInfo {
349
383
  title: string | null;
350
384
  collection: string;
351
385
  relPath: string;
386
+ author: string | null;
387
+ contentType: string | null;
388
+ categories: string[] | null;
352
389
  sourceHash: string;
353
390
  sourceMime: string;
354
391
  sourceExt: string;
@@ -478,6 +515,9 @@ async function buildDocumentMap(
478
515
  title: doc.title,
479
516
  collection: doc.collection,
480
517
  relPath: doc.relPath,
518
+ author: doc.author ?? null,
519
+ contentType: doc.contentType ?? null,
520
+ categories: doc.categories ?? null,
481
521
  sourceHash: doc.sourceHash,
482
522
  sourceMime: doc.sourceMime,
483
523
  sourceExt: doc.sourceExt,
@@ -9,6 +9,9 @@ export interface QueryModeEntry {
9
9
 
10
10
  export interface RetrievalFiltersState {
11
11
  collection: string;
12
+ intent: string;
13
+ candidateLimit: string;
14
+ exclude: string;
12
15
  since: string;
13
16
  until: string;
14
17
  category: string;
@@ -121,6 +124,10 @@ export function parseFiltersFromSearch(
121
124
 
122
125
  return {
123
126
  collection: params.get("collection") ?? defaults.collection ?? "",
127
+ intent: params.get("intent") ?? defaults.intent ?? "",
128
+ candidateLimit:
129
+ params.get("candidateLimit") ?? defaults.candidateLimit ?? "",
130
+ exclude: params.get("exclude") ?? defaults.exclude ?? "",
124
131
  since: params.get("since") ?? defaults.since ?? "",
125
132
  until: params.get("until") ?? defaults.until ?? "",
126
133
  category: params.get("category") ?? defaults.category ?? "",
@@ -145,6 +152,9 @@ export function applyFiltersToUrl(
145
152
  };
146
153
 
147
154
  setOrDelete("collection", filters.collection);
155
+ setOrDelete("intent", filters.intent);
156
+ setOrDelete("candidateLimit", filters.candidateLimit);
157
+ setOrDelete("exclude", filters.exclude);
148
158
  setOrDelete("since", filters.since);
149
159
  setOrDelete("until", filters.until);
150
160
  setOrDelete("category", filters.category);