@gmickel/gno 1.9.0 → 1.10.1
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/assets/skill/SKILL.md +28 -16
- package/assets/skill/cli-reference.md +30 -0
- package/assets/skill/examples.md +20 -0
- package/assets/skill/mcp-reference.md +7 -1
- package/package.json +1 -1
- package/src/cli/commands/graph.ts +89 -2
- 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/options.ts +4 -0
- package/src/cli/program.ts +176 -9
- package/src/config/content-types.ts +14 -0
- package/src/core/file-lock.ts +38 -3
- package/src/core/graph-query.ts +137 -0
- package/src/core/graph-resolver.ts +117 -0
- package/src/core/ref-parser.ts +145 -0
- package/src/ingestion/frontmatter.ts +95 -2
- package/src/ingestion/sync.ts +281 -1
- package/src/mcp/tools/get.ts +1 -1
- package/src/mcp/tools/index.ts +89 -1
- 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/pipeline/diagnose.ts +302 -0
- package/src/pipeline/filters.ts +119 -0
- package/src/pipeline/hybrid.ts +90 -17
- package/src/pipeline/types.ts +32 -0
- package/src/publish/export-service.ts +1 -1
- package/src/sdk/documents.ts +2 -2
- package/src/serve/routes/api.ts +194 -0
- package/src/serve/routes/graph.ts +173 -1
- package/src/serve/server.ts +24 -1
- 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 +4 -0
- package/src/store/sqlite/adapter.ts +826 -102
- package/src/store/types.ts +141 -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
|
|
|
@@ -935,6 +1007,7 @@ export async function searchHybrid(
|
|
|
935
1007
|
queryLanguage,
|
|
936
1008
|
queryModes: queryModeSummary,
|
|
937
1009
|
explain: explainData,
|
|
1010
|
+
trace: diagnoseTrace,
|
|
938
1011
|
},
|
|
939
1012
|
});
|
|
940
1013
|
}
|
package/src/pipeline/types.ts
CHANGED
|
@@ -104,6 +104,8 @@ export interface SearchMeta {
|
|
|
104
104
|
lines: ExplainLine[];
|
|
105
105
|
results: ExplainResult[];
|
|
106
106
|
};
|
|
107
|
+
/** Internal diagnose trace, only populated when diagnoseTrace is enabled */
|
|
108
|
+
trace?: QueryDiagnoseTrace;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
/** Complete search results wrapper */
|
|
@@ -182,6 +184,8 @@ export type HybridSearchOptions = SearchOptions & {
|
|
|
182
184
|
noGraph?: boolean;
|
|
183
185
|
/** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
|
|
184
186
|
queryLanguageHint?: string;
|
|
187
|
+
/** Internal: capture per-stage candidates for query diagnose */
|
|
188
|
+
diagnoseTrace?: boolean;
|
|
185
189
|
};
|
|
186
190
|
|
|
187
191
|
/** Options for ask command */
|
|
@@ -258,6 +262,34 @@ export interface FusionCandidate {
|
|
|
258
262
|
sources: FusionSource[];
|
|
259
263
|
}
|
|
260
264
|
|
|
265
|
+
export type QueryDiagnoseStageId =
|
|
266
|
+
| "bm25"
|
|
267
|
+
| "vector"
|
|
268
|
+
| "fusion"
|
|
269
|
+
| "graph"
|
|
270
|
+
| "rerank";
|
|
271
|
+
|
|
272
|
+
export type QueryDiagnoseStageStatus = "active" | "skipped";
|
|
273
|
+
|
|
274
|
+
export interface QueryDiagnoseTraceCandidate {
|
|
275
|
+
mirrorHash: string;
|
|
276
|
+
seq: number;
|
|
277
|
+
rank: number;
|
|
278
|
+
score: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface QueryDiagnoseTraceStage {
|
|
282
|
+
id: QueryDiagnoseStageId;
|
|
283
|
+
status: QueryDiagnoseStageStatus;
|
|
284
|
+
reason?: string;
|
|
285
|
+
sourceCount: number;
|
|
286
|
+
candidates: QueryDiagnoseTraceCandidate[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface QueryDiagnoseTrace {
|
|
290
|
+
stages: QueryDiagnoseTraceStage[];
|
|
291
|
+
}
|
|
292
|
+
|
|
261
293
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
262
294
|
// Rerank & Blending Types
|
|
263
295
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { Collection } from "../config/types";
|
|
8
8
|
import type { DocumentRow, StorePort, TagRow } from "../store/types";
|
|
9
9
|
|
|
10
|
-
import { parseRef } from "../
|
|
10
|
+
import { parseRef } from "../core/ref-parser";
|
|
11
11
|
import { parseFrontmatter } from "../ingestion/frontmatter";
|
|
12
12
|
import { getContentBatch } from "../store/content-batch";
|
|
13
13
|
import {
|