@gmickel/gno 1.8.0 → 1.10.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 +6 -2
- package/assets/skill/SKILL.md +41 -16
- package/assets/skill/cli-reference.md +39 -0
- package/assets/skill/examples.md +41 -0
- package/assets/skill/mcp-reference.md +13 -1
- package/package.json +1 -1
- package/src/cli/commands/capture.ts +9 -6
- package/src/cli/commands/graph.ts +89 -2
- package/src/cli/commands/index-cmd.ts +17 -6
- package/src/cli/commands/links.ts +237 -54
- package/src/cli/commands/query.ts +212 -0
- package/src/cli/commands/ref-parser.ts +8 -103
- package/src/cli/commands/shared.ts +17 -1
- package/src/cli/commands/update.ts +17 -6
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +176 -9
- package/src/config/content-types.ts +154 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +14 -0
- package/src/config/loader.ts +11 -2
- package/src/config/types.ts +37 -1
- package/src/core/config-mutation.ts +14 -2
- package/src/core/graph-query.ts +137 -0
- package/src/core/graph-resolver.ts +117 -0
- package/src/core/note-presets.ts +61 -5
- package/src/core/ref-parser.ts +145 -0
- package/src/ingestion/frontmatter.ts +170 -2
- package/src/ingestion/index.ts +2 -0
- package/src/ingestion/sync-options.ts +29 -0
- package/src/ingestion/sync.ts +385 -17
- package/src/ingestion/types.ts +14 -0
- package/src/mcp/tools/add-collection.ts +8 -5
- package/src/mcp/tools/capture.ts +5 -2
- package/src/mcp/tools/get.ts +1 -1
- package/src/mcp/tools/index-cmd.ts +13 -7
- package/src/mcp/tools/index.ts +97 -10
- package/src/mcp/tools/links.ts +83 -1
- package/src/mcp/tools/multi-get.ts +1 -1
- package/src/mcp/tools/query.ts +207 -0
- package/src/mcp/tools/sync.ts +12 -6
- package/src/mcp/tools/workspace-write.ts +16 -10
- package/src/pipeline/diagnose.ts +302 -0
- package/src/pipeline/filters.ts +119 -0
- package/src/pipeline/hybrid.ts +92 -17
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +34 -0
- package/src/pipeline/vsearch.ts +4 -0
- package/src/publish/export-service.ts +1 -1
- package/src/sdk/client.ts +51 -24
- package/src/sdk/documents.ts +2 -2
- package/src/serve/background-runtime.ts +18 -4
- package/src/serve/config-sync.ts +9 -2
- package/src/serve/routes/api.ts +244 -24
- package/src/serve/routes/graph.ts +173 -1
- package/src/serve/server.ts +24 -1
- package/src/serve/watch-service.ts +12 -2
- package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
- package/src/store/migrations/010-typed-edges.ts +67 -0
- package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
- package/src/store/migrations/index.ts +16 -1
- package/src/store/sqlite/adapter.ts +853 -114
- package/src/store/types.ts +147 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Targeted query diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* @module src/pipeline/diagnose
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { NormalizedContentTypeRule } from "../config";
|
|
8
|
+
import type { DocumentRow, StoreResult } from "../store/types";
|
|
9
|
+
import type { HybridSearchDeps } from "./hybrid";
|
|
10
|
+
import type {
|
|
11
|
+
HybridSearchOptions,
|
|
12
|
+
QueryModeSummary,
|
|
13
|
+
QueryDiagnoseStageId,
|
|
14
|
+
QueryDiagnoseTraceCandidate,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
import { fingerprintContentTypeRules } from "../config";
|
|
18
|
+
import { resolveDocRef } from "../core/ref-parser";
|
|
19
|
+
import { err, ok } from "../store/types";
|
|
20
|
+
import { evaluateQueryTargetFilters } from "./filters";
|
|
21
|
+
import { searchHybrid } from "./hybrid";
|
|
22
|
+
|
|
23
|
+
export type QueryDiagnoseTargetStatus =
|
|
24
|
+
| "not_found"
|
|
25
|
+
| "inactive"
|
|
26
|
+
| "no_indexed_content"
|
|
27
|
+
| "filtered_out"
|
|
28
|
+
| "diagnosed";
|
|
29
|
+
|
|
30
|
+
export type QueryDiagnoseDropReason =
|
|
31
|
+
| "not_in_candidate_set"
|
|
32
|
+
| "below_cutoff"
|
|
33
|
+
| "skipped"
|
|
34
|
+
| null;
|
|
35
|
+
|
|
36
|
+
export interface QueryDiagnoseStage {
|
|
37
|
+
id: QueryDiagnoseStageId;
|
|
38
|
+
status: "active" | "skipped";
|
|
39
|
+
sourceCount: number;
|
|
40
|
+
present: boolean;
|
|
41
|
+
rank: number | null;
|
|
42
|
+
score: number | null;
|
|
43
|
+
survived: boolean;
|
|
44
|
+
dropReason: QueryDiagnoseDropReason;
|
|
45
|
+
reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface QueryDiagnoseResult {
|
|
49
|
+
schemaVersion: "1.0";
|
|
50
|
+
query: string;
|
|
51
|
+
target: {
|
|
52
|
+
ref: string;
|
|
53
|
+
status: QueryDiagnoseTargetStatus;
|
|
54
|
+
docid: string | null;
|
|
55
|
+
uri: string | null;
|
|
56
|
+
title: string | null;
|
|
57
|
+
contentType: string | null;
|
|
58
|
+
contentTypeSource: string | null;
|
|
59
|
+
categories: string[];
|
|
60
|
+
graphHints: string[];
|
|
61
|
+
contentTypeRulesFingerprint: string | null;
|
|
62
|
+
contentTypeFingerprintMatches: boolean | null;
|
|
63
|
+
mirrorHash: string | null;
|
|
64
|
+
chunkCount: number;
|
|
65
|
+
filterReasons: string[];
|
|
66
|
+
};
|
|
67
|
+
stages: QueryDiagnoseStage[];
|
|
68
|
+
chunk: {
|
|
69
|
+
seq: number | null;
|
|
70
|
+
startLine: number | null;
|
|
71
|
+
endLine: number | null;
|
|
72
|
+
language: string | null;
|
|
73
|
+
};
|
|
74
|
+
meta: {
|
|
75
|
+
mode: "bm25_only" | "hybrid";
|
|
76
|
+
vectorsUsed: boolean;
|
|
77
|
+
reranked: boolean;
|
|
78
|
+
totalResults: number;
|
|
79
|
+
queryModes?: QueryModeSummary;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type QueryDiagnoseOptions = HybridSearchOptions & {
|
|
84
|
+
target: string;
|
|
85
|
+
contentTypeRules?: NormalizedContentTypeRule[];
|
|
86
|
+
contentTypeRulesFingerprint?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function graphHintsForDoc(
|
|
90
|
+
doc: DocumentRow,
|
|
91
|
+
rules: NormalizedContentTypeRule[]
|
|
92
|
+
): string[] {
|
|
93
|
+
if (!doc.contentType) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
return rules.find((rule) => rule.id === doc.contentType)?.graphHints ?? [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildBaseResult(
|
|
100
|
+
query: string,
|
|
101
|
+
targetRef: string,
|
|
102
|
+
status: QueryDiagnoseTargetStatus,
|
|
103
|
+
doc: DocumentRow | null,
|
|
104
|
+
fields: {
|
|
105
|
+
graphHints?: string[];
|
|
106
|
+
chunkCount?: number;
|
|
107
|
+
filterReasons?: string[];
|
|
108
|
+
fingerprint?: string | null;
|
|
109
|
+
fingerprintMatches?: boolean | null;
|
|
110
|
+
} = {}
|
|
111
|
+
): QueryDiagnoseResult {
|
|
112
|
+
return {
|
|
113
|
+
schemaVersion: "1.0",
|
|
114
|
+
query,
|
|
115
|
+
target: {
|
|
116
|
+
ref: targetRef,
|
|
117
|
+
status,
|
|
118
|
+
docid: doc?.docid ?? null,
|
|
119
|
+
uri: doc?.uri ?? null,
|
|
120
|
+
title: doc?.title ?? null,
|
|
121
|
+
contentType: doc?.contentType ?? null,
|
|
122
|
+
contentTypeSource: doc?.contentTypeSource ?? null,
|
|
123
|
+
categories: doc?.categories ?? [],
|
|
124
|
+
graphHints: fields.graphHints ?? [],
|
|
125
|
+
contentTypeRulesFingerprint: doc?.contentTypeRulesFingerprint ?? null,
|
|
126
|
+
contentTypeFingerprintMatches: fields.fingerprintMatches ?? null,
|
|
127
|
+
mirrorHash: doc?.mirrorHash ?? null,
|
|
128
|
+
chunkCount: fields.chunkCount ?? 0,
|
|
129
|
+
filterReasons: fields.filterReasons ?? [],
|
|
130
|
+
},
|
|
131
|
+
stages: [],
|
|
132
|
+
chunk: {
|
|
133
|
+
seq: null,
|
|
134
|
+
startLine: null,
|
|
135
|
+
endLine: null,
|
|
136
|
+
language: null,
|
|
137
|
+
},
|
|
138
|
+
meta: {
|
|
139
|
+
mode: "bm25_only",
|
|
140
|
+
vectorsUsed: false,
|
|
141
|
+
reranked: false,
|
|
142
|
+
totalResults: 0,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function findTargetCandidate(
|
|
148
|
+
candidates: QueryDiagnoseTraceCandidate[],
|
|
149
|
+
mirrorHash: string,
|
|
150
|
+
targetSeqs: Set<number>
|
|
151
|
+
): QueryDiagnoseTraceCandidate | undefined {
|
|
152
|
+
return candidates.find(
|
|
153
|
+
(candidate) =>
|
|
154
|
+
candidate.mirrorHash === mirrorHash && targetSeqs.has(candidate.seq)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function diagnoseQueryTarget(
|
|
159
|
+
deps: HybridSearchDeps,
|
|
160
|
+
query: string,
|
|
161
|
+
options: QueryDiagnoseOptions
|
|
162
|
+
): Promise<StoreResult<QueryDiagnoseResult>> {
|
|
163
|
+
const resolved = await resolveDocRef(deps.store, options.target);
|
|
164
|
+
if ("error" in resolved) {
|
|
165
|
+
return ok(buildBaseResult(query, options.target, "not_found", null));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const doc = resolved.doc;
|
|
169
|
+
const rules = options.contentTypeRules ?? [];
|
|
170
|
+
const expectedFingerprint =
|
|
171
|
+
options.contentTypeRulesFingerprint ?? fingerprintContentTypeRules(rules);
|
|
172
|
+
const fingerprintMatches = doc.contentTypeRulesFingerprint
|
|
173
|
+
? doc.contentTypeRulesFingerprint === expectedFingerprint
|
|
174
|
+
: null;
|
|
175
|
+
const graphHints = graphHintsForDoc(doc, rules);
|
|
176
|
+
|
|
177
|
+
if (!doc.active) {
|
|
178
|
+
return ok(
|
|
179
|
+
buildBaseResult(query, options.target, "inactive", doc, {
|
|
180
|
+
graphHints,
|
|
181
|
+
fingerprintMatches,
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!doc.mirrorHash) {
|
|
186
|
+
return ok(
|
|
187
|
+
buildBaseResult(query, options.target, "no_indexed_content", doc, {
|
|
188
|
+
graphHints,
|
|
189
|
+
fingerprintMatches,
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const chunksResult = await deps.store.getChunks(doc.mirrorHash);
|
|
195
|
+
if (!chunksResult.ok) {
|
|
196
|
+
return err("QUERY_FAILED", chunksResult.error.message);
|
|
197
|
+
}
|
|
198
|
+
const chunks = chunksResult.value;
|
|
199
|
+
if (chunks.length === 0) {
|
|
200
|
+
return ok(
|
|
201
|
+
buildBaseResult(query, options.target, "no_indexed_content", doc, {
|
|
202
|
+
graphHints,
|
|
203
|
+
fingerprintMatches,
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const filterEval = await evaluateQueryTargetFilters(
|
|
209
|
+
deps.store,
|
|
210
|
+
query,
|
|
211
|
+
doc,
|
|
212
|
+
chunks,
|
|
213
|
+
options
|
|
214
|
+
);
|
|
215
|
+
if (!filterEval.matches) {
|
|
216
|
+
return ok(
|
|
217
|
+
buildBaseResult(query, options.target, "filtered_out", doc, {
|
|
218
|
+
graphHints,
|
|
219
|
+
chunkCount: chunks.length,
|
|
220
|
+
filterReasons: filterEval.reasons,
|
|
221
|
+
fingerprintMatches,
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const searchResult = await searchHybrid(deps, query, {
|
|
227
|
+
...options,
|
|
228
|
+
diagnoseTrace: true,
|
|
229
|
+
});
|
|
230
|
+
if (!searchResult.ok) {
|
|
231
|
+
return err(searchResult.error.code, searchResult.error.message);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const trace = searchResult.value.meta.trace;
|
|
235
|
+
const targetSeqs = new Set(chunks.map((chunk) => chunk.seq));
|
|
236
|
+
let seenEarlier = false;
|
|
237
|
+
const stages: QueryDiagnoseStage[] =
|
|
238
|
+
trace?.stages.map((stage) => {
|
|
239
|
+
const candidate = findTargetCandidate(
|
|
240
|
+
stage.candidates,
|
|
241
|
+
doc.mirrorHash ?? "",
|
|
242
|
+
targetSeqs
|
|
243
|
+
);
|
|
244
|
+
const present = Boolean(candidate);
|
|
245
|
+
const dropReason: QueryDiagnoseDropReason =
|
|
246
|
+
stage.status === "skipped"
|
|
247
|
+
? "skipped"
|
|
248
|
+
: present
|
|
249
|
+
? null
|
|
250
|
+
: seenEarlier
|
|
251
|
+
? "below_cutoff"
|
|
252
|
+
: "not_in_candidate_set";
|
|
253
|
+
if (present) {
|
|
254
|
+
seenEarlier = true;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
id: stage.id,
|
|
258
|
+
status: stage.status,
|
|
259
|
+
sourceCount: stage.sourceCount,
|
|
260
|
+
present,
|
|
261
|
+
rank: candidate?.rank ?? null,
|
|
262
|
+
score: candidate?.score ?? null,
|
|
263
|
+
survived: present,
|
|
264
|
+
dropReason,
|
|
265
|
+
reason: stage.reason,
|
|
266
|
+
};
|
|
267
|
+
}) ?? [];
|
|
268
|
+
|
|
269
|
+
const firstMatched = trace?.stages
|
|
270
|
+
.flatMap((stage) => stage.candidates)
|
|
271
|
+
.find(
|
|
272
|
+
(candidate) =>
|
|
273
|
+
candidate.mirrorHash === doc.mirrorHash && targetSeqs.has(candidate.seq)
|
|
274
|
+
);
|
|
275
|
+
const matchedChunk =
|
|
276
|
+
chunks.find((chunk) => chunk.seq === firstMatched?.seq) ??
|
|
277
|
+
chunks[0] ??
|
|
278
|
+
null;
|
|
279
|
+
|
|
280
|
+
return ok({
|
|
281
|
+
...buildBaseResult(query, options.target, "diagnosed", doc, {
|
|
282
|
+
graphHints,
|
|
283
|
+
chunkCount: chunks.length,
|
|
284
|
+
fingerprintMatches,
|
|
285
|
+
}),
|
|
286
|
+
stages,
|
|
287
|
+
chunk: {
|
|
288
|
+
seq: matchedChunk?.seq ?? null,
|
|
289
|
+
startLine: matchedChunk?.startLine ?? null,
|
|
290
|
+
endLine: matchedChunk?.endLine ?? null,
|
|
291
|
+
language: matchedChunk?.language ?? null,
|
|
292
|
+
},
|
|
293
|
+
meta: {
|
|
294
|
+
mode:
|
|
295
|
+
searchResult.value.meta.mode === "bm25_only" ? "bm25_only" : "hybrid",
|
|
296
|
+
vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
|
|
297
|
+
reranked: searchResult.value.meta.reranked ?? false,
|
|
298
|
+
totalResults: searchResult.value.meta.totalResults,
|
|
299
|
+
queryModes: searchResult.value.meta.queryModes,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared query filter evaluation for live query assembly and diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* @module src/pipeline/filters
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChunkRow, DocumentRow, StorePort } from "../store/types";
|
|
8
|
+
import type { HybridSearchOptions } from "./types";
|
|
9
|
+
|
|
10
|
+
import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
|
|
11
|
+
import { isWithinTemporalRange, resolveTemporalRange } from "./temporal";
|
|
12
|
+
|
|
13
|
+
export interface QueryFilterEvaluation {
|
|
14
|
+
matches: boolean;
|
|
15
|
+
reasons: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function evaluateDocumentChunkFilters(
|
|
19
|
+
query: string,
|
|
20
|
+
doc: DocumentRow,
|
|
21
|
+
chunks: ChunkRow[],
|
|
22
|
+
options: HybridSearchOptions
|
|
23
|
+
): QueryFilterEvaluation {
|
|
24
|
+
const reasons: string[] = [];
|
|
25
|
+
const temporalRange = resolveTemporalRange(
|
|
26
|
+
query,
|
|
27
|
+
options.since,
|
|
28
|
+
options.until
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (options.collection && doc.collection !== options.collection) {
|
|
32
|
+
reasons.push("collection");
|
|
33
|
+
}
|
|
34
|
+
if (!isWithinTemporalRange(doc.sourceMtime, temporalRange)) {
|
|
35
|
+
reasons.push("date");
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
options.author &&
|
|
39
|
+
!doc.author?.toLowerCase().includes(options.author.toLowerCase())
|
|
40
|
+
) {
|
|
41
|
+
reasons.push("author");
|
|
42
|
+
}
|
|
43
|
+
if (options.categories?.length) {
|
|
44
|
+
const allowed = new Set(options.categories.map((c) => c.toLowerCase()));
|
|
45
|
+
const contentTypeMatch = doc.contentType
|
|
46
|
+
? allowed.has(doc.contentType.toLowerCase())
|
|
47
|
+
: false;
|
|
48
|
+
const categoryMatch = (doc.categories ?? []).some((c) =>
|
|
49
|
+
allowed.has(c.toLowerCase())
|
|
50
|
+
);
|
|
51
|
+
if (!contentTypeMatch && !categoryMatch) {
|
|
52
|
+
reasons.push("category");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
options.lang &&
|
|
57
|
+
!chunks.some((chunk) => chunk.language === options.lang)
|
|
58
|
+
) {
|
|
59
|
+
reasons.push("lang");
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
matchesExcludedText(
|
|
63
|
+
[
|
|
64
|
+
doc.title ?? "",
|
|
65
|
+
doc.relPath,
|
|
66
|
+
doc.author ?? "",
|
|
67
|
+
doc.contentType ?? "",
|
|
68
|
+
...(doc.categories ?? []),
|
|
69
|
+
],
|
|
70
|
+
options.exclude
|
|
71
|
+
) ||
|
|
72
|
+
matchesExcludedChunks(chunks, options.exclude)
|
|
73
|
+
) {
|
|
74
|
+
reasons.push("exclude");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
matches: reasons.length === 0,
|
|
79
|
+
reasons,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function evaluateQueryTargetFilters(
|
|
84
|
+
store: StorePort,
|
|
85
|
+
query: string,
|
|
86
|
+
doc: DocumentRow,
|
|
87
|
+
chunks: ChunkRow[],
|
|
88
|
+
options: HybridSearchOptions
|
|
89
|
+
): Promise<QueryFilterEvaluation> {
|
|
90
|
+
const reasons = [
|
|
91
|
+
...evaluateDocumentChunkFilters(query, doc, chunks, options).reasons,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (options.tagsAll?.length || options.tagsAny?.length) {
|
|
95
|
+
const tagsResult = await store.getTagsForDoc(doc.id);
|
|
96
|
+
if (!tagsResult.ok) {
|
|
97
|
+
reasons.push("tags");
|
|
98
|
+
} else {
|
|
99
|
+
const docTags = new Set(tagsResult.value.map((tag) => tag.tag));
|
|
100
|
+
if (
|
|
101
|
+
options.tagsAll?.length &&
|
|
102
|
+
!options.tagsAll.every((tag) => docTags.has(tag))
|
|
103
|
+
) {
|
|
104
|
+
reasons.push("tagsAll");
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
options.tagsAny?.length &&
|
|
108
|
+
!options.tagsAny.some((tag) => docTags.has(tag))
|
|
109
|
+
) {
|
|
110
|
+
reasons.push("tagsAny");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
matches: reasons.length === 0,
|
|
117
|
+
reasons,
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
ExplainLine,
|
|
15
15
|
HybridSearchOptions,
|
|
16
16
|
PipelineConfig,
|
|
17
|
+
QueryDiagnoseTrace,
|
|
18
|
+
QueryDiagnoseTraceCandidate,
|
|
17
19
|
SearchResult,
|
|
18
20
|
SearchResults,
|
|
19
21
|
} from "./types";
|
|
@@ -22,7 +24,6 @@ import { embedTextsWithRecovery } from "../embed/batch";
|
|
|
22
24
|
import { err, ok } from "../store/types";
|
|
23
25
|
import { createChunkLookup } from "./chunk-lookup";
|
|
24
26
|
import { formatQueryForEmbedding } from "./contextual";
|
|
25
|
-
import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
|
|
26
27
|
import { expandQuery } from "./expansion";
|
|
27
28
|
import {
|
|
28
29
|
buildExplainResults,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
explainTimings,
|
|
37
38
|
explainVector,
|
|
38
39
|
} from "./explain";
|
|
40
|
+
import { evaluateDocumentChunkFilters } from "./filters";
|
|
39
41
|
import { type RankedInput, rrfFuse, toRankedInput } from "./fusion";
|
|
40
42
|
import { expandGraphCandidates } from "./graph-retrieval";
|
|
41
43
|
import { selectBestChunkForSteering } from "./intent";
|
|
@@ -153,6 +155,7 @@ async function checkBm25Strength(
|
|
|
153
155
|
interface ChunkId {
|
|
154
156
|
mirrorHash: string;
|
|
155
157
|
seq: number;
|
|
158
|
+
score?: number;
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
type FtsChunksResult =
|
|
@@ -196,6 +199,7 @@ async function searchFtsChunks(
|
|
|
196
199
|
chunks: result.value.map((r) => ({
|
|
197
200
|
mirrorHash: r.mirrorHash,
|
|
198
201
|
seq: r.seq,
|
|
202
|
+
score: r.score,
|
|
199
203
|
})),
|
|
200
204
|
};
|
|
201
205
|
}
|
|
@@ -236,6 +240,32 @@ async function searchVectorChunks(
|
|
|
236
240
|
return searchResult.value.map((r) => ({
|
|
237
241
|
mirrorHash: r.mirrorHash,
|
|
238
242
|
seq: r.seq,
|
|
243
|
+
score: r.distance,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function toTraceCandidates(chunks: ChunkId[]): QueryDiagnoseTraceCandidate[] {
|
|
248
|
+
return chunks.map((chunk, index) => ({
|
|
249
|
+
mirrorHash: chunk.mirrorHash,
|
|
250
|
+
seq: chunk.seq,
|
|
251
|
+
rank: index + 1,
|
|
252
|
+
score: chunk.score ?? index + 1,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function candidatesToTrace(
|
|
257
|
+
candidates: Array<{
|
|
258
|
+
mirrorHash: string;
|
|
259
|
+
seq: number;
|
|
260
|
+
fusionScore?: number;
|
|
261
|
+
blendedScore?: number;
|
|
262
|
+
}>
|
|
263
|
+
): QueryDiagnoseTraceCandidate[] {
|
|
264
|
+
return candidates.map((candidate, index) => ({
|
|
265
|
+
mirrorHash: candidate.mirrorHash,
|
|
266
|
+
seq: candidate.seq,
|
|
267
|
+
rank: index + 1,
|
|
268
|
+
score: candidate.blendedScore ?? candidate.fusionScore ?? index + 1,
|
|
239
269
|
}));
|
|
240
270
|
}
|
|
241
271
|
|
|
@@ -377,6 +407,9 @@ export async function searchHybrid(
|
|
|
377
407
|
// 2. Parallel retrieval using raw store/vector APIs for correct seq tracking
|
|
378
408
|
// ─────────────────────────────────────────────────────────────────────────
|
|
379
409
|
const rankedInputs: RankedInput[] = [];
|
|
410
|
+
const diagnoseTrace: QueryDiagnoseTrace | undefined = options.diagnoseTrace
|
|
411
|
+
? { stages: [] }
|
|
412
|
+
: undefined;
|
|
380
413
|
|
|
381
414
|
const bm25StartedAt = performance.now();
|
|
382
415
|
|
|
@@ -401,6 +434,12 @@ export async function searchHybrid(
|
|
|
401
434
|
|
|
402
435
|
const bm25Chunks = bm25Result.ok ? bm25Result.chunks : [];
|
|
403
436
|
const bm25Count = bm25Chunks.length;
|
|
437
|
+
diagnoseTrace?.stages.push({
|
|
438
|
+
id: "bm25",
|
|
439
|
+
status: "active",
|
|
440
|
+
sourceCount: 1,
|
|
441
|
+
candidates: toTraceCandidates(bm25Chunks),
|
|
442
|
+
});
|
|
404
443
|
if (bm25Count > 0) {
|
|
405
444
|
rankedInputs.push(toRankedInput("bm25", bm25Chunks));
|
|
406
445
|
}
|
|
@@ -446,6 +485,7 @@ export async function searchHybrid(
|
|
|
446
485
|
}
|
|
447
486
|
|
|
448
487
|
const vectorStartedAt = performance.now();
|
|
488
|
+
const vectorTraceChunks: ChunkId[] = [];
|
|
449
489
|
|
|
450
490
|
if (vectorAvailable && vectorIndex && embedPort) {
|
|
451
491
|
const vectorVariantQueries = [
|
|
@@ -469,6 +509,7 @@ export async function searchHybrid(
|
|
|
469
509
|
);
|
|
470
510
|
|
|
471
511
|
vecCount = vecChunks.length;
|
|
512
|
+
vectorTraceChunks.push(...vecChunks);
|
|
472
513
|
if (vecCount > 0) {
|
|
473
514
|
rankedInputs.push(toRankedInput("vector", vecChunks));
|
|
474
515
|
}
|
|
@@ -520,6 +561,7 @@ export async function searchHybrid(
|
|
|
520
561
|
if (variant.source === "vector") {
|
|
521
562
|
vecCount = chunks.length;
|
|
522
563
|
}
|
|
564
|
+
vectorTraceChunks.push(...chunks);
|
|
523
565
|
if (chunks.length === 0) {
|
|
524
566
|
continue;
|
|
525
567
|
}
|
|
@@ -530,6 +572,14 @@ export async function searchHybrid(
|
|
|
530
572
|
}
|
|
531
573
|
timings.vectorMs = performance.now() - vectorStartedAt;
|
|
532
574
|
|
|
575
|
+
diagnoseTrace?.stages.push({
|
|
576
|
+
id: "vector",
|
|
577
|
+
status: vectorAvailable ? "active" : "skipped",
|
|
578
|
+
reason: vectorAvailable ? undefined : "vector_unavailable",
|
|
579
|
+
sourceCount: vectorAvailable ? 1 : 0,
|
|
580
|
+
candidates: toTraceCandidates(vectorTraceChunks),
|
|
581
|
+
});
|
|
582
|
+
|
|
533
583
|
explainLines.push(
|
|
534
584
|
explainVector(
|
|
535
585
|
vecCount,
|
|
@@ -546,6 +596,12 @@ export async function searchHybrid(
|
|
|
546
596
|
const candidateLimit =
|
|
547
597
|
options.candidateLimit ?? pipelineConfig.rerankCandidates;
|
|
548
598
|
let fusedCandidates = rrfFuse(rankedInputs, pipelineConfig.rrf);
|
|
599
|
+
diagnoseTrace?.stages.push({
|
|
600
|
+
id: "fusion",
|
|
601
|
+
status: "active",
|
|
602
|
+
sourceCount: rankedInputs.length,
|
|
603
|
+
candidates: candidatesToTrace(fusedCandidates),
|
|
604
|
+
});
|
|
549
605
|
|
|
550
606
|
timings.fusionMs = performance.now() - fusionStartedAt;
|
|
551
607
|
const graphStartedAt = performance.now();
|
|
@@ -570,6 +626,20 @@ export async function searchHybrid(
|
|
|
570
626
|
fusedCandidates = rrfFuse(rankedInputs, pipelineConfig.rrf);
|
|
571
627
|
timings.fusionMs += performance.now() - graphFusionStartedAt;
|
|
572
628
|
}
|
|
629
|
+
diagnoseTrace?.stages.push({
|
|
630
|
+
id: "graph",
|
|
631
|
+
status: graphExpansion.meta.attempted ? "active" : "skipped",
|
|
632
|
+
reason: graphExpansion.meta.attempted
|
|
633
|
+
? undefined
|
|
634
|
+
: graphExpansion.meta.fallbackReasons.join(", ") || "disabled",
|
|
635
|
+
sourceCount: graphExpansion.meta.attempted ? 1 : 0,
|
|
636
|
+
candidates: graphExpansion.candidates.map((candidate, index) => ({
|
|
637
|
+
mirrorHash: candidate.mirrorHash,
|
|
638
|
+
seq: candidate.seq,
|
|
639
|
+
rank: index + 1,
|
|
640
|
+
score: index + 1,
|
|
641
|
+
})),
|
|
642
|
+
});
|
|
573
643
|
if (graphExpansion.meta.fallbackReasons.length > 0) {
|
|
574
644
|
counters.fallbackEvents.push(...graphExpansion.meta.fallbackReasons);
|
|
575
645
|
}
|
|
@@ -604,6 +674,16 @@ export async function searchHybrid(
|
|
|
604
674
|
}
|
|
605
675
|
timings.rerankMs = performance.now() - rerankStartedAt;
|
|
606
676
|
|
|
677
|
+
diagnoseTrace?.stages.push({
|
|
678
|
+
id: "rerank",
|
|
679
|
+
status: rerankResult.reranked ? "active" : "skipped",
|
|
680
|
+
reason: rerankResult.reranked
|
|
681
|
+
? undefined
|
|
682
|
+
: (rerankResult.fallbackReason ?? "disabled"),
|
|
683
|
+
sourceCount: rerankResult.reranked ? 1 : 0,
|
|
684
|
+
candidates: candidatesToTrace(rerankResult.candidates),
|
|
685
|
+
});
|
|
686
|
+
|
|
607
687
|
explainLines.push(
|
|
608
688
|
explainRerank(!options.noRerank && rerankPort !== null, candidateLimit)
|
|
609
689
|
);
|
|
@@ -754,22 +834,14 @@ export async function searchHybrid(
|
|
|
754
834
|
continue;
|
|
755
835
|
}
|
|
756
836
|
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
],
|
|
766
|
-
options.exclude
|
|
767
|
-
) ||
|
|
768
|
-
matchesExcludedChunks(
|
|
769
|
-
chunksMap.get(candidate.mirrorHash) ?? [],
|
|
770
|
-
options.exclude
|
|
771
|
-
);
|
|
772
|
-
if (excluded) {
|
|
837
|
+
const docChunks = chunksMap.get(candidate.mirrorHash) ?? [];
|
|
838
|
+
const filterEval = evaluateDocumentChunkFilters(
|
|
839
|
+
query,
|
|
840
|
+
doc,
|
|
841
|
+
docChunks,
|
|
842
|
+
options
|
|
843
|
+
);
|
|
844
|
+
if (!filterEval.matches) {
|
|
773
845
|
continue;
|
|
774
846
|
}
|
|
775
847
|
|
|
@@ -841,6 +913,8 @@ export async function searchHybrid(
|
|
|
841
913
|
score: candidate.blendedScore,
|
|
842
914
|
uri: doc.uri,
|
|
843
915
|
title: doc.title ?? undefined,
|
|
916
|
+
contentType: doc.contentType ?? undefined,
|
|
917
|
+
categories: doc.categories ?? undefined,
|
|
844
918
|
line: snippetChunk.startLine,
|
|
845
919
|
snippet,
|
|
846
920
|
snippetLanguage: chunk.language ?? undefined,
|
|
@@ -933,6 +1007,7 @@ export async function searchHybrid(
|
|
|
933
1007
|
queryLanguage,
|
|
934
1008
|
queryModes: queryModeSummary,
|
|
935
1009
|
explain: explainData,
|
|
1010
|
+
trace: diagnoseTrace,
|
|
936
1011
|
},
|
|
937
1012
|
});
|
|
938
1013
|
}
|
package/src/pipeline/search.ts
CHANGED
|
@@ -117,6 +117,8 @@ function buildSearchResult(ctx: BuildResultContext): SearchResult {
|
|
|
117
117
|
score: fts.score, // Raw score, normalized later as batch
|
|
118
118
|
uri: fts.uri ?? "",
|
|
119
119
|
title: fts.title,
|
|
120
|
+
contentType: fts.contentType,
|
|
121
|
+
categories: fts.categories,
|
|
120
122
|
line: chunk?.startLine,
|
|
121
123
|
snippet,
|
|
122
124
|
snippetLanguage: chunk?.language ?? undefined,
|