@rubytech/create-maxy-code 0.1.131 → 0.1.138
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/dist/__tests__/installer-specialist-registration.test.js +5 -40
- package/dist/index.js +24 -28
- package/dist/specialist-registration.js +0 -15
- package/package.json +1 -1
- package/payload/platform/lib/graph-search/dist/boosts.d.ts +39 -0
- package/payload/platform/lib/graph-search/dist/boosts.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/boosts.js +57 -0
- package/payload/platform/lib/graph-search/dist/boosts.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/dedup.d.ts +29 -0
- package/payload/platform/lib/graph-search/dist/dedup.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/dedup.js +97 -0
- package/payload/platform/lib/graph-search/dist/dedup.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/index.d.ts +70 -8
- package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-search/dist/index.js +323 -62
- package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-search/dist/query-expansion.d.ts +37 -0
- package/payload/platform/lib/graph-search/dist/query-expansion.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/query-expansion.js +101 -0
- package/payload/platform/lib/graph-search/dist/query-expansion.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/route.d.ts +29 -0
- package/payload/platform/lib/graph-search/dist/route.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/route.js +53 -0
- package/payload/platform/lib/graph-search/dist/route.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts +31 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.js +57 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.js.map +1 -0
- package/payload/platform/lib/graph-search/src/__tests__/boosts.test.ts +59 -0
- package/payload/platform/lib/graph-search/src/__tests__/dedup.test.ts +83 -0
- package/payload/platform/lib/graph-search/src/__tests__/query-expansion.test.ts +96 -0
- package/payload/platform/lib/graph-search/src/__tests__/route.test.ts +62 -0
- package/payload/platform/lib/graph-search/src/__tests__/rrf-fusion.test.ts +39 -0
- package/payload/platform/lib/graph-search/src/boosts.ts +61 -0
- package/payload/platform/lib/graph-search/src/dedup.ts +108 -0
- package/payload/platform/lib/graph-search/src/index.ts +407 -65
- package/payload/platform/lib/graph-search/src/query-expansion.ts +129 -0
- package/payload/platform/lib/graph-search/src/route.ts +70 -0
- package/payload/platform/lib/graph-search/src/rrf-fusion.ts +62 -0
- package/payload/platform/neo4j/schema.cypher +80 -1
- package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +4 -2
- package/payload/platform/plugins/admin/mcp/dist/index.js +6 -4
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/docs/references/internals.md +36 -10
- package/payload/platform/plugins/docs/references/neo4j.md +63 -0
- package/payload/platform/plugins/docs/references/session-retrospective.md +11 -4
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +2 -2
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/index.js +114 -5
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js +25 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js +70 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js +50 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts +33 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js +99 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts +21 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js +24 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts +13 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js +151 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts +55 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js +290 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts +19 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js +102 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js +18 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +14 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts +29 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js +22 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +31 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +182 -50
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +76 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts +30 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js +129 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/scripts/backfill-typed-edges.ts +72 -0
- package/payload/platform/plugins/memory/mcp/scripts/generate-edge-docs.ts +75 -0
- package/payload/platform/plugins/memory/mcp/vitest.config.ts +5 -0
- package/payload/platform/plugins/memory/references/schema-base.md +44 -0
- package/payload/platform/scripts/seed-neo4j.sh +49 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +4 -3
- package/payload/platform/templates/specialists/agents/database-operator.md +9 -1
- package/payload/server/{adminuser-self-heal-QAWOZ3JV.js → adminuser-self-heal-YC47O34W.js} +2 -1
- package/payload/server/{chunk-JSBRDJBE.js → chunk-HYQNUVGO.js} +9 -1
- package/payload/server/{chunk-HCYM5FLU.js → chunk-T3DJD5QR.js} +3 -1
- package/payload/server/maxy-edge.js +3 -2
- package/payload/server/public/assets/{admin-FcRHAL-3.js → admin-BM9aXUUh.js} +1 -1
- package/payload/server/public/assets/{data-Ds37mflX.js → data-Ds2ECtfP.js} +1 -1
- package/payload/server/public/assets/{graph-CmWRhaiS.js → graph-BzxObKMM.js} +1 -1
- package/payload/server/public/assets/{graph-labels-Ch2r00Gt.js → graph-labels-Dwgep_nr.js} +1 -1
- package/payload/server/public/assets/{page-BcHhJXUt.js → page-BlkBsjP2.js} +1 -1
- package/payload/server/public/assets/page-h_SA5SVQ.js +51 -0
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +803 -76
- package/payload/server/public/assets/page-BOtNny_4.js +0 -51
|
@@ -41,9 +41,41 @@
|
|
|
41
41
|
|
|
42
42
|
import { int, type Session } from "neo4j-driver";
|
|
43
43
|
import { notTrashed } from "../../graph-trash/dist/index.js";
|
|
44
|
+
import { fuseRrf } from "./rrf-fusion.js";
|
|
45
|
+
import { expandQuery } from "./query-expansion.js";
|
|
46
|
+
import { applyCompiledTruthBoost, applyBacklinkBoost } from "./boosts.js";
|
|
47
|
+
import { dedup as dedupHits, DEFAULT_LAYERS, type DedupLayer } from "./dedup.js";
|
|
48
|
+
import { pickIndexMix, type RetrievalClass } from "./route.js";
|
|
44
49
|
|
|
45
50
|
const VECTOR_WEIGHT = 0.7;
|
|
46
51
|
const BM25_WEIGHT = 0.3;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-enhancement feature flags (Task 308). All read from env at the
|
|
55
|
+
* boundary of each `hybrid()` call so flips do not require a process
|
|
56
|
+
* restart. Per-call `HybridParams` fields override the env defaults so
|
|
57
|
+
* tests and A/B harnesses can opt in without touching the environment.
|
|
58
|
+
*
|
|
59
|
+
* Defaults are OFF — unflagged callers see exactly the baseline
|
|
60
|
+
* weighted-sum + nodeId-only-dedup baseline. Flip a flag on at the
|
|
61
|
+
* environment level after the soak window passes.
|
|
62
|
+
*/
|
|
63
|
+
function envFlag(name: string): boolean {
|
|
64
|
+
const v = process.env[name];
|
|
65
|
+
return v === "1" || v === "true" || v === "yes";
|
|
66
|
+
}
|
|
67
|
+
function readFlags() {
|
|
68
|
+
return {
|
|
69
|
+
rrf: envFlag("MAXY_GS_RRF"),
|
|
70
|
+
expansion: envFlag("MAXY_GS_EXPANSION"),
|
|
71
|
+
boosts: envFlag("MAXY_GS_BOOSTS"),
|
|
72
|
+
dedup: envFlag("MAXY_GS_DEDUP"),
|
|
73
|
+
route: envFlag("MAXY_GS_ROUTE"),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type { DedupLayer, RetrievalClass };
|
|
78
|
+
export { DEFAULT_LAYERS };
|
|
47
79
|
/**
|
|
48
80
|
* Name of the universal fulltext index. Mirrors the `CREATE FULLTEXT
|
|
49
81
|
* INDEX` declaration in `platform/neo4j/schema.cypher`. The doctrine test
|
|
@@ -108,7 +140,17 @@ export interface ScoredNode {
|
|
|
108
140
|
bm25Hit: boolean;
|
|
109
141
|
}
|
|
110
142
|
|
|
111
|
-
|
|
143
|
+
/**
|
|
144
|
+
* `"hybrid"` = weighted-sum fusion path (the baseline; the
|
|
145
|
+
* brief calls this `weighted-sum`).
|
|
146
|
+
* `"rrf"` = RRF fusion path (Task 308, flagged via `MAXY_GS_RRF`).
|
|
147
|
+
* `"bm25"` = degraded path after embed failure with `degradeOnEmbedFailure`.
|
|
148
|
+
*
|
|
149
|
+
* UI callers (e.g. `platform/ui/app/graph/page.tsx`) pattern-match on
|
|
150
|
+
* `"bm25"` to render the Ollama-down banner; non-`"bm25"` values collapse
|
|
151
|
+
* to the normal banner, so adding `"rrf"` is wire-compatible.
|
|
152
|
+
*/
|
|
153
|
+
export type SearchMode = "hybrid" | "rrf" | "bm25";
|
|
112
154
|
|
|
113
155
|
export interface Bm25OnlyParams {
|
|
114
156
|
query: string;
|
|
@@ -155,6 +197,33 @@ export interface HybridParams extends Bm25OnlyParams {
|
|
|
155
197
|
* default; the lib itself takes no opinion on the value.
|
|
156
198
|
*/
|
|
157
199
|
vectorThreshold?: number;
|
|
200
|
+
|
|
201
|
+
// --- Task 308 enhancements (all optional, all default off) ---
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Routing hint from the inbound-gateway classifier (Task 304). When
|
|
205
|
+
* the `MAXY_GS_ROUTE` flag is on, the lib picks a label filter and
|
|
206
|
+
* fusion-weight plan per class. Omitted ⇒ `"general"` (unflagged
|
|
207
|
+
* callers see no change).
|
|
208
|
+
*/
|
|
209
|
+
retrievalClass?: RetrievalClass;
|
|
210
|
+
/** Per-call override: enable Reciprocal Rank Fusion. */
|
|
211
|
+
enableRrf?: boolean;
|
|
212
|
+
/** Per-call override: enable Haiku multi-query expansion. */
|
|
213
|
+
enableExpansion?: boolean;
|
|
214
|
+
/** Per-call override: enable compiledTruth + backlink boosts. */
|
|
215
|
+
enableBoosts?: boolean;
|
|
216
|
+
/** Per-call override: enable 4-layer dedup (default layers below). */
|
|
217
|
+
enableDedup?: boolean;
|
|
218
|
+
/** Per-call override: enable retrieval-class routing. */
|
|
219
|
+
enableRoute?: boolean;
|
|
220
|
+
/**
|
|
221
|
+
* Layers passed to the 4-layer dedup when `enableDedup` is true.
|
|
222
|
+
* Defaults to `["nodeId","slug","canonicalName","contentHash"]`.
|
|
223
|
+
*/
|
|
224
|
+
dedupLayers?: DedupLayer[];
|
|
225
|
+
/** RRF smoothing constant. Default 60 (gbrain default). */
|
|
226
|
+
rrfK?: number;
|
|
158
227
|
}
|
|
159
228
|
|
|
160
229
|
export interface HybridResponse {
|
|
@@ -190,6 +259,21 @@ export interface HybridResponse {
|
|
|
190
259
|
suppressed: number;
|
|
191
260
|
bm25Bypass: number;
|
|
192
261
|
threshold: number | null;
|
|
262
|
+
/**
|
|
263
|
+
* Per-stage counts surfaced for the caller's `[graph-search:hybrid]`
|
|
264
|
+
* log line (Task 308). Always present; values reflect whichever
|
|
265
|
+
* enhancements ran on this call. `expansions` is 1 when expansion
|
|
266
|
+
* was off, `boosted - fused` is 0 when boosts were no-ops, etc.
|
|
267
|
+
*/
|
|
268
|
+
stageCounts: {
|
|
269
|
+
expansions: number;
|
|
270
|
+
vector: number;
|
|
271
|
+
bm25: number;
|
|
272
|
+
fused: number;
|
|
273
|
+
boosted: number;
|
|
274
|
+
deduped: number;
|
|
275
|
+
final: number;
|
|
276
|
+
};
|
|
193
277
|
}
|
|
194
278
|
|
|
195
279
|
export type EmbedFn = (text: string) => Promise<number[]>;
|
|
@@ -331,20 +415,29 @@ export async function bm25Only(
|
|
|
331
415
|
* Hybrid vector + BM25 search with graph expansion.
|
|
332
416
|
*
|
|
333
417
|
* Sequence:
|
|
334
|
-
* 1.
|
|
418
|
+
* 1. Resolve flags + route plan. Route may short-circuit (`retrievalClass=none`).
|
|
419
|
+
* 2. Multi-query expansion (Task 308 flag): Haiku produces 3-5 paraphrases.
|
|
420
|
+
* Each paraphrase is embedded and run through vector+BM25 in parallel.
|
|
421
|
+
* 3. embed(query) — may throw if Ollama is unreachable. When
|
|
335
422
|
* `degradeOnEmbedFailure=true`, caller receives bm25Only() result
|
|
336
423
|
* with `mode: "bm25"`; otherwise the throw propagates.
|
|
337
|
-
*
|
|
424
|
+
* 4. Vector search per label (one query per vector index discovered at
|
|
338
425
|
* boot). Nodes-by-label filter short-circuits when the requested
|
|
339
426
|
* labels have no index.
|
|
340
|
-
*
|
|
427
|
+
* 5. BM25 search on the universal `entity_search` index —
|
|
341
428
|
* same filter semantics as vector half (scope/agent/keyword/trashed).
|
|
342
|
-
*
|
|
429
|
+
* 6. Keyword subscriptions (when set): BM25 per keyword + property
|
|
343
430
|
* lookup against `node.keywords` array. Both bypass the agentSlug
|
|
344
431
|
* filter — subscriptions are scope-inclusive by design (matches
|
|
345
|
-
* memory-search.ts comment L319).
|
|
346
|
-
*
|
|
347
|
-
*
|
|
432
|
+
* memory-search.ts comment L319). Runs once, not per expansion.
|
|
433
|
+
* 7. Fusion: weighted-sum (`vw * vector + bw * bm25_norm`) by default,
|
|
434
|
+
* RRF when `MAXY_GS_RRF` is on. Route plan supplies fusion weights.
|
|
435
|
+
* 8. Boosts (Task 308 flag): compiledTruth bump + backlink log-bump.
|
|
436
|
+
* 9. Dedup (Task 308 flag): 4-layer dedup over the union, default layers
|
|
437
|
+
* `[nodeId, slug, canonicalName, contentHash]`.
|
|
438
|
+
* 10. Threshold + sort + slice. Counts (rawMerged / suppressed / bm25Bypass)
|
|
439
|
+
* reflect the pre-threshold and post-threshold populations.
|
|
440
|
+
* 11. Graph expand (1 hop by default, 0 to skip) — notTrashed filter +
|
|
348
441
|
* scope + agent clauses mirrored.
|
|
349
442
|
*/
|
|
350
443
|
export async function hybrid(
|
|
@@ -364,18 +457,62 @@ export async function hybrid(
|
|
|
364
457
|
keywordSubscriptions,
|
|
365
458
|
expandHops = 1,
|
|
366
459
|
degradeOnEmbedFailure = false,
|
|
460
|
+
retrievalClass,
|
|
461
|
+
dedupLayers = DEFAULT_LAYERS,
|
|
462
|
+
rrfK = 60,
|
|
367
463
|
} = params;
|
|
368
464
|
|
|
465
|
+
const envFlags = readFlags();
|
|
466
|
+
const useRoute = params.enableRoute ?? envFlags.route;
|
|
467
|
+
const useRrf = params.enableRrf ?? envFlags.rrf;
|
|
468
|
+
const useExpansion = params.enableExpansion ?? envFlags.expansion;
|
|
469
|
+
const useBoosts = params.enableBoosts ?? envFlags.boosts;
|
|
470
|
+
const useDedup = params.enableDedup ?? envFlags.dedup;
|
|
471
|
+
|
|
472
|
+
// --- Route plan (Task 308): pick label filter + fusion weights from class ---
|
|
473
|
+
const plan = useRoute
|
|
474
|
+
? pickIndexMix(retrievalClass)
|
|
475
|
+
: { skip: false, vectorWeight: VECTOR_WEIGHT, bm25Weight: BM25_WEIGHT, labelFilter: undefined as string[] | undefined };
|
|
476
|
+
|
|
477
|
+
const emptyStageCounts = {
|
|
478
|
+
expansions: 1,
|
|
479
|
+
vector: 0,
|
|
480
|
+
bm25: 0,
|
|
481
|
+
fused: 0,
|
|
482
|
+
boosted: 0,
|
|
483
|
+
deduped: 0,
|
|
484
|
+
final: 0,
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
if (plan.skip) {
|
|
488
|
+
return {
|
|
489
|
+
mode: useRrf ? "rrf" : "hybrid",
|
|
490
|
+
results: [],
|
|
491
|
+
expandMs: 0,
|
|
492
|
+
rawMerged: 0,
|
|
493
|
+
suppressed: 0,
|
|
494
|
+
bm25Bypass: 0,
|
|
495
|
+
threshold: params.vectorThreshold ?? null,
|
|
496
|
+
stageCounts: emptyStageCounts,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Per-call labels win over route-supplied label filter — operator-set
|
|
501
|
+
// filters are intentional; the route plan is a soft hint.
|
|
502
|
+
const effectiveLabels = (labels && labels.length > 0)
|
|
503
|
+
? labels
|
|
504
|
+
: plan.labelFilter;
|
|
505
|
+
|
|
506
|
+
// --- Degraded path: embed fails, fall back to BM25-only ---
|
|
507
|
+
// Run a single embed up front so degradation is detected before any
|
|
508
|
+
// expansion / multi-query work spins up.
|
|
369
509
|
let queryEmbedding: number[];
|
|
370
510
|
try {
|
|
371
511
|
queryEmbedding = await embed(query);
|
|
372
512
|
} catch (err) {
|
|
373
513
|
if (!degradeOnEmbedFailure) throw err;
|
|
374
514
|
const msg = err instanceof Error ? err.message : String(err);
|
|
375
|
-
const bm25Hits = await bm25Only(session, params);
|
|
376
|
-
// even in the degraded path, surface granular components so
|
|
377
|
-
// the /data score-line renders consistently. Vector half is 0 (no embed
|
|
378
|
-
// happened); bm25 half is normalised across the local hit set.
|
|
515
|
+
const bm25Hits = await bm25Only(session, { ...params, labels: effectiveLabels });
|
|
379
516
|
const normalised = normaliseBm25Scores(bm25Hits.map((h) => h.score));
|
|
380
517
|
const results: SearchResult[] = bm25Hits.map((h, i) => ({
|
|
381
518
|
...h,
|
|
@@ -383,11 +520,6 @@ export async function hybrid(
|
|
|
383
520
|
bm25Score: normalised[i] ?? 0,
|
|
384
521
|
related: [],
|
|
385
522
|
}));
|
|
386
|
-
// threshold has no meaning in the degraded path: no embed
|
|
387
|
-
// happened, so there is no vector cosine to compare against. Surface
|
|
388
|
-
// counts in the response (rawMerged = rendered, suppressed = 0,
|
|
389
|
-
// bm25Bypass = 0, threshold = null) so the caller's render path is
|
|
390
|
-
// uniform across hybrid + degraded modes.
|
|
391
523
|
return {
|
|
392
524
|
mode: "bm25",
|
|
393
525
|
results,
|
|
@@ -397,32 +529,36 @@ export async function hybrid(
|
|
|
397
529
|
suppressed: 0,
|
|
398
530
|
bm25Bypass: 0,
|
|
399
531
|
threshold: null,
|
|
532
|
+
stageCounts: {
|
|
533
|
+
expansions: 1,
|
|
534
|
+
vector: 0,
|
|
535
|
+
bm25: results.length,
|
|
536
|
+
fused: results.length,
|
|
537
|
+
boosted: results.length,
|
|
538
|
+
deduped: results.length,
|
|
539
|
+
final: results.length,
|
|
540
|
+
},
|
|
400
541
|
};
|
|
401
542
|
}
|
|
402
543
|
|
|
403
544
|
const labelToIndex = await discoverIndexes(session);
|
|
404
545
|
|
|
405
|
-
|
|
406
|
-
const keywordClause = keywordFilter?.clause ?? "";
|
|
407
|
-
const keywordParams = keywordFilter?.params ?? {};
|
|
408
|
-
|
|
409
|
-
const scoreMap = new Map<string, ScoredNode>();
|
|
410
|
-
|
|
411
|
-
// --- Vector search (per label) ---
|
|
546
|
+
// --- Resolve vector indexes from the effective label set ---
|
|
412
547
|
let indexesToQuery: string[];
|
|
413
|
-
if (
|
|
414
|
-
indexesToQuery =
|
|
548
|
+
if (effectiveLabels && effectiveLabels.length > 0) {
|
|
549
|
+
indexesToQuery = effectiveLabels
|
|
415
550
|
.map((l) => labelToIndex.get(l))
|
|
416
551
|
.filter((idx): idx is string => idx !== undefined);
|
|
417
552
|
if (indexesToQuery.length === 0) {
|
|
418
553
|
return {
|
|
419
|
-
mode: "hybrid",
|
|
554
|
+
mode: useRrf ? "rrf" : "hybrid",
|
|
420
555
|
results: [],
|
|
421
556
|
expandMs: 0,
|
|
422
557
|
rawMerged: 0,
|
|
423
558
|
suppressed: 0,
|
|
424
559
|
bm25Bypass: 0,
|
|
425
560
|
threshold: params.vectorThreshold ?? null,
|
|
561
|
+
stageCounts: emptyStageCounts,
|
|
426
562
|
};
|
|
427
563
|
}
|
|
428
564
|
} else {
|
|
@@ -438,59 +574,198 @@ export async function hybrid(
|
|
|
438
574
|
: "";
|
|
439
575
|
const agentParams = agentSlug ? { agentSlug } : {};
|
|
440
576
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
577
|
+
const keywordFilter = buildKeywordFilter(keywords, keywordMatch);
|
|
578
|
+
const keywordClause = keywordFilter?.clause ?? "";
|
|
579
|
+
const keywordParams = keywordFilter?.params ?? {};
|
|
580
|
+
|
|
581
|
+
// --- Multi-query expansion (Task 308) ---
|
|
582
|
+
// Original query is always first. On expansion failure expandQuery returns
|
|
583
|
+
// [query] only and logs `[graph-search:expansion-failed]`. Embeddings for
|
|
584
|
+
// additional paraphrases are computed in parallel; embed failures on a
|
|
585
|
+
// paraphrase fall back to skipping the vector half for that pass.
|
|
586
|
+
const expansions = useExpansion
|
|
587
|
+
? await expandQuery({ query, retrievalClass, accountId })
|
|
588
|
+
: [query];
|
|
589
|
+
|
|
590
|
+
type Pass = {
|
|
591
|
+
queryText: string;
|
|
592
|
+
embedding: number[] | null;
|
|
593
|
+
vectorRanked: ScoredNode[];
|
|
594
|
+
bm25Ranked: ScoredNode[];
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const passes: Pass[] = await Promise.all(
|
|
598
|
+
expansions.map(async (qText, idx) => {
|
|
599
|
+
let pEmbed: number[] | null;
|
|
600
|
+
if (idx === 0) {
|
|
601
|
+
pEmbed = queryEmbedding;
|
|
602
|
+
} else {
|
|
603
|
+
try {
|
|
604
|
+
pEmbed = await embed(qText);
|
|
605
|
+
} catch {
|
|
606
|
+
pEmbed = null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const vectorRanked: ScoredNode[] = [];
|
|
611
|
+
const vectorSeen = new Map<string, number>();
|
|
612
|
+
if (pEmbed) {
|
|
613
|
+
for (const indexName of indexesToQuery) {
|
|
614
|
+
const vectorResult = await session.run(
|
|
615
|
+
`CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
|
|
616
|
+
YIELD node, score
|
|
617
|
+
WHERE node.accountId = $accountId
|
|
618
|
+
${scopeClause}
|
|
619
|
+
${agentClause}
|
|
620
|
+
AND ${notTrashed("node")}
|
|
621
|
+
${keywordClause}
|
|
622
|
+
RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
|
|
623
|
+
ORDER BY score DESC
|
|
624
|
+
LIMIT $limit`,
|
|
625
|
+
{
|
|
626
|
+
indexName,
|
|
627
|
+
embedding: pEmbed,
|
|
628
|
+
limit: int(limit),
|
|
629
|
+
accountId,
|
|
630
|
+
...scopeParams,
|
|
631
|
+
...agentParams,
|
|
632
|
+
...keywordParams,
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
for (const record of vectorResult.records) {
|
|
636
|
+
const nodeId = record.get("nodeId") as string;
|
|
637
|
+
const scoreRaw = record.get("score");
|
|
638
|
+
const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
|
|
639
|
+
const existingIdx = vectorSeen.get(nodeId);
|
|
640
|
+
if (existingIdx !== undefined) {
|
|
641
|
+
const existing = vectorRanked[existingIdx];
|
|
642
|
+
if (score > existing.vectorScore) existing.vectorScore = score;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const node = record.get("node") as { properties: Record<string, unknown> };
|
|
646
|
+
vectorSeen.set(nodeId, vectorRanked.length);
|
|
647
|
+
vectorRanked.push({
|
|
648
|
+
nodeId,
|
|
649
|
+
labels: record.get("nodeLabels") as string[],
|
|
650
|
+
properties: plainProperties(node.properties),
|
|
651
|
+
vectorScore: score,
|
|
652
|
+
bm25Score: 0,
|
|
653
|
+
bm25Hit: false,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
vectorRanked.sort((a, b) => b.vectorScore - a.vectorScore);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const bm25Hits = await bm25Only(session, {
|
|
661
|
+
...params,
|
|
662
|
+
query: qText,
|
|
663
|
+
labels: effectiveLabels,
|
|
664
|
+
});
|
|
665
|
+
const bm25Norm = normaliseBm25Scores(bm25Hits.map((h) => h.score));
|
|
666
|
+
const bm25Ranked: ScoredNode[] = bm25Hits.map((h, i) => ({
|
|
667
|
+
nodeId: h.nodeId,
|
|
668
|
+
labels: h.labels,
|
|
669
|
+
properties: h.properties,
|
|
670
|
+
vectorScore: 0,
|
|
671
|
+
bm25Score: bm25Norm[i] ?? 0,
|
|
672
|
+
bm25Hit: true,
|
|
673
|
+
}));
|
|
674
|
+
|
|
675
|
+
return { queryText: qText, embedding: pEmbed, vectorRanked, bm25Ranked };
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
// --- Fuse passes into the score map ---
|
|
680
|
+
// Weighted-sum: union per nodeId, taking the max vector score and max
|
|
681
|
+
// bm25-normalised score across passes, then combine with the route's
|
|
682
|
+
// weights. This preserves the baseline single-query behaviour exactly
|
|
683
|
+
// when expansions.length === 1.
|
|
684
|
+
// RRF: each pass contributes one vector-ranked list and one bm25-ranked
|
|
685
|
+
// list. Fusion is computed across the full union of ranked lists.
|
|
686
|
+
const scoreMap = new Map<string, ScoredNode>();
|
|
687
|
+
let totalVector = 0;
|
|
688
|
+
let totalBm25 = 0;
|
|
689
|
+
|
|
690
|
+
for (const pass of passes) {
|
|
691
|
+
totalVector += pass.vectorRanked.length;
|
|
692
|
+
totalBm25 += pass.bm25Ranked.length;
|
|
693
|
+
for (const v of pass.vectorRanked) {
|
|
694
|
+
const existing = scoreMap.get(v.nodeId);
|
|
695
|
+
if (existing) {
|
|
696
|
+
existing.vectorScore = Math.max(existing.vectorScore, v.vectorScore);
|
|
697
|
+
} else {
|
|
698
|
+
scoreMap.set(v.nodeId, { ...v });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const b of pass.bm25Ranked) {
|
|
702
|
+
mergeBm25Hit(scoreMap, {
|
|
703
|
+
nodeId: b.nodeId,
|
|
704
|
+
labels: b.labels,
|
|
705
|
+
properties: b.properties,
|
|
706
|
+
score: b.bm25Score,
|
|
707
|
+
}, b.bm25Score);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// --- Keyword subscriptions (scope-inclusive, agentSlug-bypassing) ---
|
|
712
|
+
// Runs once regardless of expansion count — subscriptions are keyed by
|
|
713
|
+
// operator config, not by the inbound query.
|
|
714
|
+
if (keywordSubscriptions && keywordSubscriptions.length > 0) {
|
|
715
|
+
for (const kw of keywordSubscriptions) {
|
|
716
|
+
const kwHits = await bm25Only(session, {
|
|
717
|
+
query: kw,
|
|
718
|
+
accountId,
|
|
719
|
+
limit,
|
|
720
|
+
allowedScopes,
|
|
721
|
+
});
|
|
722
|
+
if (kwHits.length === 0) continue;
|
|
723
|
+
const rawScores = kwHits.map((h) => h.score);
|
|
724
|
+
const normalised = normaliseBm25Scores(rawScores);
|
|
725
|
+
for (let i = 0; i < kwHits.length; i++) {
|
|
726
|
+
mergeBm25Hit(scoreMap, kwHits[i], normalised[i]);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const propScopeClause = allowedScopes
|
|
731
|
+
? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
|
|
732
|
+
: "";
|
|
733
|
+
const propResult = await session.run(
|
|
734
|
+
`MATCH (node)
|
|
445
735
|
WHERE node.accountId = $accountId
|
|
446
|
-
${scopeClause}
|
|
447
|
-
${agentClause}
|
|
448
736
|
AND ${notTrashed("node")}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
737
|
+
AND node.keywords IS NOT NULL
|
|
738
|
+
AND ANY(kw IN $kwSubs WHERE ANY(nk IN node.keywords WHERE toLower(nk) = kw))
|
|
739
|
+
${propScopeClause}
|
|
740
|
+
RETURN node, labels(node) AS nodeLabels, elementId(node) AS nodeId
|
|
452
741
|
LIMIT $limit`,
|
|
453
742
|
{
|
|
454
|
-
indexName,
|
|
455
|
-
embedding: queryEmbedding,
|
|
456
|
-
limit: int(limit),
|
|
457
743
|
accountId,
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
...
|
|
744
|
+
kwSubs: keywordSubscriptions,
|
|
745
|
+
limit: int(limit),
|
|
746
|
+
...(allowedScopes ? { allowedScopes } : {}),
|
|
461
747
|
},
|
|
462
748
|
);
|
|
463
|
-
for (const record of
|
|
749
|
+
for (const record of propResult.records) {
|
|
464
750
|
const nodeId = record.get("nodeId") as string;
|
|
465
|
-
const scoreRaw = record.get("score");
|
|
466
|
-
const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
|
|
467
751
|
const existing = scoreMap.get(nodeId);
|
|
468
752
|
if (existing) {
|
|
469
|
-
existing.
|
|
753
|
+
existing.bm25Score = Math.max(existing.bm25Score, 1.0);
|
|
754
|
+
existing.bm25Hit = true;
|
|
470
755
|
} else {
|
|
471
756
|
const node = record.get("node") as { properties: Record<string, unknown> };
|
|
472
757
|
scoreMap.set(nodeId, {
|
|
473
758
|
nodeId,
|
|
474
759
|
labels: record.get("nodeLabels") as string[],
|
|
475
760
|
properties: plainProperties(node.properties),
|
|
476
|
-
vectorScore:
|
|
477
|
-
bm25Score: 0,
|
|
478
|
-
bm25Hit:
|
|
761
|
+
vectorScore: 0,
|
|
762
|
+
bm25Score: 1.0,
|
|
763
|
+
bm25Hit: true,
|
|
479
764
|
});
|
|
480
765
|
}
|
|
481
766
|
}
|
|
482
767
|
}
|
|
483
768
|
|
|
484
|
-
// --- BM25 half ---
|
|
485
|
-
const bm25Hits = await bm25Only(session, params);
|
|
486
|
-
if (bm25Hits.length > 0) {
|
|
487
|
-
const rawScores = bm25Hits.map((h) => h.score);
|
|
488
|
-
const normalised = normaliseBm25Scores(rawScores);
|
|
489
|
-
for (let i = 0; i < bm25Hits.length; i++) {
|
|
490
|
-
mergeBm25Hit(scoreMap, bm25Hits[i], normalised[i]);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
769
|
// --- Keyword subscriptions (scope-inclusive, agentSlug-bypassing) ---
|
|
495
770
|
if (keywordSubscriptions && keywordSubscriptions.length > 0) {
|
|
496
771
|
for (const kw of keywordSubscriptions) {
|
|
@@ -547,7 +822,69 @@ export async function hybrid(
|
|
|
547
822
|
}
|
|
548
823
|
}
|
|
549
824
|
|
|
550
|
-
// ---
|
|
825
|
+
// --- Fusion ---
|
|
826
|
+
//
|
|
827
|
+
// Weighted-sum (default): combined = vw * vector + bw * bm25_norm, with
|
|
828
|
+
// weights from the route plan (or the 0.7/0.3 lib defaults when routing
|
|
829
|
+
// is off). This preserves the baseline single-query single-fusion path
|
|
830
|
+
// when no flags are flipped on.
|
|
831
|
+
//
|
|
832
|
+
// RRF (flagged): contributions from each pass's vector and BM25 ranked
|
|
833
|
+
// lists are summed across all passes; combinedScore becomes the RRF
|
|
834
|
+
// score. Per-half components on `ScoredNode` (vectorScore / bm25Score)
|
|
835
|
+
// are retained so the /data UI's score-line renders consistently.
|
|
836
|
+
let allMerged: Array<ScoredNode & { combinedScore: number }>;
|
|
837
|
+
if (useRrf) {
|
|
838
|
+
const rankedLists: ScoredNode[][] = [];
|
|
839
|
+
for (const pass of passes) {
|
|
840
|
+
if (pass.vectorRanked.length > 0) rankedLists.push(pass.vectorRanked);
|
|
841
|
+
if (pass.bm25Ranked.length > 0) rankedLists.push(pass.bm25Ranked);
|
|
842
|
+
}
|
|
843
|
+
const fused = fuseRrf(rankedLists, rrfK);
|
|
844
|
+
allMerged = fused.map((f) => {
|
|
845
|
+
// Use the score-map representative so vectorScore / bm25Score reflect
|
|
846
|
+
// the cross-pass max, not just whichever list ranked first. The
|
|
847
|
+
// representative is guaranteed to exist (scoreMap aggregated the
|
|
848
|
+
// union); fall back to the fusion entry if not (defensive).
|
|
849
|
+
const rep = scoreMap.get(f.nodeId) ?? f;
|
|
850
|
+
return { ...rep, combinedScore: f.rrfScore };
|
|
851
|
+
});
|
|
852
|
+
} else {
|
|
853
|
+
allMerged = [...scoreMap.values()].map((node) => ({
|
|
854
|
+
...node,
|
|
855
|
+
combinedScore: plan.vectorWeight * node.vectorScore + plan.bm25Weight * node.bm25Score,
|
|
856
|
+
}));
|
|
857
|
+
}
|
|
858
|
+
const fusedCount = allMerged.length;
|
|
859
|
+
|
|
860
|
+
// --- Boosts (Task 308) ---
|
|
861
|
+
// Both are no-ops on the current corpus until Task 305 (typed-edge
|
|
862
|
+
// backlinks) and Task 306 (compiledTruth property) land. Reading a
|
|
863
|
+
// missing property as zero is the correct behaviour — the boost is
|
|
864
|
+
// additive over hits that have the field.
|
|
865
|
+
if (useBoosts) {
|
|
866
|
+
allMerged = applyCompiledTruthBoost(
|
|
867
|
+
applyBacklinkBoost(allMerged.map((m) => ({
|
|
868
|
+
...m,
|
|
869
|
+
score: m.combinedScore,
|
|
870
|
+
}))),
|
|
871
|
+
).map((m) => ({ ...m, combinedScore: m.score }));
|
|
872
|
+
}
|
|
873
|
+
const boostedCount = allMerged.length;
|
|
874
|
+
|
|
875
|
+
// --- Dedup (Task 308) ---
|
|
876
|
+
// 4-layer dedup is a strict superset of the baseline nodeId-only dedup.
|
|
877
|
+
// The post-fusion scoreMap is already nodeId-deduped, so when no flag
|
|
878
|
+
// is set this step is a no-op.
|
|
879
|
+
if (useDedup) {
|
|
880
|
+
allMerged = dedupHits(
|
|
881
|
+
allMerged.map((m) => ({ ...m, score: m.combinedScore })),
|
|
882
|
+
dedupLayers,
|
|
883
|
+
).map((m) => ({ ...m, combinedScore: m.score }));
|
|
884
|
+
}
|
|
885
|
+
const dedupedCount = allMerged.length;
|
|
886
|
+
|
|
887
|
+
// --- Threshold + sort + slice ---
|
|
551
888
|
//
|
|
552
889
|
// vectorThreshold filters BEFORE sort+slice. Order matters:
|
|
553
890
|
// slicing first would consume the slot budget with rows the threshold
|
|
@@ -562,10 +899,6 @@ export async function hybrid(
|
|
|
562
899
|
// normalisation pins the lowest BM25 row's score to 0.0, so checking
|
|
563
900
|
// bm25Score > 0 would silently drop a literal-token match that just
|
|
564
901
|
// happens to be the worst-scored BM25 hit in the set.
|
|
565
|
-
const allMerged = [...scoreMap.values()].map((node) => ({
|
|
566
|
-
...node,
|
|
567
|
-
combinedScore: VECTOR_WEIGHT * node.vectorScore + BM25_WEIGHT * node.bm25Score,
|
|
568
|
-
}));
|
|
569
902
|
const rawMerged = allMerged.length;
|
|
570
903
|
const threshold = params.vectorThreshold;
|
|
571
904
|
let kept = allMerged;
|
|
@@ -668,13 +1001,22 @@ export async function hybrid(
|
|
|
668
1001
|
}
|
|
669
1002
|
|
|
670
1003
|
return {
|
|
671
|
-
mode: "hybrid",
|
|
1004
|
+
mode: useRrf ? "rrf" : "hybrid",
|
|
672
1005
|
results,
|
|
673
1006
|
expandMs,
|
|
674
1007
|
rawMerged,
|
|
675
1008
|
suppressed,
|
|
676
1009
|
bm25Bypass,
|
|
677
1010
|
threshold: threshold ?? null,
|
|
1011
|
+
stageCounts: {
|
|
1012
|
+
expansions: expansions.length,
|
|
1013
|
+
vector: totalVector,
|
|
1014
|
+
bm25: totalBm25,
|
|
1015
|
+
fused: fusedCount,
|
|
1016
|
+
boosted: boostedCount,
|
|
1017
|
+
deduped: dedupedCount,
|
|
1018
|
+
final: results.length,
|
|
1019
|
+
},
|
|
678
1020
|
};
|
|
679
1021
|
}
|
|
680
1022
|
|