@gmickel/gno 1.22.0 → 1.23.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 +16 -1
- package/assets/skill/SKILL.md +15 -0
- package/package.json +1 -1
- package/spec/cli.md +36 -20
- package/spec/evals-agentic.md +35 -0
- package/spec/evals.md +6 -0
- package/spec/mcp.md +18 -0
- package/spec/output-schemas/query-diagnose-v1.schema.json +123 -0
- package/spec/output-schemas/query-diagnose.schema.json +89 -2
- package/src/app/context-runtime-types.ts +3 -0
- package/src/app/context-runtime.ts +1 -0
- package/src/app/context-surface.ts +4 -2
- package/src/cli/commands/ask.ts +31 -20
- package/src/cli/commands/context-build.ts +17 -7
- package/src/cli/commands/query.ts +58 -37
- package/src/cli/commands/search.ts +29 -19
- package/src/cli/commands/vsearch.ts +31 -22
- package/src/cli/options.ts +39 -0
- package/src/cli/program.ts +48 -0
- package/src/config/defaults.ts +10 -1
- package/src/config/types.ts +71 -0
- package/src/core/project-affinity-surface.ts +114 -0
- package/src/core/project-affinity.ts +330 -0
- package/src/core/validation.ts +20 -1
- package/src/mcp/tools/ask.ts +10 -1
- package/src/mcp/tools/context.ts +18 -0
- package/src/mcp/tools/index.ts +13 -2
- package/src/mcp/tools/query.ts +12 -0
- package/src/mcp/tools/search.ts +7 -0
- package/src/mcp/tools/vsearch.ts +7 -0
- package/src/pipeline/diagnose.ts +48 -3
- package/src/pipeline/explain.ts +54 -13
- package/src/pipeline/hybrid.ts +100 -59
- package/src/pipeline/project-affinity.ts +162 -0
- package/src/pipeline/search.ts +76 -10
- package/src/pipeline/types.ts +9 -0
- package/src/pipeline/vsearch.ts +117 -91
- package/src/sdk/client.ts +80 -20
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +20 -7
- package/src/serve/context-capsule.ts +18 -1
- package/src/serve/routes/api.ts +69 -0
package/src/pipeline/explain.ts
CHANGED
|
@@ -11,8 +11,11 @@ import type {
|
|
|
11
11
|
ExplainResult,
|
|
12
12
|
QueryModeSummary,
|
|
13
13
|
RerankedCandidate,
|
|
14
|
+
SearchResult,
|
|
14
15
|
} from "./types";
|
|
15
16
|
|
|
17
|
+
import { SEARCH_RESULT_PLANNER_METADATA } from "./types";
|
|
18
|
+
|
|
16
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
20
|
// Formatter
|
|
18
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -35,7 +38,8 @@ export function formatResultExplain(results: ExplainResult[]): string {
|
|
|
35
38
|
r.fusionScore !== undefined ||
|
|
36
39
|
r.bm25Score !== undefined ||
|
|
37
40
|
r.vecScore !== undefined ||
|
|
38
|
-
r.rerankScore !== undefined
|
|
41
|
+
r.rerankScore !== undefined ||
|
|
42
|
+
r.projectAffinity !== undefined
|
|
39
43
|
) {
|
|
40
44
|
msg += " (";
|
|
41
45
|
if (r.fusionScore !== undefined) {
|
|
@@ -59,6 +63,12 @@ export function formatResultExplain(results: ExplainResult[]): string {
|
|
|
59
63
|
}
|
|
60
64
|
msg += `rerank=${r.rerankScore.toFixed(2)}`;
|
|
61
65
|
}
|
|
66
|
+
if (r.projectAffinity) {
|
|
67
|
+
if (msg.at(-1) !== "(") {
|
|
68
|
+
msg += ", ";
|
|
69
|
+
}
|
|
70
|
+
msg += `raw=${r.projectAffinity.rawScoreKind}:${r.projectAffinity.rawScore.toFixed(3)}, base=${r.projectAffinity.baseScore.toFixed(3)}, affinity=${r.projectAffinity.affinityApplied.toFixed(3)}/${r.projectAffinity.affinityRequested.toFixed(3)}, auxiliary=${r.projectAffinity.combinedAuxiliaryApplied.toFixed(3)}/${r.projectAffinity.combinedAuxiliaryCap.toFixed(3)}, collection=${r.projectAffinity.collectionAlias}, root=${r.projectAffinity.rootAlias}, source=${r.projectAffinity.source}, final=${r.projectAffinity.finalScore.toFixed(3)}`;
|
|
71
|
+
}
|
|
62
72
|
msg += ")";
|
|
63
73
|
}
|
|
64
74
|
lines.push(`[explain] result ${r.rank}: ${r.docid} ${msg}`);
|
|
@@ -206,18 +216,49 @@ export function explainTimings(timings: StageTimingsInput): ExplainLine {
|
|
|
206
216
|
|
|
207
217
|
export function buildExplainResults(
|
|
208
218
|
candidates: RerankedCandidate[],
|
|
209
|
-
docidMap: Map<string, string
|
|
219
|
+
docidMap: Map<string, string>,
|
|
220
|
+
finalResults?: SearchResult[]
|
|
210
221
|
): ExplainResult[] {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
if (finalResults) {
|
|
223
|
+
return finalResults.slice(0, 20).map((result, index) => {
|
|
224
|
+
const planner = result[SEARCH_RESULT_PLANNER_METADATA];
|
|
225
|
+
const candidate = candidates.find(
|
|
226
|
+
(entry) =>
|
|
227
|
+
entry.mirrorHash === result.conversion?.mirrorHash &&
|
|
228
|
+
entry.seq === (planner?.retrievalSeq ?? planner?.seq)
|
|
229
|
+
);
|
|
230
|
+
return buildExplainResult(result.docid, result.score, index, candidate);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return candidates.slice(0, 20).map((candidate, index) => {
|
|
234
|
+
const key = `${candidate.mirrorHash}:${candidate.seq}`;
|
|
235
|
+
return buildExplainResult(
|
|
236
|
+
docidMap.get(key) ?? "#unknown",
|
|
237
|
+
candidate.blendedScore,
|
|
238
|
+
index,
|
|
239
|
+
candidate
|
|
240
|
+
);
|
|
222
241
|
});
|
|
223
242
|
}
|
|
243
|
+
|
|
244
|
+
function buildExplainResult(
|
|
245
|
+
docid: string,
|
|
246
|
+
score: number,
|
|
247
|
+
index: number,
|
|
248
|
+
candidate?: RerankedCandidate
|
|
249
|
+
): ExplainResult {
|
|
250
|
+
if (!candidate) {
|
|
251
|
+
return { rank: index + 1, docid, score };
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
rank: index + 1,
|
|
255
|
+
docid,
|
|
256
|
+
score,
|
|
257
|
+
fusionScore: candidate.fusionScore,
|
|
258
|
+
bm25Score:
|
|
259
|
+
candidate.bm25Rank !== null ? 1 / (60 + candidate.bm25Rank) : undefined,
|
|
260
|
+
vecScore:
|
|
261
|
+
candidate.vecRank !== null ? 1 / (60 + candidate.vecRank) : undefined,
|
|
262
|
+
rerankScore: candidate.rerankScore ?? undefined,
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -41,6 +41,11 @@ import { evaluateDocumentChunkFilters } from "./filters";
|
|
|
41
41
|
import { type RankedInput, rrfFuse, toRankedInput } from "./fusion";
|
|
42
42
|
import { expandGraphCandidates } from "./graph-retrieval";
|
|
43
43
|
import { selectBestChunkForSteering } from "./intent";
|
|
44
|
+
import {
|
|
45
|
+
applyProjectAffinity,
|
|
46
|
+
getProjectAffinityMetadata,
|
|
47
|
+
hasProjectAffinity,
|
|
48
|
+
} from "./project-affinity";
|
|
44
49
|
import { detectQueryLanguage } from "./query-language";
|
|
45
50
|
import {
|
|
46
51
|
buildExpansionFromQueryModes,
|
|
@@ -301,6 +306,7 @@ export async function searchHybrid(
|
|
|
301
306
|
const runStartedAt = performance.now();
|
|
302
307
|
const { store, vectorIndex, embedPort, expandPort, rerankPort } = deps;
|
|
303
308
|
const pipelineConfig = deps.pipelineConfig ?? DEFAULT_PIPELINE_CONFIG;
|
|
309
|
+
const affinityActive = hasProjectAffinity(options.projectAffinity);
|
|
304
310
|
|
|
305
311
|
const limit = options.limit ?? 20;
|
|
306
312
|
const recencySort = shouldSortByRecency(query);
|
|
@@ -719,7 +725,7 @@ export async function searchHybrid(
|
|
|
719
725
|
// ─────────────────────────────────────────────────────────────────────────
|
|
720
726
|
const minScore = options.minScore ?? 0;
|
|
721
727
|
const filteredCandidates =
|
|
722
|
-
minScore > 0
|
|
728
|
+
minScore > 0 && !affinityActive
|
|
723
729
|
? rerankResult.candidates.filter((c) => c.blendedScore >= minScore)
|
|
724
730
|
: rerankResult.candidates;
|
|
725
731
|
|
|
@@ -875,7 +881,7 @@ export async function searchHybrid(
|
|
|
875
881
|
// Iterate until we have enough results (don't slice early - deduping may skip candidates)
|
|
876
882
|
for (const [candidateIndex, candidate] of filteredCandidates.entries()) {
|
|
877
883
|
// Stop when we have enough results
|
|
878
|
-
if (results.length >= assemblyLimit) {
|
|
884
|
+
if (!affinityActive && results.length >= assemblyLimit) {
|
|
879
885
|
break;
|
|
880
886
|
}
|
|
881
887
|
|
|
@@ -939,64 +945,76 @@ export async function searchHybrid(
|
|
|
939
945
|
}
|
|
940
946
|
|
|
941
947
|
for (const doc of candidateDocs) {
|
|
942
|
-
if (results.length >= assemblyLimit) break;
|
|
948
|
+
if (!affinityActive && results.length >= assemblyLimit) break;
|
|
943
949
|
const filterEval = evaluateDocumentChunkFilters(
|
|
944
950
|
query,
|
|
945
951
|
doc,
|
|
946
952
|
docChunks,
|
|
947
953
|
options
|
|
948
954
|
);
|
|
949
|
-
if (
|
|
955
|
+
if (
|
|
956
|
+
!filterEval.matches ||
|
|
957
|
+
(options.full && !affinityActive && seenDocids.has(doc.docid))
|
|
958
|
+
) {
|
|
950
959
|
continue;
|
|
951
960
|
}
|
|
952
961
|
const docidKey = `${candidate.mirrorHash}:${candidate.seq}`;
|
|
953
962
|
if (!docidMap.has(docidKey)) docidMap.set(docidKey, doc.docid);
|
|
954
963
|
const collectionPath = collectionPaths.get(doc.collection);
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
conversion: {
|
|
982
|
-
mirrorHash: candidate.mirrorHash,
|
|
983
|
-
converterId: doc.converterId ?? undefined,
|
|
984
|
-
converterVersion: doc.converterVersion ?? undefined,
|
|
985
|
-
},
|
|
964
|
+
if (options.full && !affinityActive) {
|
|
965
|
+
seenDocids.add(doc.docid);
|
|
966
|
+
}
|
|
967
|
+
const scoredResult = applyProjectAffinity(
|
|
968
|
+
{
|
|
969
|
+
docid: doc.docid,
|
|
970
|
+
score: candidate.blendedScore,
|
|
971
|
+
uri: doc.uri,
|
|
972
|
+
title: doc.title ?? undefined,
|
|
973
|
+
contentType: doc.contentType ?? undefined,
|
|
974
|
+
categories: doc.categories ?? undefined,
|
|
975
|
+
line: snippetChunk.startLine,
|
|
976
|
+
snippet,
|
|
977
|
+
snippetLanguage: chunk.language ?? undefined,
|
|
978
|
+
snippetRange,
|
|
979
|
+
source: {
|
|
980
|
+
relPath: doc.relPath,
|
|
981
|
+
absPath: collectionPath
|
|
982
|
+
? `${collectionPath}/${doc.relPath}`
|
|
983
|
+
: undefined,
|
|
984
|
+
mime: doc.sourceMime,
|
|
985
|
+
ext: doc.sourceExt,
|
|
986
|
+
modifiedAt: doc.sourceMtime,
|
|
987
|
+
documentDate: doc.frontmatterDate ?? undefined,
|
|
988
|
+
sizeBytes: doc.sourceSize,
|
|
989
|
+
sourceHash: doc.sourceHash,
|
|
986
990
|
},
|
|
987
|
-
{
|
|
988
|
-
retrievalRank: candidateIndex + 1,
|
|
991
|
+
conversion: {
|
|
989
992
|
mirrorHash: candidate.mirrorHash,
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
993
|
+
converterId: doc.converterId ?? undefined,
|
|
994
|
+
converterVersion: doc.converterVersion ?? undefined,
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
doc.collection,
|
|
998
|
+
options.projectAffinity,
|
|
999
|
+
{ kind: "hybrid_blended", score: candidate.blendedScore }
|
|
1000
|
+
);
|
|
1001
|
+
if (scoredResult.score < minScore) continue;
|
|
1002
|
+
results.push(
|
|
1003
|
+
attachSearchResultPlannerMetadata(scoredResult, {
|
|
1004
|
+
retrievalRank: candidateIndex + 1,
|
|
1005
|
+
mirrorHash: candidate.mirrorHash,
|
|
1006
|
+
seq: snippetChunk.seq,
|
|
1007
|
+
...(affinityActive && snippetChunk.seq !== candidate.seq
|
|
1008
|
+
? { retrievalSeq: candidate.seq }
|
|
1009
|
+
: {}),
|
|
1010
|
+
sources: [...candidate.sources].sort(),
|
|
1011
|
+
graphExpanded: candidate.sources.includes("graph"),
|
|
1012
|
+
startLine: snippetChunk.startLine,
|
|
1013
|
+
endLine: snippetChunk.endLine,
|
|
1014
|
+
passageHash: new Bun.CryptoHasher("sha256")
|
|
1015
|
+
.update(snippetChunk.text)
|
|
1016
|
+
.digest("hex"),
|
|
1017
|
+
})
|
|
1000
1018
|
);
|
|
1001
1019
|
}
|
|
1002
1020
|
}
|
|
@@ -1008,21 +1026,16 @@ export async function searchHybrid(
|
|
|
1008
1026
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1009
1027
|
// 6. Build explain data (if requested)
|
|
1010
1028
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1011
|
-
const explainData = options.explain
|
|
1012
|
-
? {
|
|
1013
|
-
lines: explainLines,
|
|
1014
|
-
results: buildExplainResults(
|
|
1015
|
-
filteredCandidates.slice(0, limit),
|
|
1016
|
-
docidMap
|
|
1017
|
-
),
|
|
1018
|
-
}
|
|
1019
|
-
: undefined;
|
|
1020
|
-
|
|
1021
1029
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1022
1030
|
// 7. Return results
|
|
1023
1031
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1032
|
+
const dedupedResults =
|
|
1033
|
+
options.full && affinityActive
|
|
1034
|
+
? dedupeFullResultsByDocid(results)
|
|
1035
|
+
: results;
|
|
1036
|
+
|
|
1024
1037
|
if (recencySort) {
|
|
1025
|
-
|
|
1038
|
+
dedupedResults.sort((a, b) => {
|
|
1026
1039
|
const aTs = resolveRecencyTimestamp(
|
|
1027
1040
|
a.source.documentDate,
|
|
1028
1041
|
a.source.modifiedAt
|
|
@@ -1036,9 +1049,26 @@ export async function searchHybrid(
|
|
|
1036
1049
|
}
|
|
1037
1050
|
return b.score - a.score;
|
|
1038
1051
|
});
|
|
1052
|
+
} else if (affinityActive) {
|
|
1053
|
+
dedupedResults.sort((a, b) => b.score - a.score);
|
|
1039
1054
|
}
|
|
1040
1055
|
|
|
1041
|
-
const finalResults =
|
|
1056
|
+
const finalResults = dedupedResults.slice(0, limit);
|
|
1057
|
+
const explainData = options.explain
|
|
1058
|
+
? {
|
|
1059
|
+
lines: explainLines,
|
|
1060
|
+
results: affinityActive
|
|
1061
|
+
? buildExplainResults(filteredCandidates, docidMap, finalResults).map(
|
|
1062
|
+
(result, index) => ({
|
|
1063
|
+
...result,
|
|
1064
|
+
projectAffinity: getProjectAffinityMetadata(
|
|
1065
|
+
finalResults[index]!
|
|
1066
|
+
),
|
|
1067
|
+
})
|
|
1068
|
+
)
|
|
1069
|
+
: buildExplainResults(filteredCandidates.slice(0, limit), docidMap),
|
|
1070
|
+
}
|
|
1071
|
+
: undefined;
|
|
1042
1072
|
await attachSearchResultContexts(store, finalResults);
|
|
1043
1073
|
|
|
1044
1074
|
const output: SearchResults = {
|
|
@@ -1143,3 +1173,14 @@ export async function searchHybrid(
|
|
|
1143
1173
|
}
|
|
1144
1174
|
return ok(output);
|
|
1145
1175
|
}
|
|
1176
|
+
|
|
1177
|
+
function dedupeFullResultsByDocid(results: SearchResult[]): SearchResult[] {
|
|
1178
|
+
const bestByDocid = new Map<string, SearchResult>();
|
|
1179
|
+
for (const result of results) {
|
|
1180
|
+
const existing = bestByDocid.get(result.docid);
|
|
1181
|
+
if (!existing || result.score > existing.score) {
|
|
1182
|
+
bestByDocid.set(result.docid, result);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return [...bestByDocid.values()];
|
|
1186
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded auxiliary scoring for trusted project affinity.
|
|
3
|
+
*
|
|
4
|
+
* @module src/pipeline/project-affinity
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ProjectAffinityMatch,
|
|
9
|
+
ProjectAffinityResolution,
|
|
10
|
+
} from "../core/project-affinity";
|
|
11
|
+
import type { SearchResult } from "./types";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AUXILIARY_RANKING_MAX_CONTRIBUTION,
|
|
15
|
+
PROJECT_AFFINITY_MAX_CONTRIBUTION,
|
|
16
|
+
} from "../config/types";
|
|
17
|
+
|
|
18
|
+
export interface ProjectAffinityScoringInput {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
contribution?: number;
|
|
21
|
+
resolution: ProjectAffinityResolution;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProjectAffinityScoreMetadata {
|
|
25
|
+
affinityAdjustedScore: number;
|
|
26
|
+
affinityApplied: number;
|
|
27
|
+
affinityRequested: number;
|
|
28
|
+
affinityWeight: number;
|
|
29
|
+
baseScore: number;
|
|
30
|
+
collectionAlias: string | null;
|
|
31
|
+
combinedAuxiliaryApplied: number;
|
|
32
|
+
combinedAuxiliaryCap: number;
|
|
33
|
+
combinedAuxiliaryRequested: number;
|
|
34
|
+
finalBlendedScore: number;
|
|
35
|
+
finalScore: number;
|
|
36
|
+
matched: boolean;
|
|
37
|
+
rawScore: number;
|
|
38
|
+
rawScoreKind: "bm25" | "hybrid_blended" | "normalized" | "vector_distance";
|
|
39
|
+
rootAlias: string | null;
|
|
40
|
+
source: ProjectAffinityMatch["source"] | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const SEARCH_RESULT_AFFINITY_METADATA = Symbol(
|
|
44
|
+
"gno.searchResultAffinityMetadata"
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
48
|
+
Math.min(max, Math.max(min, value));
|
|
49
|
+
|
|
50
|
+
export function applyAuxiliaryScore(
|
|
51
|
+
baseScore: number,
|
|
52
|
+
contributions: readonly number[]
|
|
53
|
+
): {
|
|
54
|
+
applied: number;
|
|
55
|
+
finalScore: number;
|
|
56
|
+
requested: number;
|
|
57
|
+
} {
|
|
58
|
+
const requested = [...contributions]
|
|
59
|
+
.sort((left, right) => left - right)
|
|
60
|
+
.reduce((total, contribution) => total + contribution, 0);
|
|
61
|
+
const applied = clamp(
|
|
62
|
+
requested,
|
|
63
|
+
-AUXILIARY_RANKING_MAX_CONTRIBUTION,
|
|
64
|
+
AUXILIARY_RANKING_MAX_CONTRIBUTION
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
requested,
|
|
68
|
+
applied,
|
|
69
|
+
finalScore: clamp(baseScore + applied, 0, 1),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const matchingCollection = (
|
|
74
|
+
input: ProjectAffinityScoringInput | undefined,
|
|
75
|
+
collection: string
|
|
76
|
+
): ProjectAffinityMatch | undefined => {
|
|
77
|
+
if (input?.enabled === false) return undefined;
|
|
78
|
+
return input?.resolution.matches.find(
|
|
79
|
+
(match) => match.collection === collection
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function scoreProjectAffinity(
|
|
84
|
+
baseScore: number,
|
|
85
|
+
collection: string,
|
|
86
|
+
input: ProjectAffinityScoringInput | undefined,
|
|
87
|
+
raw: {
|
|
88
|
+
kind: ProjectAffinityScoreMetadata["rawScoreKind"];
|
|
89
|
+
score: number;
|
|
90
|
+
} = { kind: "normalized", score: baseScore }
|
|
91
|
+
): ProjectAffinityScoreMetadata {
|
|
92
|
+
const match = matchingCollection(input, collection);
|
|
93
|
+
const configuredWeight = clamp(
|
|
94
|
+
input?.contribution ?? PROJECT_AFFINITY_MAX_CONTRIBUTION,
|
|
95
|
+
0,
|
|
96
|
+
PROJECT_AFFINITY_MAX_CONTRIBUTION
|
|
97
|
+
);
|
|
98
|
+
const affinityRequested = match ? configuredWeight : 0;
|
|
99
|
+
const auxiliary = applyAuxiliaryScore(baseScore, [affinityRequested]);
|
|
100
|
+
const affinityApplied = auxiliary.finalScore - baseScore;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
affinityAdjustedScore: auxiliary.finalScore,
|
|
104
|
+
affinityApplied,
|
|
105
|
+
affinityRequested,
|
|
106
|
+
affinityWeight: configuredWeight,
|
|
107
|
+
baseScore,
|
|
108
|
+
collectionAlias: match?.collectionAlias ?? null,
|
|
109
|
+
combinedAuxiliaryApplied: auxiliary.applied,
|
|
110
|
+
combinedAuxiliaryCap: AUXILIARY_RANKING_MAX_CONTRIBUTION,
|
|
111
|
+
combinedAuxiliaryRequested: auxiliary.requested,
|
|
112
|
+
finalBlendedScore: auxiliary.finalScore,
|
|
113
|
+
finalScore: auxiliary.finalScore,
|
|
114
|
+
matched: Boolean(match),
|
|
115
|
+
rawScore: raw.score,
|
|
116
|
+
rawScoreKind: raw.kind,
|
|
117
|
+
rootAlias: match?.rootAlias ?? null,
|
|
118
|
+
source: match?.source ?? null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function hasProjectAffinity(
|
|
123
|
+
input: ProjectAffinityScoringInput | undefined
|
|
124
|
+
): boolean {
|
|
125
|
+
return (
|
|
126
|
+
Boolean(input) &&
|
|
127
|
+
input?.enabled !== false &&
|
|
128
|
+
(input?.contribution ?? PROJECT_AFFINITY_MAX_CONTRIBUTION) > 0 &&
|
|
129
|
+
(input?.resolution.matches.length ?? 0) !== 0
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function applyProjectAffinity(
|
|
134
|
+
result: SearchResult,
|
|
135
|
+
collection: string,
|
|
136
|
+
input: ProjectAffinityScoringInput | undefined,
|
|
137
|
+
raw?: {
|
|
138
|
+
kind: ProjectAffinityScoreMetadata["rawScoreKind"];
|
|
139
|
+
score: number;
|
|
140
|
+
}
|
|
141
|
+
): SearchResult {
|
|
142
|
+
if (!hasProjectAffinity(input)) return result;
|
|
143
|
+
const metadata = scoreProjectAffinity(result.score, collection, input, raw);
|
|
144
|
+
result.score = metadata.finalScore;
|
|
145
|
+
Object.defineProperty(result, SEARCH_RESULT_AFFINITY_METADATA, {
|
|
146
|
+
configurable: true,
|
|
147
|
+
enumerable: false,
|
|
148
|
+
value: metadata,
|
|
149
|
+
writable: true,
|
|
150
|
+
});
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getProjectAffinityMetadata(
|
|
155
|
+
result: SearchResult
|
|
156
|
+
): ProjectAffinityScoreMetadata | undefined {
|
|
157
|
+
return (
|
|
158
|
+
result as SearchResult & {
|
|
159
|
+
[SEARCH_RESULT_AFFINITY_METADATA]?: ProjectAffinityScoreMetadata;
|
|
160
|
+
}
|
|
161
|
+
)[SEARCH_RESULT_AFFINITY_METADATA];
|
|
162
|
+
}
|
package/src/pipeline/search.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { err, ok } from "../store/types";
|
|
|
20
20
|
import { createChunkLookup } from "./chunk-lookup";
|
|
21
21
|
import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
|
|
22
22
|
import { selectBestChunkForSteering } from "./intent";
|
|
23
|
+
import { applyProjectAffinity, hasProjectAffinity } from "./project-affinity";
|
|
23
24
|
import { detectQueryLanguage } from "./query-language";
|
|
24
25
|
import { attachSearchResultContexts } from "./result-context";
|
|
25
26
|
import {
|
|
@@ -160,8 +161,9 @@ export async function searchBm25(
|
|
|
160
161
|
const traceStartedAt = options.traceSession ? performance.now() : 0;
|
|
161
162
|
const limit = options.limit ?? 20;
|
|
162
163
|
const minScore = options.minScore ?? 0;
|
|
164
|
+
const affinityActive = hasProjectAffinity(options.projectAffinity);
|
|
163
165
|
const recencySort = shouldSortByRecency(query);
|
|
164
|
-
const retrievalLimit = recencySort ? limit * 3 : limit;
|
|
166
|
+
const retrievalLimit = recencySort || affinityActive ? limit * 3 : limit;
|
|
165
167
|
const temporalRange = resolveTemporalRange(
|
|
166
168
|
query,
|
|
167
169
|
options.since,
|
|
@@ -208,6 +210,10 @@ export async function searchBm25(
|
|
|
208
210
|
|
|
209
211
|
// Build results
|
|
210
212
|
const results: SearchResult[] = [];
|
|
213
|
+
const scoringByResult = new WeakMap<
|
|
214
|
+
SearchResult,
|
|
215
|
+
{ collection: string; rawScore: number }
|
|
216
|
+
>();
|
|
211
217
|
|
|
212
218
|
// Pre-fetch all chunks in one batch query (eliminates N+1)
|
|
213
219
|
const uniqueHashes = [
|
|
@@ -229,6 +235,11 @@ export async function searchBm25(
|
|
|
229
235
|
string,
|
|
230
236
|
{ fts: FtsResult; chunk: ChunkRow | null; score: number }
|
|
231
237
|
>();
|
|
238
|
+
const fullAffinityEntries: {
|
|
239
|
+
fts: FtsResult;
|
|
240
|
+
chunk: ChunkRow | null;
|
|
241
|
+
score: number;
|
|
242
|
+
}[] = [];
|
|
232
243
|
|
|
233
244
|
for (const fts of ftsResult.value) {
|
|
234
245
|
// Dedup by uri+seq - eliminates rows from mirror_hash join fan-out
|
|
@@ -278,6 +289,10 @@ export async function searchBm25(
|
|
|
278
289
|
// For --full, de-dupe by docid (keep best scoring chunk per doc)
|
|
279
290
|
// Raw BM25: smaller (more negative) is better
|
|
280
291
|
if (options.full) {
|
|
292
|
+
if (affinityActive) {
|
|
293
|
+
fullAffinityEntries.push({ fts, chunk, score: fts.score });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
281
296
|
const docid = fts.docid ?? "";
|
|
282
297
|
const existing = bestByDocid.get(docid);
|
|
283
298
|
if (!existing || fts.score < existing.score) {
|
|
@@ -289,16 +304,22 @@ export async function searchBm25(
|
|
|
289
304
|
const collectionPath = fts.collection
|
|
290
305
|
? collectionPaths.get(fts.collection)
|
|
291
306
|
: undefined;
|
|
292
|
-
|
|
293
|
-
|
|
307
|
+
const result = buildSearchResult({ fts, chunk, collectionPath, options });
|
|
308
|
+
if (fts.collection) {
|
|
309
|
+
scoringByResult.set(result, {
|
|
310
|
+
collection: fts.collection,
|
|
311
|
+
rawScore: fts.score,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
results.push(result);
|
|
294
315
|
}
|
|
295
316
|
|
|
296
317
|
// For --full, fetch full content and build results
|
|
297
318
|
if (options.full) {
|
|
298
319
|
// Sort by raw BM25 score (smaller = better) before building results
|
|
299
|
-
const sortedEntries =
|
|
300
|
-
|
|
301
|
-
);
|
|
320
|
+
const sortedEntries = (
|
|
321
|
+
affinityActive ? fullAffinityEntries : [...bestByDocid.values()]
|
|
322
|
+
).sort((a, b) => a.score - b.score);
|
|
302
323
|
const fullContentResult = await getContentBatch(
|
|
303
324
|
store,
|
|
304
325
|
sortedEntries
|
|
@@ -317,18 +338,50 @@ export async function searchBm25(
|
|
|
317
338
|
const collectionPath = fts.collection
|
|
318
339
|
? collectionPaths.get(fts.collection)
|
|
319
340
|
: undefined;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
const result = buildSearchResult({
|
|
342
|
+
fts,
|
|
343
|
+
chunk,
|
|
344
|
+
collectionPath,
|
|
345
|
+
options,
|
|
346
|
+
fullContent,
|
|
347
|
+
});
|
|
348
|
+
if (fts.collection) {
|
|
349
|
+
scoringByResult.set(result, {
|
|
350
|
+
collection: fts.collection,
|
|
351
|
+
rawScore: fts.score,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
results.push(result);
|
|
323
355
|
}
|
|
324
356
|
}
|
|
325
357
|
|
|
326
358
|
// Normalize scores to 0-1 range (batch min-max)
|
|
327
359
|
normalizeBm25Scores(results);
|
|
328
360
|
|
|
361
|
+
if (affinityActive) {
|
|
362
|
+
for (const result of results) {
|
|
363
|
+
const scoring = scoringByResult.get(result);
|
|
364
|
+
if (scoring) {
|
|
365
|
+
applyProjectAffinity(
|
|
366
|
+
result,
|
|
367
|
+
scoring.collection,
|
|
368
|
+
options.projectAffinity,
|
|
369
|
+
{ kind: "bm25", score: scoring.rawScore }
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const dedupedResults =
|
|
376
|
+
options.full && affinityActive
|
|
377
|
+
? dedupeFullResultsByDocid(results)
|
|
378
|
+
: results;
|
|
379
|
+
|
|
329
380
|
// Apply minScore filter after normalization
|
|
330
381
|
const filteredResults =
|
|
331
|
-
minScore > 0
|
|
382
|
+
minScore > 0
|
|
383
|
+
? dedupedResults.filter((r) => r.score >= minScore)
|
|
384
|
+
: dedupedResults;
|
|
332
385
|
|
|
333
386
|
if (recencySort) {
|
|
334
387
|
filteredResults.sort((a, b) => {
|
|
@@ -345,6 +398,8 @@ export async function searchBm25(
|
|
|
345
398
|
}
|
|
346
399
|
return b.score - a.score;
|
|
347
400
|
});
|
|
401
|
+
} else if (affinityActive) {
|
|
402
|
+
filteredResults.sort((a, b) => b.score - a.score);
|
|
348
403
|
}
|
|
349
404
|
|
|
350
405
|
const finalResults = filteredResults.slice(0, limit);
|
|
@@ -384,3 +439,14 @@ export async function searchBm25(
|
|
|
384
439
|
}
|
|
385
440
|
return ok(output);
|
|
386
441
|
}
|
|
442
|
+
|
|
443
|
+
function dedupeFullResultsByDocid(results: SearchResult[]): SearchResult[] {
|
|
444
|
+
const bestByDocid = new Map<string, SearchResult>();
|
|
445
|
+
for (const result of results) {
|
|
446
|
+
const existing = bestByDocid.get(result.docid);
|
|
447
|
+
if (!existing || result.score > existing.score) {
|
|
448
|
+
bestByDocid.set(result.docid, result);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return [...bestByDocid.values()];
|
|
452
|
+
}
|
package/src/pipeline/types.ts
CHANGED
|
@@ -13,6 +13,10 @@ import type { RetrievalTraceSession } from "../core/retrieval-trace-session";
|
|
|
13
13
|
import type { StoreResult } from "../store/types";
|
|
14
14
|
import type { ClaimVerificationResult } from "./claim-verification";
|
|
15
15
|
import type { SemanticVerificationCapability } from "./claim-verifier";
|
|
16
|
+
import type {
|
|
17
|
+
ProjectAffinityScoreMetadata,
|
|
18
|
+
ProjectAffinityScoringInput,
|
|
19
|
+
} from "./project-affinity";
|
|
16
20
|
|
|
17
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
22
|
// Search Result Types
|
|
@@ -53,6 +57,8 @@ export interface SearchResultPlannerMetadata {
|
|
|
53
57
|
retrievalRank: number;
|
|
54
58
|
mirrorHash: string;
|
|
55
59
|
seq: number;
|
|
60
|
+
/** Original retrieval seq when intent steering selects another snippet. */
|
|
61
|
+
retrievalSeq?: number;
|
|
56
62
|
sources: FusionSource[];
|
|
57
63
|
graphExpanded: boolean;
|
|
58
64
|
/** Exact canonical chunk coordinates, retained even for full-content output. */
|
|
@@ -164,6 +170,8 @@ export interface SearchResults {
|
|
|
164
170
|
export interface SearchOptions {
|
|
165
171
|
/** Internal receipt seam; never serialized or included in public schemas. */
|
|
166
172
|
traceSession?: RetrievalTraceSession;
|
|
173
|
+
/** Trusted, already-resolved project affinity; never accepts raw roots. */
|
|
174
|
+
projectAffinity?: ProjectAffinityScoringInput;
|
|
167
175
|
/** Max results */
|
|
168
176
|
limit?: number;
|
|
169
177
|
/** Min score threshold (0-1) */
|
|
@@ -536,4 +544,5 @@ export interface ExplainResult {
|
|
|
536
544
|
bm25Score?: number;
|
|
537
545
|
vecScore?: number;
|
|
538
546
|
rerankScore?: number;
|
|
547
|
+
projectAffinity?: ProjectAffinityScoreMetadata;
|
|
539
548
|
}
|