@crewhaus/compaction-curator 0.1.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/package.json +41 -0
- package/src/index.test.ts +168 -0
- package/src/index.ts +288 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/compaction-curator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pillar-2 active context curation — pre-compaction relevance + dedupe pass that lets compaction-autocompact skip the model call when curation alone brings tokens under the trigger threshold",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/compaction-curator"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/compaction-curator#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type EmbedderFn, type Item, curate, dedupeBySimilarity, rankByRelevance } from "./index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tiny canned embedder for deterministic tests: maps each unique input
|
|
6
|
+
* string to a fixed unit vector. Same input → same vector → cosine of 1.
|
|
7
|
+
* Different input → orthogonal vector → cosine of 0.
|
|
8
|
+
*/
|
|
9
|
+
function makeCannedEmbedder(): { fn: EmbedderFn; counter: () => number } {
|
|
10
|
+
const cache = new Map<string, ReadonlyArray<number>>();
|
|
11
|
+
let calls = 0;
|
|
12
|
+
const dim = 16;
|
|
13
|
+
const fn: EmbedderFn = async (texts) => {
|
|
14
|
+
calls += 1;
|
|
15
|
+
return texts.map((t) => {
|
|
16
|
+
const cached = cache.get(t);
|
|
17
|
+
if (cached !== undefined) return cached;
|
|
18
|
+
const idx = cache.size % dim;
|
|
19
|
+
const v = new Array(dim).fill(0);
|
|
20
|
+
v[idx] = 1;
|
|
21
|
+
const arr = Object.freeze(v) as ReadonlyArray<number>;
|
|
22
|
+
cache.set(t, arr);
|
|
23
|
+
return arr;
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
return { fn, counter: () => calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("curate — happy path", () => {
|
|
30
|
+
test("returns input unchanged when no dedupe + no query", async () => {
|
|
31
|
+
const { fn } = makeCannedEmbedder();
|
|
32
|
+
const items: ReadonlyArray<Item> = [
|
|
33
|
+
{ text: "alpha gamma delta epsilon zeta" },
|
|
34
|
+
{ text: "beta theta iota kappa lambda" },
|
|
35
|
+
{ text: "rho sigma tau upsilon phi" },
|
|
36
|
+
];
|
|
37
|
+
const result = await curate(items, { embedder: fn });
|
|
38
|
+
expect(result.items.length).toBe(3);
|
|
39
|
+
expect(result.droppedIndices.length).toBe(0);
|
|
40
|
+
expect(result.bytesSaved).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("dedupes when two items embed identically", async () => {
|
|
44
|
+
const embedder: EmbedderFn = async (texts) =>
|
|
45
|
+
texts.map(() => [1, 0, 0, 0] as ReadonlyArray<number>);
|
|
46
|
+
const items: ReadonlyArray<Item> = [
|
|
47
|
+
{ text: "alpha gamma delta epsilon zeta one" },
|
|
48
|
+
{ text: "alpha gamma delta epsilon zeta two" },
|
|
49
|
+
{ text: "alpha gamma delta epsilon zeta three" },
|
|
50
|
+
];
|
|
51
|
+
const result = await curate(items, { embedder });
|
|
52
|
+
expect(result.items.length).toBe(1);
|
|
53
|
+
expect(result.droppedIndices).toEqual([1, 2]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("keeps when below dedupe threshold", async () => {
|
|
57
|
+
const { fn } = makeCannedEmbedder();
|
|
58
|
+
const items: ReadonlyArray<Item> = [
|
|
59
|
+
{ text: "alpha gamma delta epsilon zeta" },
|
|
60
|
+
{ text: "beta theta iota kappa lambda" },
|
|
61
|
+
];
|
|
62
|
+
const result = await curate(items, { embedder: fn, dedupeThreshold: 0.99 });
|
|
63
|
+
expect(result.items.length).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("relevance-orders when query supplied", async () => {
|
|
67
|
+
const items: ReadonlyArray<Item> = [
|
|
68
|
+
{ text: "completely unrelated chitchat", id: "off-topic" },
|
|
69
|
+
{ text: "deep technical content about query", id: "on-topic" },
|
|
70
|
+
];
|
|
71
|
+
// Make on-topic share the query vector
|
|
72
|
+
const embedder: EmbedderFn = async (texts) => {
|
|
73
|
+
return texts.map((t) => {
|
|
74
|
+
if (t === "deep technical content about query" || t === "search query") {
|
|
75
|
+
return [1, 0, 0] as ReadonlyArray<number>;
|
|
76
|
+
}
|
|
77
|
+
return [0, 1, 0] as ReadonlyArray<number>;
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const result = await curate(items, { embedder, query: "search query" });
|
|
81
|
+
expect(result.items[0]?.id).toBe("on-topic");
|
|
82
|
+
expect(result.items[1]?.id).toBe("off-topic");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("topK trims after relevance reorder", async () => {
|
|
86
|
+
const items: ReadonlyArray<Item> = [
|
|
87
|
+
{ text: "irrelevant content one", id: "1" },
|
|
88
|
+
{ text: "highly relevant query match", id: "2" },
|
|
89
|
+
{ text: "irrelevant content two", id: "3" },
|
|
90
|
+
];
|
|
91
|
+
const embedder: EmbedderFn = async (texts) =>
|
|
92
|
+
texts.map((t) => {
|
|
93
|
+
if (t === "highly relevant query match" || t === "the search query") {
|
|
94
|
+
return [1, 0] as ReadonlyArray<number>;
|
|
95
|
+
}
|
|
96
|
+
return [0, 1] as ReadonlyArray<number>;
|
|
97
|
+
});
|
|
98
|
+
const result = await curate(items, {
|
|
99
|
+
embedder,
|
|
100
|
+
query: "the search query",
|
|
101
|
+
relevanceTopK: 1,
|
|
102
|
+
});
|
|
103
|
+
expect(result.items.length).toBe(1);
|
|
104
|
+
expect(result.items[0]?.id).toBe("2");
|
|
105
|
+
expect(result.droppedIndices.length).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("curate — pre-embedded path", () => {
|
|
110
|
+
test("no embedder needed when all items carry embeddings", async () => {
|
|
111
|
+
const items: ReadonlyArray<Item> = [
|
|
112
|
+
{ text: "first item with embedding precomputed", embedding: [1, 0] },
|
|
113
|
+
{ text: "second item with embedding precomputed", embedding: [0, 1] },
|
|
114
|
+
];
|
|
115
|
+
const result = await curate(items, {}); // no embedder, no query
|
|
116
|
+
expect(result.items.length).toBe(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("throws when items lack embeddings and no embedder is supplied", async () => {
|
|
120
|
+
const items: ReadonlyArray<Item> = [{ text: "needs embedding plenty of words" }];
|
|
121
|
+
await expect(curate(items, {})).rejects.toThrow(/lack pre-computed embeddings/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("curate — edge cases", () => {
|
|
126
|
+
test("empty input returns empty result", async () => {
|
|
127
|
+
const result = await curate([], {});
|
|
128
|
+
expect(result.items.length).toBe(0);
|
|
129
|
+
expect(result.bytesSaved).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("bytesSaved reflects dropped item lengths", async () => {
|
|
133
|
+
const embedder: EmbedderFn = async (texts) => texts.map(() => [1, 0] as ReadonlyArray<number>);
|
|
134
|
+
const items: ReadonlyArray<Item> = [
|
|
135
|
+
{ text: "kept first kept first kept first" }, // 32 bytes
|
|
136
|
+
{ text: "dropped dropped dropped" }, // 23 bytes
|
|
137
|
+
];
|
|
138
|
+
const result = await curate(items, { embedder });
|
|
139
|
+
expect(result.bytesSaved).toBe(Buffer.byteLength("dropped dropped dropped", "utf8"));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("dedupeBySimilarity (pure)", () => {
|
|
144
|
+
test("first occurrence wins for duplicates", () => {
|
|
145
|
+
const items: ReadonlyArray<Item> = [{ text: "a" }, { text: "b" }, { text: "c" }];
|
|
146
|
+
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
147
|
+
[1, 0],
|
|
148
|
+
[1, 0], // duplicate of #0
|
|
149
|
+
[0, 1],
|
|
150
|
+
];
|
|
151
|
+
const r = dedupeBySimilarity(items, embeddings, 0.92);
|
|
152
|
+
expect(r.kept).toEqual([0, 2]);
|
|
153
|
+
expect(r.dropped).toEqual([1]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("rankByRelevance (pure)", () => {
|
|
158
|
+
test("ranks by descending cosine similarity", async () => {
|
|
159
|
+
const items: ReadonlyArray<Item> = [{ text: "x" }, { text: "y" }];
|
|
160
|
+
const embeddings: ReadonlyArray<ReadonlyArray<number>> = [
|
|
161
|
+
[0, 1],
|
|
162
|
+
[1, 0],
|
|
163
|
+
];
|
|
164
|
+
const embedder: EmbedderFn = async () => [[1, 0]];
|
|
165
|
+
const order = await rankByRelevance(items, embeddings, "q", embedder);
|
|
166
|
+
expect(order).toEqual([1, 0]);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pillar 2 active context curation — pre-compaction relevance + dedupe.
|
|
3
|
+
*
|
|
4
|
+
* `compaction-autocompact` (model summarize-then-replace) and
|
|
5
|
+
* `compaction-snip` (drop oldest) are *reactive*: they only fire when
|
|
6
|
+
* context is already near its limit, and they cost a model call (autocompact)
|
|
7
|
+
* or accept information loss (snip). Routray's "Stop Wasting Money on AI
|
|
8
|
+
* Context You Don't Need" (Mar 2026) documents 60–80% token reduction
|
|
9
|
+
* from a pre-compaction pass that:
|
|
10
|
+
*
|
|
11
|
+
* 1. Removes semantically duplicate items (embedding cosine ≥ threshold).
|
|
12
|
+
* 2. Re-orders to front-load high-relevance items (since transformer
|
|
13
|
+
* attention favors prompt-start and prompt-end positions).
|
|
14
|
+
*
|
|
15
|
+
* This package implements both as pure functions over a generic `Item`
|
|
16
|
+
* shape (string text + optional embedding). Callers (compaction-autocompact
|
|
17
|
+
* primarily) wire it as an opt-in stage controlled by `spec.compaction.curate`.
|
|
18
|
+
* When curation brings the item count below the model-summarize trigger,
|
|
19
|
+
* the summarization model call is skipped entirely — that's where the
|
|
20
|
+
* cost savings compound on top of token savings.
|
|
21
|
+
*
|
|
22
|
+
* The package does NOT carry an embedder dependency. Callers supply
|
|
23
|
+
* `EmbedderFn` — typically wrapping `@crewhaus/embedder`'s configured
|
|
24
|
+
* provider. This keeps `compaction-curator` light enough to be useful
|
|
25
|
+
* from `target-pipeline` (which already has an embedder) without forcing
|
|
26
|
+
* a heavy import onto `target-cli` (which doesn't).
|
|
27
|
+
*
|
|
28
|
+
* Catalog layer: R6 (extension of §17 compaction primitives, symmetric to
|
|
29
|
+
* `compaction-autocompact` and `compaction-snip`). Recipe:
|
|
30
|
+
* demos/walkthroughs/52-context-curation.md.
|
|
31
|
+
*/
|
|
32
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
33
|
+
|
|
34
|
+
export class CompactionCuratorError extends CrewhausError {
|
|
35
|
+
override readonly name = "CompactionCuratorError";
|
|
36
|
+
constructor(message: string, cause?: unknown) {
|
|
37
|
+
super("config", message, cause);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The generic shape this package operates on. Sufficient for two backends:
|
|
43
|
+
* - context items: `text` = message body, `embedding` = optional pre-
|
|
44
|
+
* computed vector
|
|
45
|
+
* - RAG-retrieved chunks: `text` = chunk body, `embedding` = the vector
|
|
46
|
+
* already used for retrieval
|
|
47
|
+
*/
|
|
48
|
+
export type Item = {
|
|
49
|
+
readonly text: string;
|
|
50
|
+
/** Optional pre-computed embedding. When absent, callers must supply
|
|
51
|
+
* `EmbedderFn` to `curate()`. */
|
|
52
|
+
readonly embedding?: ReadonlyArray<number>;
|
|
53
|
+
/** Optional client-side identifier carried through unchanged. */
|
|
54
|
+
readonly id?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Async embedder callback. Returns a vector per input string in the same
|
|
59
|
+
* order. Implementations typically batch internally for cost efficiency.
|
|
60
|
+
*
|
|
61
|
+
* Vectors must be normalized (unit length) so cosine reduces to a dot
|
|
62
|
+
* product. Most production embedders return normalized vectors by default;
|
|
63
|
+
* if yours doesn't, normalize before returning here.
|
|
64
|
+
*/
|
|
65
|
+
export type EmbedderFn = (
|
|
66
|
+
texts: ReadonlyArray<string>,
|
|
67
|
+
) => Promise<ReadonlyArray<ReadonlyArray<number>>>;
|
|
68
|
+
|
|
69
|
+
export type CurateOptions = {
|
|
70
|
+
/**
|
|
71
|
+
* Query / goal string used to score relevance. When absent, the curator
|
|
72
|
+
* skips the relevance-reorder pass and only does dedupe.
|
|
73
|
+
*/
|
|
74
|
+
readonly query?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Cosine-similarity threshold above which two items are considered
|
|
77
|
+
* duplicates. Default 0.92 — high enough to avoid false-positive
|
|
78
|
+
* collapses on near-but-distinct items (a function definition and its
|
|
79
|
+
* caller share substantial token overlap but should NOT dedupe).
|
|
80
|
+
*/
|
|
81
|
+
readonly dedupeThreshold?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Max items to keep after relevance scoring. Items below this rank are
|
|
84
|
+
* dropped entirely. When absent, no items are dropped from the
|
|
85
|
+
* relevance pass — items are only reordered. Set when you want a hard
|
|
86
|
+
* top-K filter.
|
|
87
|
+
*/
|
|
88
|
+
readonly relevanceTopK?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Embedder supplied by the caller. Required if any item lacks a
|
|
91
|
+
* pre-computed `embedding` AND either dedupe or relevance reorder is
|
|
92
|
+
* requested. The function-level types let it stay optional for the
|
|
93
|
+
* pre-embedded case (most production callers).
|
|
94
|
+
*/
|
|
95
|
+
readonly embedder?: EmbedderFn;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const DEFAULT_DEDUPE_THRESHOLD = 0.92;
|
|
99
|
+
|
|
100
|
+
function cosine(a: ReadonlyArray<number>, b: ReadonlyArray<number>): number {
|
|
101
|
+
if (a.length !== b.length) {
|
|
102
|
+
throw new CompactionCuratorError(`embedding dimension mismatch (${a.length} vs ${b.length})`);
|
|
103
|
+
}
|
|
104
|
+
let dot = 0;
|
|
105
|
+
let na = 0;
|
|
106
|
+
let nb = 0;
|
|
107
|
+
for (let i = 0; i < a.length; i++) {
|
|
108
|
+
const av = a[i] ?? 0;
|
|
109
|
+
const bv = b[i] ?? 0;
|
|
110
|
+
dot += av * bv;
|
|
111
|
+
na += av * av;
|
|
112
|
+
nb += bv * bv;
|
|
113
|
+
}
|
|
114
|
+
if (na === 0 || nb === 0) return 0;
|
|
115
|
+
return dot / Math.sqrt(na * nb);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ensure every input item has an embedding. Items already carrying one
|
|
120
|
+
* pass through; items missing one are batched into a single embedder
|
|
121
|
+
* call. Preserves order so the caller's downstream indexing isn't
|
|
122
|
+
* disturbed.
|
|
123
|
+
*/
|
|
124
|
+
async function ensureEmbeddings(
|
|
125
|
+
items: ReadonlyArray<Item>,
|
|
126
|
+
embedder: EmbedderFn | undefined,
|
|
127
|
+
): Promise<ReadonlyArray<ReadonlyArray<number>>> {
|
|
128
|
+
if (items.length === 0) return [];
|
|
129
|
+
const missingIdx: number[] = [];
|
|
130
|
+
const missingTexts: string[] = [];
|
|
131
|
+
for (let i = 0; i < items.length; i++) {
|
|
132
|
+
const item = items[i];
|
|
133
|
+
if (item !== undefined && item.embedding === undefined) {
|
|
134
|
+
missingIdx.push(i);
|
|
135
|
+
missingTexts.push(item.text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (missingIdx.length > 0 && embedder === undefined) {
|
|
139
|
+
throw new CompactionCuratorError(
|
|
140
|
+
`${missingIdx.length} item(s) lack pre-computed embeddings and no embedder was supplied — pass options.embedder or pre-embed your items`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const fresh = embedder !== undefined && missingIdx.length > 0 ? await embedder(missingTexts) : [];
|
|
144
|
+
return items.map((item, i) => {
|
|
145
|
+
if (item.embedding !== undefined) return item.embedding;
|
|
146
|
+
const slot = missingIdx.indexOf(i);
|
|
147
|
+
if (slot === -1) {
|
|
148
|
+
throw new CompactionCuratorError(`internal: missing embedding for item ${i}`);
|
|
149
|
+
}
|
|
150
|
+
const v = fresh[slot];
|
|
151
|
+
if (v === undefined) {
|
|
152
|
+
throw new CompactionCuratorError(`embedder returned no vector for item ${i}`);
|
|
153
|
+
}
|
|
154
|
+
return v;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Pass 1 — semantic dedupe. For each item, if a *prior* item's embedding
|
|
160
|
+
* has cosine ≥ threshold, drop the current. Order-preserving: the first
|
|
161
|
+
* occurrence wins (keeps the head of the conversation, which carries
|
|
162
|
+
* goal-setting context).
|
|
163
|
+
*/
|
|
164
|
+
export function dedupeBySimilarity(
|
|
165
|
+
items: ReadonlyArray<Item>,
|
|
166
|
+
embeddings: ReadonlyArray<ReadonlyArray<number>>,
|
|
167
|
+
threshold: number,
|
|
168
|
+
): { kept: ReadonlyArray<number>; dropped: ReadonlyArray<number> } {
|
|
169
|
+
const kept: number[] = [];
|
|
170
|
+
const dropped: number[] = [];
|
|
171
|
+
for (let i = 0; i < items.length; i++) {
|
|
172
|
+
let isDuplicate = false;
|
|
173
|
+
for (const k of kept) {
|
|
174
|
+
const e1 = embeddings[i];
|
|
175
|
+
const e2 = embeddings[k];
|
|
176
|
+
if (e1 === undefined || e2 === undefined) continue;
|
|
177
|
+
if (cosine(e1, e2) >= threshold) {
|
|
178
|
+
isDuplicate = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (isDuplicate) dropped.push(i);
|
|
183
|
+
else kept.push(i);
|
|
184
|
+
}
|
|
185
|
+
return { kept, dropped };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Pass 2 — relevance reorder. Compute cosine(query, item) for every
|
|
190
|
+
* surviving item; return indices in descending similarity order.
|
|
191
|
+
* Stable sort: items with identical similarity stay in original order.
|
|
192
|
+
*
|
|
193
|
+
* When `topK` is supplied, items beyond that rank are dropped.
|
|
194
|
+
*/
|
|
195
|
+
export async function rankByRelevance(
|
|
196
|
+
items: ReadonlyArray<Item>,
|
|
197
|
+
itemEmbeddings: ReadonlyArray<ReadonlyArray<number>>,
|
|
198
|
+
query: string,
|
|
199
|
+
embedder: EmbedderFn | undefined,
|
|
200
|
+
topK?: number,
|
|
201
|
+
): Promise<ReadonlyArray<number>> {
|
|
202
|
+
if (items.length === 0) return [];
|
|
203
|
+
if (embedder === undefined) {
|
|
204
|
+
throw new CompactionCuratorError(
|
|
205
|
+
"rankByRelevance requires an embedder to compute the query vector",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const queryVecs = await embedder([query]);
|
|
209
|
+
const queryVec = queryVecs[0];
|
|
210
|
+
if (queryVec === undefined) {
|
|
211
|
+
throw new CompactionCuratorError("embedder returned no vector for the query");
|
|
212
|
+
}
|
|
213
|
+
const scored: Array<{ idx: number; sim: number; original: number }> = [];
|
|
214
|
+
for (let i = 0; i < items.length; i++) {
|
|
215
|
+
const e = itemEmbeddings[i];
|
|
216
|
+
if (e === undefined) continue;
|
|
217
|
+
scored.push({ idx: i, sim: cosine(queryVec, e), original: i });
|
|
218
|
+
}
|
|
219
|
+
scored.sort((a, b) => {
|
|
220
|
+
if (a.sim !== b.sim) return b.sim - a.sim;
|
|
221
|
+
return a.original - b.original; // stable
|
|
222
|
+
});
|
|
223
|
+
const ordered = scored.map((s) => s.idx);
|
|
224
|
+
if (topK !== undefined && ordered.length > topK) return ordered.slice(0, topK);
|
|
225
|
+
return ordered;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export type CurationResult = {
|
|
229
|
+
/** Items in the curated order (deduped + relevance-ranked + topK-trimmed). */
|
|
230
|
+
readonly items: ReadonlyArray<Item>;
|
|
231
|
+
/** Indices into the *original* input array, in output order. */
|
|
232
|
+
readonly originalIndices: ReadonlyArray<number>;
|
|
233
|
+
/** Indices that were dropped (in original order). */
|
|
234
|
+
readonly droppedIndices: ReadonlyArray<number>;
|
|
235
|
+
/** Bytes saved as compared to the original total (text only). */
|
|
236
|
+
readonly bytesSaved: number;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* High-level entry: dedupe then relevance-reorder (when a query is
|
|
241
|
+
* supplied) then top-K filter. The most common call from
|
|
242
|
+
* `compaction-autocompact` is `curate(items, { query, embedder })`.
|
|
243
|
+
*/
|
|
244
|
+
export async function curate(
|
|
245
|
+
items: ReadonlyArray<Item>,
|
|
246
|
+
opts: CurateOptions = {},
|
|
247
|
+
): Promise<CurationResult> {
|
|
248
|
+
if (items.length === 0) {
|
|
249
|
+
return { items: [], originalIndices: [], droppedIndices: [], bytesSaved: 0 };
|
|
250
|
+
}
|
|
251
|
+
const threshold = opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD;
|
|
252
|
+
const embeddings = await ensureEmbeddings(items, opts.embedder);
|
|
253
|
+
const { kept, dropped } = dedupeBySimilarity(items, embeddings, threshold);
|
|
254
|
+
let surviving: ReadonlyArray<number> = kept;
|
|
255
|
+
const droppedAll: number[] = [...dropped];
|
|
256
|
+
if (opts.query !== undefined && opts.query.length > 0) {
|
|
257
|
+
const subItems = surviving.map((i) => items[i]).filter((it): it is Item => it !== undefined);
|
|
258
|
+
const subEmbeddings = surviving
|
|
259
|
+
.map((i) => embeddings[i])
|
|
260
|
+
.filter((e): e is ReadonlyArray<number> => e !== undefined);
|
|
261
|
+
const localOrder = await rankByRelevance(
|
|
262
|
+
subItems,
|
|
263
|
+
subEmbeddings,
|
|
264
|
+
opts.query,
|
|
265
|
+
opts.embedder,
|
|
266
|
+
opts.relevanceTopK,
|
|
267
|
+
);
|
|
268
|
+
const localToOriginal: ReadonlyArray<number> = surviving;
|
|
269
|
+
surviving = localOrder
|
|
270
|
+
.map((localIdx) => localToOriginal[localIdx])
|
|
271
|
+
.filter((i): i is number => i !== undefined);
|
|
272
|
+
if (opts.relevanceTopK !== undefined) {
|
|
273
|
+
const survivingSet = new Set(surviving);
|
|
274
|
+
for (const i of kept) if (!survivingSet.has(i)) droppedAll.push(i);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const outItems = surviving.map((i) => items[i]).filter((it): it is Item => it !== undefined);
|
|
278
|
+
const droppedTotal = droppedAll
|
|
279
|
+
.map((i) => items[i])
|
|
280
|
+
.filter((it): it is Item => it !== undefined)
|
|
281
|
+
.reduce((acc, it) => acc + Buffer.byteLength(it.text, "utf8"), 0);
|
|
282
|
+
return {
|
|
283
|
+
items: outItems,
|
|
284
|
+
originalIndices: surviving,
|
|
285
|
+
droppedIndices: [...droppedAll].sort((a, b) => a - b),
|
|
286
|
+
bytesSaved: droppedTotal,
|
|
287
|
+
};
|
|
288
|
+
}
|