@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.
- package/README.md +25 -1
- package/package.json +1 -1
- package/src/cli/commands/ask.ts +7 -0
- package/src/cli/commands/models/use.ts +1 -0
- package/src/cli/program.ts +42 -0
- package/src/config/types.ts +2 -0
- package/src/llm/nodeLlamaCpp/generation.ts +3 -1
- package/src/llm/registry.ts +1 -0
- package/src/llm/types.ts +2 -0
- package/src/mcp/tools/index.ts +7 -0
- package/src/mcp/tools/query.ts +6 -0
- package/src/mcp/tools/search.ts +4 -0
- package/src/mcp/tools/vsearch.ts +4 -0
- package/src/pipeline/exclude.ts +69 -0
- package/src/pipeline/expansion.ts +39 -4
- package/src/pipeline/hybrid.ts +59 -18
- package/src/pipeline/intent.ts +152 -0
- package/src/pipeline/rerank.ts +81 -44
- package/src/pipeline/search.ts +34 -1
- package/src/pipeline/types.ts +15 -0
- package/src/pipeline/vsearch.ts +41 -1
- package/src/serve/public/lib/retrieval-filters.ts +10 -0
- package/src/serve/public/pages/Ask.tsx +189 -1
- package/src/serve/public/pages/Search.tsx +78 -2
- package/src/serve/routes/api.ts +161 -48
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent-aware retrieval helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module src/pipeline/intent
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChunkRow } from "../store/types";
|
|
8
|
+
|
|
9
|
+
const TOKEN_PATTERN = /[A-Za-z0-9][A-Za-z0-9.+#/_-]*/g;
|
|
10
|
+
|
|
11
|
+
const STOPWORDS = new Set([
|
|
12
|
+
"a",
|
|
13
|
+
"an",
|
|
14
|
+
"and",
|
|
15
|
+
"are",
|
|
16
|
+
"as",
|
|
17
|
+
"at",
|
|
18
|
+
"be",
|
|
19
|
+
"by",
|
|
20
|
+
"for",
|
|
21
|
+
"from",
|
|
22
|
+
"how",
|
|
23
|
+
"in",
|
|
24
|
+
"is",
|
|
25
|
+
"it",
|
|
26
|
+
"of",
|
|
27
|
+
"on",
|
|
28
|
+
"or",
|
|
29
|
+
"that",
|
|
30
|
+
"the",
|
|
31
|
+
"this",
|
|
32
|
+
"to",
|
|
33
|
+
"what",
|
|
34
|
+
"when",
|
|
35
|
+
"where",
|
|
36
|
+
"which",
|
|
37
|
+
"who",
|
|
38
|
+
"why",
|
|
39
|
+
"with",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const MATCH_ANCHOR_BONUS = 0.2;
|
|
43
|
+
|
|
44
|
+
function dedupe(values: string[]): string[] {
|
|
45
|
+
return [...new Set(values)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeToken(token: string): string {
|
|
49
|
+
return token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "").toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract meaningful steering terms from query/intent text.
|
|
54
|
+
* Keeps short domain tokens like API/SQL/LLM while dropping common stop words.
|
|
55
|
+
*/
|
|
56
|
+
export function extractSteeringTerms(text: string): string[] {
|
|
57
|
+
const matches = text.match(TOKEN_PATTERN) ?? [];
|
|
58
|
+
const terms: string[] = [];
|
|
59
|
+
|
|
60
|
+
for (const rawToken of matches) {
|
|
61
|
+
const token = normalizeToken(rawToken);
|
|
62
|
+
if (token.length < 2) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (STOPWORDS.has(token)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
terms.push(token);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return dedupe(terms);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scoreTextForTerms(text: string, terms: string[]): number {
|
|
75
|
+
if (terms.length === 0 || text.length === 0) {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const haystack = text.toLowerCase();
|
|
80
|
+
let score = 0;
|
|
81
|
+
|
|
82
|
+
for (const term of terms) {
|
|
83
|
+
if (haystack.includes(term)) {
|
|
84
|
+
score += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return score;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ChunkSelectionOptions {
|
|
92
|
+
preferredSeq?: number | null;
|
|
93
|
+
intentWeight: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Choose the most query-relevant chunk in a document, with intent as a softer steer.
|
|
98
|
+
*/
|
|
99
|
+
export function selectBestChunkForSteering(
|
|
100
|
+
chunks: ChunkRow[],
|
|
101
|
+
query: string,
|
|
102
|
+
intent: string | undefined,
|
|
103
|
+
options: ChunkSelectionOptions
|
|
104
|
+
): ChunkRow | null {
|
|
105
|
+
if (chunks.length === 0) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const queryTerms = extractSteeringTerms(query);
|
|
110
|
+
const intentTerms = extractSteeringTerms(intent ?? "");
|
|
111
|
+
const preferredSeq = options.preferredSeq ?? null;
|
|
112
|
+
let bestChunk: ChunkRow | null = null;
|
|
113
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
114
|
+
|
|
115
|
+
for (const chunk of chunks) {
|
|
116
|
+
const queryScore = scoreTextForTerms(chunk.text, queryTerms);
|
|
117
|
+
const intentScore =
|
|
118
|
+
scoreTextForTerms(chunk.text, intentTerms) * options.intentWeight;
|
|
119
|
+
const preferredBonus =
|
|
120
|
+
preferredSeq !== null && chunk.seq === preferredSeq
|
|
121
|
+
? MATCH_ANCHOR_BONUS
|
|
122
|
+
: 0;
|
|
123
|
+
const score = queryScore + intentScore + preferredBonus;
|
|
124
|
+
|
|
125
|
+
if (score > bestScore) {
|
|
126
|
+
bestScore = score;
|
|
127
|
+
bestChunk = chunk;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (score === bestScore && bestChunk && chunk.seq < bestChunk.seq) {
|
|
132
|
+
bestChunk = chunk;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return bestChunk ?? chunks[0] ?? null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a rerank query that provides intent as context without becoming a search term.
|
|
141
|
+
*/
|
|
142
|
+
export function buildIntentAwareRerankQuery(
|
|
143
|
+
query: string,
|
|
144
|
+
intent?: string
|
|
145
|
+
): string {
|
|
146
|
+
const trimmedIntent = intent?.trim();
|
|
147
|
+
if (!trimmedIntent) {
|
|
148
|
+
return query;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `Intent: ${trimmedIntent}\nQuery: ${query}`;
|
|
152
|
+
}
|
package/src/pipeline/rerank.ts
CHANGED
|
@@ -9,6 +9,10 @@ import type { RerankPort } from "../llm/types";
|
|
|
9
9
|
import type { ChunkRow, StorePort } from "../store/types";
|
|
10
10
|
import type { BlendingTier, FusionCandidate, RerankedCandidate } from "./types";
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
buildIntentAwareRerankQuery,
|
|
14
|
+
selectBestChunkForSteering,
|
|
15
|
+
} from "./intent";
|
|
12
16
|
import { DEFAULT_BLENDING_SCHEDULE } from "./types";
|
|
13
17
|
|
|
14
18
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -20,6 +24,8 @@ export interface RerankOptions {
|
|
|
20
24
|
maxCandidates?: number;
|
|
21
25
|
/** Blending schedule */
|
|
22
26
|
blendingSchedule?: BlendingTier[];
|
|
27
|
+
/** Optional disambiguating context for reranking */
|
|
28
|
+
intent?: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface RerankResult {
|
|
@@ -75,27 +81,6 @@ function blend(
|
|
|
75
81
|
const MAX_CHUNK_CHARS = 4000;
|
|
76
82
|
const PROTECT_BM25_TOP_RANK = 1;
|
|
77
83
|
|
|
78
|
-
interface BestChunkInfo {
|
|
79
|
-
candidate: FusionCandidate;
|
|
80
|
-
seq: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Extract best chunk per document for efficient reranking.
|
|
85
|
-
*/
|
|
86
|
-
function selectBestChunks(
|
|
87
|
-
toRerank: FusionCandidate[]
|
|
88
|
-
): Map<string, BestChunkInfo> {
|
|
89
|
-
const bestChunkPerDoc = new Map<string, BestChunkInfo>();
|
|
90
|
-
for (const c of toRerank) {
|
|
91
|
-
const existing = bestChunkPerDoc.get(c.mirrorHash);
|
|
92
|
-
if (!existing || c.fusionScore > existing.candidate.fusionScore) {
|
|
93
|
-
bestChunkPerDoc.set(c.mirrorHash, { candidate: c, seq: c.seq });
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return bestChunkPerDoc;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
84
|
function isProtectedLexicalTopHit(candidate: FusionCandidate): boolean {
|
|
100
85
|
return (
|
|
101
86
|
candidate.bm25Rank === PROTECT_BM25_TOP_RANK &&
|
|
@@ -108,31 +93,50 @@ function isProtectedLexicalTopHit(candidate: FusionCandidate): boolean {
|
|
|
108
93
|
*/
|
|
109
94
|
async function fetchChunkTexts(
|
|
110
95
|
store: StorePort,
|
|
111
|
-
|
|
96
|
+
toRerank: FusionCandidate[],
|
|
97
|
+
query: string,
|
|
98
|
+
intent: string | undefined
|
|
112
99
|
): Promise<{ texts: string[]; hashToIndex: Map<string, number> }> {
|
|
113
|
-
const uniqueHashes = [
|
|
100
|
+
const uniqueHashes = [
|
|
101
|
+
...new Set(toRerank.map((candidate) => candidate.mirrorHash)),
|
|
102
|
+
];
|
|
114
103
|
const chunksBatchResult = await store.getChunksBatch(uniqueHashes);
|
|
115
104
|
const chunksByHash: Map<string, ChunkRow[]> = chunksBatchResult.ok
|
|
116
105
|
? chunksBatchResult.value
|
|
117
106
|
: new Map();
|
|
118
|
-
const
|
|
107
|
+
const preferredSeqByHash = new Map<string, number>();
|
|
108
|
+
|
|
109
|
+
for (const candidate of toRerank) {
|
|
110
|
+
const existingSeq = preferredSeqByHash.get(candidate.mirrorHash);
|
|
111
|
+
if (existingSeq !== undefined) {
|
|
112
|
+
const existingCandidate = toRerank.find(
|
|
113
|
+
(entry) =>
|
|
114
|
+
entry.mirrorHash === candidate.mirrorHash && entry.seq === existingSeq
|
|
115
|
+
);
|
|
116
|
+
if (
|
|
117
|
+
existingCandidate &&
|
|
118
|
+
existingCandidate.fusionScore >= candidate.fusionScore
|
|
119
|
+
) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
preferredSeqByHash.set(candidate.mirrorHash, candidate.seq);
|
|
124
|
+
}
|
|
119
125
|
|
|
126
|
+
const chunkTexts = new Map<string, string>();
|
|
120
127
|
for (const hash of uniqueHashes) {
|
|
121
|
-
const bestInfo = bestChunkPerDoc.get(hash);
|
|
122
128
|
const chunks = chunksByHash.get(hash);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
chunkTexts.set(hash, "");
|
|
135
|
-
}
|
|
129
|
+
const bestChunk = selectBestChunkForSteering(chunks ?? [], query, intent, {
|
|
130
|
+
preferredSeq: preferredSeqByHash.get(hash) ?? null,
|
|
131
|
+
intentWeight: 0.5,
|
|
132
|
+
});
|
|
133
|
+
const text = bestChunk?.text ?? "";
|
|
134
|
+
chunkTexts.set(
|
|
135
|
+
hash,
|
|
136
|
+
text.length > MAX_CHUNK_CHARS
|
|
137
|
+
? `${text.slice(0, MAX_CHUNK_CHARS)}...`
|
|
138
|
+
: text
|
|
139
|
+
);
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
const hashToIndex = new Map<string, number>();
|
|
@@ -198,11 +202,40 @@ export async function rerankCandidates(
|
|
|
198
202
|
const remaining = candidates.slice(maxCandidates);
|
|
199
203
|
|
|
200
204
|
// Extract best chunk per document for efficient reranking
|
|
201
|
-
const
|
|
202
|
-
|
|
205
|
+
const { texts, hashToIndex } = await fetchChunkTexts(
|
|
206
|
+
store,
|
|
207
|
+
toRerank,
|
|
208
|
+
query,
|
|
209
|
+
options.intent
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const uniqueTexts: string[] = [];
|
|
213
|
+
const docIndexToUniqueIndex = new Map<number, number>();
|
|
214
|
+
const uniqueIndexToDocIndices = new Map<number, number[]>();
|
|
215
|
+
const textToUniqueIndex = new Map<string, number>();
|
|
216
|
+
|
|
217
|
+
for (const [docIndex, text] of texts.entries()) {
|
|
218
|
+
const existingIndex = textToUniqueIndex.get(text);
|
|
219
|
+
if (existingIndex !== undefined) {
|
|
220
|
+
docIndexToUniqueIndex.set(docIndex, existingIndex);
|
|
221
|
+
const mapped = uniqueIndexToDocIndices.get(existingIndex) ?? [];
|
|
222
|
+
mapped.push(docIndex);
|
|
223
|
+
uniqueIndexToDocIndices.set(existingIndex, mapped);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const uniqueIndex = uniqueTexts.length;
|
|
228
|
+
uniqueTexts.push(text);
|
|
229
|
+
textToUniqueIndex.set(text, uniqueIndex);
|
|
230
|
+
docIndexToUniqueIndex.set(docIndex, uniqueIndex);
|
|
231
|
+
uniqueIndexToDocIndices.set(uniqueIndex, [docIndex]);
|
|
232
|
+
}
|
|
203
233
|
|
|
204
234
|
// Run reranking on best chunks (much faster than full docs)
|
|
205
|
-
const rerankResult = await rerankPort.rerank(
|
|
235
|
+
const rerankResult = await rerankPort.rerank(
|
|
236
|
+
buildIntentAwareRerankQuery(query, options.intent),
|
|
237
|
+
uniqueTexts
|
|
238
|
+
);
|
|
206
239
|
|
|
207
240
|
if (!rerankResult.ok) {
|
|
208
241
|
return {
|
|
@@ -217,9 +250,13 @@ export async function rerankCandidates(
|
|
|
217
250
|
}
|
|
218
251
|
|
|
219
252
|
// Normalize rerank scores using min-max
|
|
220
|
-
const scoreByDocIndex = new Map(
|
|
221
|
-
|
|
222
|
-
|
|
253
|
+
const scoreByDocIndex = new Map<number, number>();
|
|
254
|
+
for (const score of rerankResult.value) {
|
|
255
|
+
const docIndices = uniqueIndexToDocIndices.get(score.index) ?? [];
|
|
256
|
+
for (const docIndex of docIndices) {
|
|
257
|
+
scoreByDocIndex.set(docIndex, score.score);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
223
260
|
const rerankScores = rerankResult.value.map((s) => s.score);
|
|
224
261
|
const minRerank = Math.min(...rerankScores);
|
|
225
262
|
const maxRerank = Math.max(...rerankScores);
|
package/src/pipeline/search.ts
CHANGED
|
@@ -17,6 +17,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
|
|
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,
|
package/src/pipeline/types.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface SearchMeta {
|
|
|
62
62
|
reranked?: boolean;
|
|
63
63
|
vectorsUsed?: boolean;
|
|
64
64
|
totalResults: number;
|
|
65
|
+
intent?: string;
|
|
65
66
|
collection?: string;
|
|
66
67
|
lang?: string;
|
|
67
68
|
/** Detected/overridden query language for prompt selection (typically BCP-47; may be user-provided via --lang) */
|
|
@@ -76,6 +77,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;
|
package/src/pipeline/vsearch.ts
CHANGED
|
@@ -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
|
|
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);
|