Skip to content

Commit 546e078

Browse files
Copilotdevlux76
andcommitted
feat(#91): implement hierarchical routing in dialectical retrieval pipeline
Query.ts now routes through Shelf→Volume→Book→Page hierarchy using rankShelves/rankVolumes/rankBooks before flat page scoring. Combines hierarchy-discovered pages with hotpath pages for comprehensive results. Closes #91 Co-authored-by: devlux76 <[email protected]>
1 parent 097a8ab commit 546e078

File tree

2 files changed

+129
-7
lines changed

2 files changed

+129
-7
lines changed

lib/cortex/Query.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { EmbeddingRunner } from "../embeddings/EmbeddingRunner";
44
import { runPromotionSweep } from "../core/SalienceEngine";
55
import { computeSubgraphBounds } from "../core/HotpathPolicy";
66
import type { QueryResult } from "./QueryResult";
7-
import { rankPages, spillToWarm } from "./Ranking";
7+
import { rankPages, rankBooks, rankVolumes, rankShelves, spillToWarm } from "./Ranking";
88
import { buildMetroid } from "./MetroidBuilder";
99
import { detectKnowledgeGap } from "./KnowledgeGapDetector";
1010
import { solveOpenTSP } from "./OpenTSPSolver";
@@ -46,11 +46,82 @@ export async function query(
4646

4747
const rankingOptions = { vectorStore, metadataStore };
4848

49-
// --- HOT path: score resident pages ---
50-
const hotpathEntries = await metadataStore.getHotpathEntries("page");
51-
const hotpathIds = hotpathEntries.map((e) => e.entityId);
49+
// --- Hierarchical routing: Shelf → Volume → Book → Page ---
50+
// When higher-tier hotpath entries exist, we route through the hierarchy
51+
// to narrow the candidate set before flat page scoring.
52+
const hotpathShelfEntries = await metadataStore.getHotpathEntries("shelf");
53+
const hotpathVolumeEntries = await metadataStore.getHotpathEntries("volume");
54+
const hotpathBookEntries = await metadataStore.getHotpathEntries("book");
55+
const hotpathPageEntries = await metadataStore.getHotpathEntries("page");
56+
57+
// Collect candidate page IDs from hierarchical routing.
58+
const hierarchyPageIds = new Set<Hash>();
59+
60+
// Shelf → Volume → Book → Page drill-down
61+
if (hotpathShelfEntries.length > 0) {
62+
const topShelves = await rankShelves(
63+
queryEmbedding,
64+
hotpathShelfEntries.map((e) => e.entityId),
65+
Math.max(2, Math.ceil(hotpathShelfEntries.length / 2)),
66+
rankingOptions,
67+
);
68+
for (const s of topShelves) {
69+
const shelf = await metadataStore.getShelf(s.id);
70+
if (shelf) {
71+
for (const vid of shelf.volumeIds) hierarchyPageIds.add(vid);
72+
}
73+
}
74+
}
75+
76+
// Rank volumes — include both hotpath volumes and those found via shelf drill-down
77+
const volumeCandidateIds = new Set<Hash>([
78+
...hotpathVolumeEntries.map((e) => e.entityId),
79+
...hierarchyPageIds,
80+
]);
81+
hierarchyPageIds.clear();
82+
83+
if (volumeCandidateIds.size > 0) {
84+
const topVolumes = await rankVolumes(
85+
queryEmbedding,
86+
[...volumeCandidateIds],
87+
Math.max(2, Math.ceil(volumeCandidateIds.size / 2)),
88+
rankingOptions,
89+
);
90+
for (const v of topVolumes) {
91+
const volume = await metadataStore.getVolume(v.id);
92+
if (volume) {
93+
for (const bid of volume.bookIds) hierarchyPageIds.add(bid);
94+
}
95+
}
96+
}
5297

53-
const hotResults = await rankPages(queryEmbedding, hotpathIds, topK, rankingOptions);
98+
// Rank books — include both hotpath books and those found via volume drill-down
99+
const bookCandidateIds = new Set<Hash>([
100+
...hotpathBookEntries.map((e) => e.entityId),
101+
...hierarchyPageIds,
102+
]);
103+
hierarchyPageIds.clear();
104+
105+
if (bookCandidateIds.size > 0) {
106+
const topBooks = await rankBooks(
107+
queryEmbedding,
108+
[...bookCandidateIds],
109+
Math.max(2, Math.ceil(bookCandidateIds.size / 2)),
110+
rankingOptions,
111+
);
112+
for (const b of topBooks) {
113+
const book = await metadataStore.getBook(b.id);
114+
if (book) {
115+
for (const pid of book.pageIds) hierarchyPageIds.add(pid);
116+
}
117+
}
118+
}
119+
120+
// --- HOT path: score resident pages merged with hierarchy-discovered pages ---
121+
const hotpathIds = hotpathPageEntries.map((e) => e.entityId);
122+
const combinedPageIds = new Set<Hash>([...hotpathIds, ...hierarchyPageIds]);
123+
124+
const hotResults = await rankPages(queryEmbedding, [...combinedPageIds], topK, rankingOptions);
54125
const seenIds = new Set(hotResults.map((r) => r.id));
55126

56127
// --- Warm spill: fill up to topK if hot path is insufficient ---
@@ -75,8 +146,7 @@ export async function query(
75146
.map((r) => r.score);
76147

77148
// --- MetroidBuilder: build dialectical probe ---
78-
// Candidates: hotpath book medoid pages + hotpath pages themselves
79-
const hotpathBookEntries = await metadataStore.getHotpathEntries("book");
149+
// Candidates: hotpath book medoid pages + top-ranked pages
80150
const bookCandidates = (
81151
await Promise.all(
82152
hotpathBookEntries.map(async (e) => {

tests/cortex/Query.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,56 @@ describe("cortex query (dialectical orchestrator)", () => {
237237
expect(Array.isArray(result.coherencePath)).toBe(true);
238238
expect(result.metroid).toBeDefined();
239239
});
240+
241+
it("uses hierarchical routing when volumes and shelves exist", async () => {
242+
const metadataStore = await IndexedDbMetadataStore.open(freshDbName());
243+
const vectorStore = new MemoryVectorStore();
244+
const keyPair = await generateKeyPair();
245+
246+
const backend = new DeterministicDummyEmbeddingBackend({ dimension: 4 });
247+
248+
const runner = new EmbeddingRunner(async () => ({
249+
backend,
250+
selectedKind: "dummy" as const,
251+
reason: "forced" as const,
252+
supportedKinds: ["dummy" as const],
253+
measurements: [],
254+
}));
255+
256+
const profile: ModelProfile = {
257+
modelId: "test-model",
258+
embeddingDimension: 4,
259+
contextWindowTokens: 64,
260+
truncationTokens: 48,
261+
maxChunkTokens: 5,
262+
source: "metadata",
263+
};
264+
265+
const text = "One two three four five six seven eight nine ten.";
266+
const ingestResult = await ingestText(text, {
267+
modelProfile: profile,
268+
embeddingRunner: runner,
269+
vectorStore,
270+
metadataStore,
271+
keyPair,
272+
});
273+
274+
// Ingest should now produce hierarchy
275+
expect(ingestResult.volumes.length).toBeGreaterThanOrEqual(1);
276+
expect(ingestResult.shelves.length).toBeGreaterThanOrEqual(1);
277+
278+
// Query should still work correctly with hierarchy-based routing
279+
const result = await query(ingestResult.pages[0].content, {
280+
modelProfile: profile,
281+
embeddingRunner: runner,
282+
vectorStore,
283+
metadataStore,
284+
topK: 2,
285+
});
286+
287+
expect(result.pages.length).toBeGreaterThan(0);
288+
expect(result.scores.length).toBeGreaterThan(0);
289+
expect(Array.isArray(result.coherencePath)).toBe(true);
290+
expect(result.metroid).toBeDefined();
291+
});
240292
});

0 commit comments

Comments
 (0)