@tpsdev-ai/flair 0.27.1 → 0.28.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/dist/cli.js +22 -0
- package/dist/resources/migrations/embedding-stamp.js +23 -0
- package/dist/resources/migrations/graph-heal.js +250 -0
- package/dist/resources/migrations/registry.js +11 -5
- package/dist/resources/rerank-provider.js +220 -32
- package/docs/rerank-provisioning.md +40 -6
- package/package.json +4 -1
- package/schemas/memory.graphql +19 -1
package/dist/cli.js
CHANGED
|
@@ -8873,6 +8873,28 @@ program
|
|
|
8873
8873
|
}
|
|
8874
8874
|
});
|
|
8875
8875
|
// ─── flair reembed ────────────────────────────────────────────────────────────
|
|
8876
|
+
//
|
|
8877
|
+
// ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
|
|
8878
|
+
// `flair reembed` replaces the stored embedding of many rows IN PLACE (it
|
|
8879
|
+
// clears embedding/embeddingModel, then re-PUTs through Memory.put()'s regen
|
|
8880
|
+
// branch — the same bulk in-place re-embed path resources/migrations/
|
|
8881
|
+
// embedding-stamp.ts uses). Historically, an OLDER (pre-fix) Harper's
|
|
8882
|
+
// INCREMENTAL HNSW update left stale/asymmetric reverse edges under bulk
|
|
8883
|
+
// re-embed, which collapsed prod recall in July. That engine bug is FIXED in
|
|
8884
|
+
// the Harper this ships against (5.1.22) — its update path reconstructs the
|
|
8885
|
+
// prior vector and does the reverse-edge cleanup the old build skipped — so a
|
|
8886
|
+
// bulk re-embed no longer corrupts the graph. DEFENSE-IN-DEPTH RULE (a
|
|
8887
|
+
// prudent, version-independent default, not a workaround for a live bug): pair
|
|
8888
|
+
// any BULK re-embed with a structural graph REBUILD trigger rather than relying
|
|
8889
|
+
// on incremental HNSW updates to converge (today: the
|
|
8890
|
+
// `@indexed(type:"HNSW", M:16)` descriptor bump in schemas/memory.graphql,
|
|
8891
|
+
// which makes Harper clear + rebuild the graph cleanly from the stored vectors
|
|
8892
|
+
// on the next boot — see resources/migrations/graph-heal.ts). Do NOT use
|
|
8893
|
+
// resources/MemoryReindex.ts's `_reindex` for graph correctness (it re-PUTs the
|
|
8894
|
+
// same vector through the incremental path and rebuilds nothing). If a `flair
|
|
8895
|
+
// reembed` run ever materially changes the vector space (e.g. a model swap),
|
|
8896
|
+
// follow it with a deploy that trips the structural reindex (bump the HNSW
|
|
8897
|
+
// descriptor / restart after a schema change).
|
|
8876
8898
|
program
|
|
8877
8899
|
.command("reembed")
|
|
8878
8900
|
.description("Re-generate embeddings for memories with stale or missing model tags")
|
|
@@ -101,6 +101,29 @@
|
|
|
101
101
|
* OTHER, not-yet-understood counting artifact; this lets the runner PROVE
|
|
102
102
|
* (via `.get()`, a primary-key lookup — always live, never index-assisted)
|
|
103
103
|
* that a nonzero countPending() result is real before halting on it.
|
|
104
|
+
*
|
|
105
|
+
* ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
|
|
106
|
+
* This migration RE-EMBEDS rows in BULK via `PUT /Memory/:id` — it replaces
|
|
107
|
+
* the stored vector of many rows in place. Historically, an OLDER (pre-fix)
|
|
108
|
+
* Harper's INCREMENTAL HNSW update left stale/asymmetric reverse edges under
|
|
109
|
+
* exactly this kind of bulk re-embed (order-dependent), which collapsed prod
|
|
110
|
+
* recall (p@3 0.25) in July. That engine bug is FIXED in the Harper this ships
|
|
111
|
+
* against (5.1.22): its index() reconstructs the prior vector from the stored
|
|
112
|
+
* node and carries the node's real connections into the reverse-edge cleanup
|
|
113
|
+
* the old build skipped, so a bulk re-embed no longer corrupts the graph.
|
|
114
|
+
* DEFENSE-IN-DEPTH RULE (a prudent, version-independent default — not a
|
|
115
|
+
* workaround for a live bug): pair any BULK re-embed with a structural graph
|
|
116
|
+
* REBUILD trigger rather than relying on incremental HNSW updates to converge
|
|
117
|
+
* — it keeps recall correct regardless of engine version, closes the rare
|
|
118
|
+
* decode-failure edge case, and heals a graph already corrupted on disk by an
|
|
119
|
+
* older engine. Do NOT reach for resources/MemoryReindex.ts's `_reindex` for
|
|
120
|
+
* graph correctness (it re-PUTs the same vector through the incremental path
|
|
121
|
+
* and rebuilds nothing). The lever flair uses is a structural schema-descriptor
|
|
122
|
+
* change: the `@indexed(type:"HNSW", M:16)` bump in schemas/memory.graphql
|
|
123
|
+
* ships alongside this migration precisely so a graph corrupted under an older
|
|
124
|
+
* engine is rebuilt on the upgrade boot (observed/ledgered by
|
|
125
|
+
* resources/migrations/graph-heal.ts). Before enabling any FUTURE bulk
|
|
126
|
+
* re-embed, ship a structural reindex trigger with it.
|
|
104
127
|
*/
|
|
105
128
|
import { databases } from "@harperfast/harper";
|
|
106
129
|
import { getModelId } from "../embeddings-provider.js";
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-heal.ts — the recall graph-heal OBSERVABILITY migration.
|
|
3
|
+
*
|
|
4
|
+
* WHAT ACTUALLY HEALS: nothing in this file. The heal is entirely
|
|
5
|
+
* schema-driven — `schemas/memory.graphql`'s `embedding` field now declares
|
|
6
|
+
* `@indexed(type: "HNSW", M: 16)` (M:16 = Harper's own default, a zero
|
|
7
|
+
* behavior change). On the first boot after upgrade, Harper structurally
|
|
8
|
+
* diffs the persisted per-attribute HNSW descriptor (built with NO options on
|
|
9
|
+
* every pre-this-change install) against the schema and, because
|
|
10
|
+
* `canonicalizeIndexOptions` does NOT inject defaults (`{type:"HNSW"}` and
|
|
11
|
+
* `{type:"HNSW",M:16}` are DIFFERENT canonical keys — verified against the
|
|
12
|
+
* installed @harperfast/harper source, resources/databases.js:
|
|
13
|
+
* canonicalizeIndexOptions + the `indexOptionsChanged` reset of
|
|
14
|
+
* `lastIndexedKey` to undefined), clears the graph store and rebuilds it
|
|
15
|
+
* CLEANLY from the already-correct stored vectors. That rebuild heals the
|
|
16
|
+
* stale/asymmetric reverse-edge corruption that an OLDER (pre-fix) Harper's
|
|
17
|
+
* INCREMENTAL HNSW update left on disk during the July bulk embedding-stamp
|
|
18
|
+
* re-embed — a fresh index of the same vectors finds the true neighbors, the
|
|
19
|
+
* mangled live graph did not. The corruption is HISTORICAL: the installed
|
|
20
|
+
* Harper (5.1.22) already fixes that incremental-update path —
|
|
21
|
+
* HierarchicalNavigableSmallWorld reconstructs the prior vector from the
|
|
22
|
+
* stored node and carries its real connections into the reverse-edge cleanup
|
|
23
|
+
* the old build skipped — so current Harper no longer produces it; it just
|
|
24
|
+
* still persists on disk in stores re-embedded under the old engine. The heal
|
|
25
|
+
* is therefore version-agnostic: it repairs any such store regardless of the
|
|
26
|
+
* Harper now running. No re-embed happens — only the graph is rebuilt from the
|
|
27
|
+
* vectors already on disk.
|
|
28
|
+
*
|
|
29
|
+
* WHY THIS MIGRATION EXISTS ANYWAY: the heal is invisible — Harper triggers
|
|
30
|
+
* it, this code cannot force it, and there is no Harper API to observe it
|
|
31
|
+
* directly. So this migration's ONLY job is to VERIFY + LEDGER that recall is
|
|
32
|
+
* healthy after the boot, so the heal is auditable and version-gated (runs
|
|
33
|
+
* once per upgrade, then the runner's state-file short-circuit — state.ts —
|
|
34
|
+
* skips it). It touches ZERO rows: riskClass 'derived-only', countPending()
|
|
35
|
+
* is always 0, run() writes one structural-only OrgEvent and returns
|
|
36
|
+
* `processed: 0`. The runner (resources/migrations/runner.ts) then records
|
|
37
|
+
* success in the state file, short-circuiting every subsequent boot at this
|
|
38
|
+
* version.
|
|
39
|
+
*
|
|
40
|
+
* NON-THROWING throughout (invariant II — the runner halts-not-bricks): a
|
|
41
|
+
* canary that can't run, an index still mid-rebuild, or a ledger-write
|
|
42
|
+
* failure all degrade to "not verified this boot, retry next boot" or a
|
|
43
|
+
* best-effort skip — never an exception into the boot path.
|
|
44
|
+
*
|
|
45
|
+
* DEFENSE-IN-DEPTH RULE (a prudent, version-independent default — NOT a
|
|
46
|
+
* workaround for a live bug): any BULK re-embed of the corpus should be paired
|
|
47
|
+
* with a structural graph REBUILD (a full runIndexing pass) rather than
|
|
48
|
+
* relying on the engine's incremental `index()` updates to converge. The
|
|
49
|
+
* installed Harper (5.1.22) DOES converge — its update path is fixed — but the
|
|
50
|
+
* rebuild keeps recall correct regardless of engine version, closes the rare
|
|
51
|
+
* decode-failure edge case, and is what heals a store already corrupted on
|
|
52
|
+
* disk by an older engine. The lever flair uses for that rebuild is a
|
|
53
|
+
* structural-schema change (the M:16 descriptor bump that ships with this
|
|
54
|
+
* migration) — see the guard comments in
|
|
55
|
+
* resources/migrations/embedding-stamp.ts and the `flair reembed` path in
|
|
56
|
+
* src/cli.ts. Do NOT use resources/MemoryReindex.ts's `_reindex` for graph
|
|
57
|
+
* correctness — it re-PUTs the same vector through the incremental path and
|
|
58
|
+
* does not rebuild the graph.
|
|
59
|
+
*/
|
|
60
|
+
import { databases } from "@harperfast/harper";
|
|
61
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
62
|
+
import { dirname, join } from "node:path";
|
|
63
|
+
import { fileURLToPath } from "node:url";
|
|
64
|
+
export const GRAPH_HEAL_ID = "graph-heal";
|
|
65
|
+
/** How many rows to scan for a canary that carries a real embedding. Bounded — detect() must stay cheap. */
|
|
66
|
+
const CANARY_SCAN_LIMIT = 8;
|
|
67
|
+
function defaultMemoryTable() {
|
|
68
|
+
return databases.flair.Memory;
|
|
69
|
+
}
|
|
70
|
+
function defaultOrgEventTable() {
|
|
71
|
+
return databases.flair.OrgEvent;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Same "resolve the running package's own version" idiom as
|
|
75
|
+
* resources/health.ts / resources/migration-boot.ts. Inlined (not imported
|
|
76
|
+
* from migration-boot) to avoid an import cycle: migration-boot → registry →
|
|
77
|
+
* graph-heal. Purely informational — it only stamps the ledger detail blob;
|
|
78
|
+
* a "dev" fallback is harmless.
|
|
79
|
+
*/
|
|
80
|
+
function resolveRunningVersionLocal() {
|
|
81
|
+
try {
|
|
82
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
83
|
+
const candidates = [
|
|
84
|
+
join(here, "..", "..", "..", "package.json"), // dist/resources/migrations → root
|
|
85
|
+
join(here, "..", "..", "package.json"),
|
|
86
|
+
];
|
|
87
|
+
for (const p of candidates) {
|
|
88
|
+
if (existsSync(p)) {
|
|
89
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
90
|
+
if (pkg.version)
|
|
91
|
+
return String(pkg.version);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* fall through */
|
|
97
|
+
}
|
|
98
|
+
return process.env.npm_package_version ?? "dev";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pick a canary row that carries a real embedding vector: scan a bounded
|
|
102
|
+
* handful of rows and `.get()` the first one whose stored `embedding` is a
|
|
103
|
+
* non-empty array. `.get()` (a primary-key read) always returns the full
|
|
104
|
+
* record including `embedding`, independent of any `select`/projection
|
|
105
|
+
* quirk. Returns null when the corpus has no embedded rows at all — a fresh
|
|
106
|
+
* or empty instance, where there is simply nothing to heal or verify.
|
|
107
|
+
*/
|
|
108
|
+
async function pickCanary(table) {
|
|
109
|
+
for await (const row of table.search({ select: ["id"], limit: CANARY_SCAN_LIMIT })) {
|
|
110
|
+
const id = String(row.id ?? "");
|
|
111
|
+
if (!id)
|
|
112
|
+
continue;
|
|
113
|
+
const full = await table.get(id);
|
|
114
|
+
const emb = full?.embedding;
|
|
115
|
+
if (Array.isArray(emb) && emb.length > 0)
|
|
116
|
+
return { id, embedding: emb };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The id of the HNSW nearest neighbor of `target` — the exact cosine-sort
|
|
122
|
+
* query the retrieval path (resources/semantic-retrieval-core.ts) uses,
|
|
123
|
+
* bounded to rank 1. Throws if the index is unavailable / mid-rebuild; the
|
|
124
|
+
* caller treats a throw as "not verified this boot".
|
|
125
|
+
*/
|
|
126
|
+
async function topNeighborId(table, target) {
|
|
127
|
+
for await (const row of table.search({
|
|
128
|
+
sort: { attribute: "embedding", target, distance: "cosine" },
|
|
129
|
+
select: ["id"],
|
|
130
|
+
limit: 1,
|
|
131
|
+
})) {
|
|
132
|
+
const id = row.id;
|
|
133
|
+
return id ? String(id) : null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Canary self-recall: a healthy HNSW graph returns the canary row itself as
|
|
139
|
+
* the rank-1 neighbor of its OWN stored vector. Cheap, read-only, bounded —
|
|
140
|
+
* and the cleanest black-box proof the graph rebuilt and is serving (a
|
|
141
|
+
* mid-rebuild index throws or returns nothing; a store with no embedded rows
|
|
142
|
+
* has no canary). Never throws (invariant II).
|
|
143
|
+
*/
|
|
144
|
+
async function recallHealthy(table) {
|
|
145
|
+
try {
|
|
146
|
+
const canary = await pickCanary(table);
|
|
147
|
+
if (!canary)
|
|
148
|
+
return { verified: false, hasData: false };
|
|
149
|
+
const top = await topNeighborId(table, canary.embedding);
|
|
150
|
+
return { verified: top === canary.id, hasData: true };
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { verified: false, hasData: true };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Count rows carrying a real (non-hash-fallback) embedding — the vectors that live in the HNSW graph. Best-effort. */
|
|
157
|
+
async function countEmbeddedVectors(table) {
|
|
158
|
+
try {
|
|
159
|
+
let n = 0;
|
|
160
|
+
for await (const row of table.search({ select: ["embeddingModel"] })) {
|
|
161
|
+
const m = row.embeddingModel;
|
|
162
|
+
if (typeof m === "string" && m.length > 0 && m !== "hash-512d")
|
|
163
|
+
n++;
|
|
164
|
+
}
|
|
165
|
+
return n;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Structural-only ledger OrgEvent (Sherlock discipline, same as
|
|
173
|
+
* resources/migrations/ledger.ts): counts, outcome, version — NEVER a memory
|
|
174
|
+
* id or content (the canary row's id is deliberately omitted). Written via an
|
|
175
|
+
* internal (no-HTTP-context) put — resolveAgentAuth(undefined) → internal, the
|
|
176
|
+
* same trusted server-internal write path the migration ledger already uses.
|
|
177
|
+
*/
|
|
178
|
+
async function writeGraphHealEvent(obs, table) {
|
|
179
|
+
const countNote = obs.embeddedVectorCount != null
|
|
180
|
+
? ` (${obs.embeddedVectorCount} embedded vector${obs.embeddedVectorCount === 1 ? "" : "s"})`
|
|
181
|
+
: "";
|
|
182
|
+
await table.put({
|
|
183
|
+
id: `migration-graph-heal-verified-${obs.at}`,
|
|
184
|
+
authorId: "flair-migrations",
|
|
185
|
+
kind: "migration",
|
|
186
|
+
scope: "full",
|
|
187
|
+
summary: `HNSW graph-heal: recall ${obs.verified ? "verified healthy" : "unconfirmed"}${countNote}`,
|
|
188
|
+
detail: JSON.stringify({
|
|
189
|
+
migrationId: GRAPH_HEAL_ID,
|
|
190
|
+
verified: obs.verified,
|
|
191
|
+
canaryRank1: obs.verified,
|
|
192
|
+
embeddedVectorCount: obs.embeddedVectorCount,
|
|
193
|
+
runningVersion: obs.runningVersion,
|
|
194
|
+
verifiedAt: obs.at,
|
|
195
|
+
}),
|
|
196
|
+
refId: GRAPH_HEAL_ID,
|
|
197
|
+
createdAt: obs.at,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* `getTable`/`getOrgEventTable`/`getVersion`/`now` are injectable so tests can
|
|
202
|
+
* exercise detect/countPending/run against fakes (matching the mocking style
|
|
203
|
+
* of embedding-stamp.ts / the unit tests). The real HNSW self-recall + ledger
|
|
204
|
+
* path is exercised end-to-end in
|
|
205
|
+
* test/integration/hnsw-graph-heal-e2e.test.ts.
|
|
206
|
+
*/
|
|
207
|
+
export function createGraphHealMigration(getTable = defaultMemoryTable, getOrgEventTable = defaultOrgEventTable, getVersion = resolveRunningVersionLocal, now = () => new Date()) {
|
|
208
|
+
return {
|
|
209
|
+
id: GRAPH_HEAL_ID,
|
|
210
|
+
riskClass: "derived-only",
|
|
211
|
+
affectsTables: ["Memory"],
|
|
212
|
+
/**
|
|
213
|
+
* True only when recall is confirmed healthy AND there is embedded data
|
|
214
|
+
* to verify — i.e. the graph rebuilt and is serving. Returns false (the
|
|
215
|
+
* runner marks the migration completed for this boot WITHOUT writing a
|
|
216
|
+
* success state entry, so detect() runs again next boot) when the index
|
|
217
|
+
* is still mid-rebuild or the store has nothing embedded yet. This is the
|
|
218
|
+
* enrollment gate: only a confirmed-healthy boot proceeds to run() +
|
|
219
|
+
* ledger + the version short-circuit.
|
|
220
|
+
*/
|
|
221
|
+
async detect() {
|
|
222
|
+
const { verified } = await recallHealthy(getTable());
|
|
223
|
+
return verified;
|
|
224
|
+
},
|
|
225
|
+
/** Observe-only — never any pending row work. */
|
|
226
|
+
async countPending() {
|
|
227
|
+
return 0;
|
|
228
|
+
},
|
|
229
|
+
/**
|
|
230
|
+
* Called exactly once (countPending() is 0 → the runner's batch loop runs
|
|
231
|
+
* once and breaks). Re-verifies recall for the ledger snapshot, counts
|
|
232
|
+
* the embedded vectors, writes ONE structural-only OrgEvent, and returns
|
|
233
|
+
* `processed: 0` so the completion gate (count+marker, derived-only)
|
|
234
|
+
* passes and the runner records success → short-circuit next boot.
|
|
235
|
+
*/
|
|
236
|
+
async run(_batchSize) {
|
|
237
|
+
const table = getTable();
|
|
238
|
+
const { verified } = await recallHealthy(table);
|
|
239
|
+
const embeddedVectorCount = await countEmbeddedVectors(table);
|
|
240
|
+
try {
|
|
241
|
+
await writeGraphHealEvent({ verified, embeddedVectorCount, runningVersion: getVersion(), at: now().toISOString() }, getOrgEventTable());
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Observability must never brick the runner — a ledger-write failure
|
|
245
|
+
// is swallowed; the heal itself already happened schema-side.
|
|
246
|
+
}
|
|
247
|
+
return { processed: 0, touchedIds: [] };
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* registry.ts — the MigrationRegistry: an ordered list of registered
|
|
3
|
-
* migrations. embedding-stamp always
|
|
4
|
-
* variant registers ONLY when shouldRegisterSyntheticMigration() is
|
|
5
|
-
* (see synthetic-test-migration.ts's doc for the exact gating rule).
|
|
3
|
+
* migrations. embedding-stamp and graph-heal always register; the synthetic
|
|
4
|
+
* CI-only variant registers ONLY when shouldRegisterSyntheticMigration() is
|
|
5
|
+
* true (see synthetic-test-migration.ts's doc for the exact gating rule).
|
|
6
6
|
*/
|
|
7
7
|
import { createEmbeddingStampMigration } from "./embedding-stamp.js";
|
|
8
|
+
import { createGraphHealMigration } from "./graph-heal.js";
|
|
8
9
|
import { createSyntheticTestMigration, shouldRegisterSyntheticMigration } from "./synthetic-test-migration.js";
|
|
9
10
|
export class MigrationRegistry {
|
|
10
11
|
migrations = [];
|
|
@@ -23,12 +24,17 @@ export class MigrationRegistry {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
|
-
* Builds the production registry. Always includes embedding-stamp
|
|
27
|
-
* conditionally includes the CI-only synthetic migration.
|
|
27
|
+
* Builds the production registry. Always includes embedding-stamp and
|
|
28
|
+
* graph-heal; conditionally includes the CI-only synthetic migration.
|
|
29
|
+
*
|
|
30
|
+
* Order note: graph-heal registers AFTER embedding-stamp so that if a boot
|
|
31
|
+
* ever runs both, the (verify-only) recall check ledgers the state AFTER any
|
|
32
|
+
* re-embed work that cycle already applied — never a stale pre-embed reading.
|
|
28
33
|
*/
|
|
29
34
|
export function buildRegistry(env = process.env) {
|
|
30
35
|
const registry = new MigrationRegistry();
|
|
31
36
|
registry.register(createEmbeddingStampMigration());
|
|
37
|
+
registry.register(createGraphHealMigration());
|
|
32
38
|
if (shouldRegisterSyntheticMigration(env)) {
|
|
33
39
|
registry.register(createSyntheticTestMigration());
|
|
34
40
|
}
|
|
@@ -9,21 +9,62 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Two inference modes (selected by FLAIR_RERANK_MODEL):
|
|
11
11
|
*
|
|
12
|
-
* 1. "
|
|
13
|
-
* causal-LM reranker, NOT a rank-pooling cross-encoder. Its GGUF reports
|
|
14
|
-
* supportsRanking=false, so node-llama-cpp's createRankingContext() rejects
|
|
15
|
-
* it. The correct path is generative: format query+doc into the official
|
|
16
|
-
* instruction prompt ending at the assistant turn, evaluate, and read the
|
|
17
|
-
* next-token probability of "yes" vs "no":
|
|
18
|
-
* score = P(yes) / (P(yes) + P(no))
|
|
19
|
-
* Implemented via seq.controlledEvaluate with generateNext probabilities.
|
|
20
|
-
* (Validated offline in RERANK-PILOT-RESULTS.md: 4/4 target cases flipped
|
|
21
|
-
* positive, p@1 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit.)
|
|
22
|
-
*
|
|
23
|
-
* 2. "jina-reranker-v2" (latency fallback) — jina-reranker-v2 IS a rank-pooling
|
|
12
|
+
* 1. "jina-reranker-v2" (DEFAULT, working) — jina-reranker-v2 IS a rank-pooling
|
|
24
13
|
* cross-encoder; its gpustack GGUF loads via createRankingContext()/rankAll
|
|
25
|
-
* (supportsRanking=true). ~145ms / 16 docs
|
|
26
|
-
* hardest consensus case negative
|
|
14
|
+
* (supportsRanking=true). ~145ms / 16 docs in the offline pilot (7/8,
|
|
15
|
+
* leaves the hardest consensus case negative — weaker than qwen3's
|
|
16
|
+
* generative path in that pilot, but it's the path that actually PRODUCES
|
|
17
|
+
* a score inside Harper's process — see #811 / point 2 below).
|
|
18
|
+
*
|
|
19
|
+
* 2. "qwen3-reranker-0.6b-q8" (EXPERIMENTAL, quality-if-it-worked) —
|
|
20
|
+
* Qwen3-Reranker-0.6B is a causal-LM reranker, NOT a rank-pooling
|
|
21
|
+
* cross-encoder. Its GGUF reports supportsRanking=false, so
|
|
22
|
+
* createRankingContext() rejects it; the intended path is generative:
|
|
23
|
+
* format query+doc into the official instruction prompt ending at the
|
|
24
|
+
* assistant turn, evaluate, and read the next-token probability of "yes"
|
|
25
|
+
* vs "no": score = P(yes) / (P(yes) + P(no)), via seq.controlledEvaluate
|
|
26
|
+
* with generateNext probabilities. Validated OFFLINE, standalone, in
|
|
27
|
+
* RERANK-PILOT-RESULTS.md (4/4 target cases flipped positive, p@1
|
|
28
|
+
* 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit) — but
|
|
29
|
+
* flair#811 found that INSIDE Harper's resource runtime,
|
|
30
|
+
* controlledEvaluate reliably returns empty logits (no decoded output),
|
|
31
|
+
* so every generative call throws "generative reranker produced no
|
|
32
|
+
* logits" and falls open. Root cause per docs/rerank-provisioning.md's
|
|
33
|
+
* "Known limitation": HFE's embedding engine has already initialized a
|
|
34
|
+
* separate native llama backend in the same process before this
|
|
35
|
+
* provider's own dynamic import runs, and the low-level
|
|
36
|
+
* controlledEvaluate + custom-sampler logit readout the generative path
|
|
37
|
+
* needs doesn't survive that dual-backend residency (ordinary model
|
|
38
|
+
* loading and the jina rank-pooling call both work fine across it — only
|
|
39
|
+
* this specific low-level readout is affected). Kept available and
|
|
40
|
+
* documented for whoever revisits dual-backend isolation; NOT the
|
|
41
|
+
* default until that's fixed.
|
|
42
|
+
*
|
|
43
|
+
* Context-budget truncation (flair#811 point 1): real memory content is
|
|
44
|
+
* routinely far longer than either model's small context window. Every
|
|
45
|
+
* (query, doc) pair is bounded BEFORE it reaches the engine — a cheap char
|
|
46
|
+
* pre-cut (`truncateChars`, avoids tokenizing pathological multi-MB content)
|
|
47
|
+
* followed by an exact token-level cut (`truncateForModel`, uses the loaded
|
|
48
|
+
* model's own tokenizer — the real guarantee, since char/token ratio varies
|
|
49
|
+
* a lot across prose/code/CJK/emoji). Budgets are derived from the context
|
|
50
|
+
* size each mode actually requests (both NUMERIC, not "auto" — node-llama-cpp
|
|
51
|
+
* grants a numeric contextSize exactly as asked, see GENERATIVE_CONTEXT_SIZE/
|
|
52
|
+
* RANK_CONTEXT_SIZE below) minus reserved template + query overhead. This
|
|
53
|
+
* makes the `rankAll`/context-overflow throw effectively unreachable in
|
|
54
|
+
* normal operation instead of guaranteed on any real-length memory.
|
|
55
|
+
*
|
|
56
|
+
* Config re-validated on every use (flair#811 point 3): `ensureInit()` used
|
|
57
|
+
* to cache `_modelKey`/`_state` permanently on first call — a later change to
|
|
58
|
+
* FLAIR_RERANK_MODEL (or a transient first-call failure) had NO effect for
|
|
59
|
+
* the lifetime of the process, since `_state === "ready"` (or `"failed"`)
|
|
60
|
+
* short-circuited every subsequent call without re-reading env. `needsReinit()`
|
|
61
|
+
* now compares the currently-resolved model key against what's actually
|
|
62
|
+
* loaded and re-initializes (disposing old handles first) whenever they
|
|
63
|
+
* differ, so "configured model X, served model Y" can no longer persist
|
|
64
|
+
* silently across calls. This is the most likely, directly-fixable
|
|
65
|
+
* code-level cause of the model-mismatch symptom reported in #811; without a
|
|
66
|
+
* live process repro we can't rule out an additional contributing factor,
|
|
67
|
+
* but this closes the gap regardless of the exact prior trigger.
|
|
27
68
|
*
|
|
28
69
|
* Serving path = the same in-process node-llama-cpp the embedding engine ships.
|
|
29
70
|
* No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
|
|
@@ -33,13 +74,15 @@
|
|
|
33
74
|
* embedding GGUF. See docs/rerank-provisioning.md for download sources.
|
|
34
75
|
*/
|
|
35
76
|
import { join } from "node:path";
|
|
36
|
-
// Known reranker models → GGUF filename + inference mode.
|
|
37
|
-
//
|
|
77
|
+
// Known reranker models → GGUF filename + inference mode. jina is the DEFAULT
|
|
78
|
+
// (working — see file header, flair#811): its rank-pooling path completes
|
|
79
|
+
// inside Harper. qwen3 is EXPERIMENTAL: its generative path is validated
|
|
80
|
+
// offline but reliably fails open inside Harper today (empty logits).
|
|
38
81
|
const MODELS = {
|
|
39
|
-
"qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
|
|
40
82
|
"jina-reranker-v2": { file: "jina-reranker-v2-base.Q8_0.gguf", mode: "rank" },
|
|
83
|
+
"qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
|
|
41
84
|
};
|
|
42
|
-
const DEFAULT_MODEL = "
|
|
85
|
+
const DEFAULT_MODEL = "jina-reranker-v2";
|
|
43
86
|
// Official Qwen3-Reranker prompt scaffold (generative yes/no judgement).
|
|
44
87
|
const QWEN_PREFIX = '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>\n<|im_start|>user\n';
|
|
45
88
|
const QWEN_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
|
|
@@ -47,9 +90,71 @@ const QWEN_INSTRUCT = "Given a web search query, retrieve relevant passages that
|
|
|
47
90
|
function buildQwenPrompt(q, doc) {
|
|
48
91
|
return `${QWEN_PREFIX}<Instruct>: ${QWEN_INSTRUCT}\n<Query>: ${q}\n<Document>: ${doc}${QWEN_SUFFIX}`;
|
|
49
92
|
}
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
93
|
+
// ── Context-budget truncation (flair#811 point 1) ──────────────────────────
|
|
94
|
+
// See the file header for the two-layer rationale. Budgets are derived from
|
|
95
|
+
// the context size each mode actually requests, minus reserved overhead —
|
|
96
|
+
// not a flat guess detached from either number.
|
|
97
|
+
/** Context size requested for the generative (Qwen3) context. NUMERIC (not
|
|
98
|
+
* "auto"), so node-llama-cpp grants exactly this many tokens — verified
|
|
99
|
+
* against node-llama-cpp 3.18.1's resolveContextContextSizeOption: a numeric
|
|
100
|
+
* contextSize is granted as-is (or context creation throws
|
|
101
|
+
* InsufficientMemoryError); only "auto"/object requests get VRAM-adaptive
|
|
102
|
+
* shrinking. */
|
|
103
|
+
const GENERATIVE_CONTEXT_SIZE = 1024;
|
|
104
|
+
/** Context size requested for the rank (jina) context — see
|
|
105
|
+
* createRankingContext() below. Also numeric, same guarantee. */
|
|
106
|
+
const RANK_CONTEXT_SIZE = 2048;
|
|
107
|
+
/** Reserved tokens for the Qwen3 prompt scaffold (QWEN_PREFIX + INSTRUCT +
|
|
108
|
+
* SUFFIX + <|im_start|>/<|im_end|> special tokens). Rough estimate from the
|
|
109
|
+
* literal scaffold text is ~130-140 tokens; 180 leaves real headroom above
|
|
110
|
+
* that estimate rather than sitting right on top of it — we don't have the
|
|
111
|
+
* actual Qwen3-Reranker tokenizer available to measure exactly (no GGUF in
|
|
112
|
+
* this worktree), so this errs generous. Belt-and-suspenders: scoreGenerative
|
|
113
|
+
* also hard-checks the FINAL built prompt against GENERATIVE_CONTEXT_SIZE
|
|
114
|
+
* and throws (fail-open) rather than proceeding if this margin ever isn't
|
|
115
|
+
* enough — see there. */
|
|
116
|
+
const GENERATIVE_TEMPLATE_OVERHEAD_TOKENS = 180;
|
|
117
|
+
/** Rank mode's DEFAULT template (no GGUF `chat_template.rerank` metadata) is
|
|
118
|
+
* just BOS + EOS + SEP + EOS — ~4 tokens. But if the loaded GGUF DOES carry
|
|
119
|
+
* a custom rerank template (LlamaRankingContext prefers it when present),
|
|
120
|
+
* that template's literal text adds more; we can't inspect the actual jina
|
|
121
|
+
* GGUF's metadata from this worktree (no model files here), so this reserves
|
|
122
|
+
* well above the no-template case. If the real template needs more than
|
|
123
|
+
* this, `rankAll()` still throws its own clean, caught error (existing
|
|
124
|
+
* fail-open path) rather than corrupting anything. */
|
|
125
|
+
const RANK_TEMPLATE_OVERHEAD_TOKENS = 64;
|
|
126
|
+
/** Queries are normally a handful of words. Bounding them caps the worst
|
|
127
|
+
* case so a pathologically long query can never eat the whole doc budget or
|
|
128
|
+
* overflow the context on its own. */
|
|
129
|
+
const MAX_QUERY_TOKENS = 128;
|
|
130
|
+
/** Per-mode doc token budgets: context size minus template overhead minus
|
|
131
|
+
* the reserved query budget. This is the number `truncateForModel()` enforces
|
|
132
|
+
* exactly (via the model's own tokenizer) for candidate document text. */
|
|
133
|
+
const GENERATIVE_DOC_BUDGET_TOKENS = GENERATIVE_CONTEXT_SIZE - GENERATIVE_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
|
|
134
|
+
const RANK_DOC_BUDGET_TOKENS = RANK_CONTEXT_SIZE - RANK_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
|
|
135
|
+
// Cheap CHAR pre-cuts — layer 1 (see file header). Deliberately generous
|
|
136
|
+
// (not a tight token-accurate estimate): their only job is keeping us from
|
|
137
|
+
// ever tokenizing a pathological multi-MB memory blob before layer 2
|
|
138
|
+
// (`truncateForModel`'s exact token-level cut) does the real enforcement.
|
|
139
|
+
const GENERATIVE_DOC_CHAR_PRECUT = 2000;
|
|
140
|
+
const RANK_DOC_CHAR_PRECUT = 5000;
|
|
141
|
+
const QUERY_CHAR_PRECUT = 800;
|
|
142
|
+
/** Pure, trivial char-length cap — layer 1 of truncation. Returns `text`
|
|
143
|
+
* UNCHANGED (same reference) when already within budget, so callers can
|
|
144
|
+
* skip unnecessary work; otherwise returns the first `maxChars` characters.
|
|
145
|
+
* Exported for unit testing (see test/unit/rerank-provider.test.ts). */
|
|
146
|
+
export function truncateChars(text, maxChars) {
|
|
147
|
+
if (text.length <= maxChars)
|
|
148
|
+
return text;
|
|
149
|
+
return text.slice(0, Math.max(0, maxChars));
|
|
150
|
+
}
|
|
151
|
+
/** Pure, trivial token-array cap — layer 2's core slice. Exported for unit
|
|
152
|
+
* testing independent of a real tokenizer. */
|
|
153
|
+
export function truncateTokenBudget(tokens, maxTokens) {
|
|
154
|
+
if (tokens.length <= maxTokens)
|
|
155
|
+
return tokens;
|
|
156
|
+
return tokens.slice(0, Math.max(0, maxTokens));
|
|
157
|
+
}
|
|
53
158
|
let _state = "uninitialized";
|
|
54
159
|
let _initError;
|
|
55
160
|
let _warnedOnce = false;
|
|
@@ -76,6 +181,31 @@ function resolveModelKey() {
|
|
|
76
181
|
return requested;
|
|
77
182
|
return DEFAULT_MODEL;
|
|
78
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Decide whether `ensureInit()` needs to (re)run the engine init sequence —
|
|
186
|
+
* the flair#811 point-3 fix. Pure so the decision matrix is directly
|
|
187
|
+
* unit-testable without touching the native engine.
|
|
188
|
+
*
|
|
189
|
+
* - Never initialized → always init.
|
|
190
|
+
* - Currently loaded model differs from what's now configured → always
|
|
191
|
+
* (re)init, regardless of whether the PREVIOUS attempt was "ready" or
|
|
192
|
+
* "failed" — a config change deserves a fresh attempt under the new
|
|
193
|
+
* config. This is the fix: the old code short-circuited on `_state ===
|
|
194
|
+
* "ready"`/`"failed"` unconditionally, so a later FLAIR_RERANK_MODEL
|
|
195
|
+
* change (or a transient first-call failure under a config that was since
|
|
196
|
+
* corrected) had NO effect for the life of the process.
|
|
197
|
+
* - Same model, already "ready" → no-op (don't reload a loaded GGUF).
|
|
198
|
+
* - Same model, already "failed" → no-op (don't retry-storm a config that's
|
|
199
|
+
* still broken; avoids hammering a persistently-unavailable engine on
|
|
200
|
+
* every search).
|
|
201
|
+
*/
|
|
202
|
+
export function needsReinit(state, cachedModelKey, requestedModelKey) {
|
|
203
|
+
if (state === "uninitialized")
|
|
204
|
+
return true;
|
|
205
|
+
if (cachedModelKey !== requestedModelKey)
|
|
206
|
+
return true;
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
79
209
|
/** Discover the platform addon binary the same way embeddings-provider.ts does. */
|
|
80
210
|
async function findAddonPath() {
|
|
81
211
|
const { existsSync } = await import("node:fs");
|
|
@@ -88,12 +218,19 @@ async function findAddonPath() {
|
|
|
88
218
|
return undefined;
|
|
89
219
|
}
|
|
90
220
|
async function ensureInit() {
|
|
91
|
-
|
|
221
|
+
const requested = resolveModelKey();
|
|
222
|
+
if (!needsReinit(_state, _modelKey, requested))
|
|
92
223
|
return;
|
|
93
|
-
|
|
94
|
-
|
|
224
|
+
// Reinitializing under a new config (or first-ever init) — drop any
|
|
225
|
+
// previously loaded engine handles and let a fresh init attempt warn again
|
|
226
|
+
// if IT fails too (a new config's failure is a new fact, not a repeat of
|
|
227
|
+
// the old one).
|
|
228
|
+
if (_state === "ready")
|
|
229
|
+
await disposeHandles().catch(() => { });
|
|
230
|
+
_state = "uninitialized";
|
|
231
|
+
_warnedOnce = false;
|
|
95
232
|
try {
|
|
96
|
-
_modelKey =
|
|
233
|
+
_modelKey = requested;
|
|
97
234
|
const spec = MODELS[_modelKey];
|
|
98
235
|
_mode = spec.mode;
|
|
99
236
|
const { existsSync } = await import("node:fs");
|
|
@@ -116,7 +253,7 @@ async function ensureInit() {
|
|
|
116
253
|
_llama = await _nlc.getLlama();
|
|
117
254
|
_model = await _llama.loadModel({ modelPath });
|
|
118
255
|
if (_mode === "generative") {
|
|
119
|
-
_ctx = await _model.createContext({ contextSize:
|
|
256
|
+
_ctx = await _model.createContext({ contextSize: GENERATIVE_CONTEXT_SIZE });
|
|
120
257
|
_seq = _ctx.getSequence();
|
|
121
258
|
// Resolve yes/no token ids (both case variants — the post-</think>
|
|
122
259
|
// position puts mass on "yes"/"Yes").
|
|
@@ -131,9 +268,11 @@ async function ensureInit() {
|
|
|
131
268
|
if (!_model.fileInsights?.supportsRanking) {
|
|
132
269
|
throw new Error(`model ${spec.file} does not support ranking (not a rank-pooling cross-encoder)`);
|
|
133
270
|
}
|
|
134
|
-
// Context must fit query + the longest document (jina concatenates
|
|
135
|
-
// 512 is too small for real memories; 2048
|
|
136
|
-
|
|
271
|
+
// Context must fit query + the longest document (jina concatenates
|
|
272
|
+
// them). 512 is too small for real memories; RANK_CONTEXT_SIZE (2048)
|
|
273
|
+
// is what RANK_DOC_BUDGET_TOKENS/truncateForModel() are derived from —
|
|
274
|
+
// see the file header.
|
|
275
|
+
_rankCtx = await _model.createRankingContext({ contextSize: RANK_CONTEXT_SIZE });
|
|
137
276
|
}
|
|
138
277
|
_state = "ready";
|
|
139
278
|
}
|
|
@@ -182,9 +321,51 @@ async function resetSequence() {
|
|
|
182
321
|
catch { /* ignore */ }
|
|
183
322
|
_seq = _ctx.getSequence();
|
|
184
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Bound `text` to at most `maxTokens` tokens using the loaded model's own
|
|
326
|
+
* tokenizer — layer 2 of the truncation scheme (see file header). Layer 1's
|
|
327
|
+
* cheap char pre-cut runs first (`truncateChars`, avoids tokenizing
|
|
328
|
+
* pathological multi-MB content); if the pre-cut string still tokenizes over
|
|
329
|
+
* budget (dense code/CJK/emoji content packs more tokens per char than the
|
|
330
|
+
* pre-cut assumes), the token array itself is cut and detokenized back to
|
|
331
|
+
* text. This is the actual guarantee: whatever this returns tokenizes to
|
|
332
|
+
* AT MOST `maxTokens` tokens (specialTokens=false — plain content text, no
|
|
333
|
+
* markup interpretation), full stop. Not exported/pure (needs `_model`); its
|
|
334
|
+
* two building blocks (`truncateChars`, `truncateTokenBudget`) are each unit
|
|
335
|
+
* tested directly.
|
|
336
|
+
*/
|
|
337
|
+
function truncateForModel(text, maxChars, maxTokens) {
|
|
338
|
+
const precut = truncateChars(text, maxChars);
|
|
339
|
+
const tokens = _model.tokenize(precut, false);
|
|
340
|
+
if (tokens.length <= maxTokens)
|
|
341
|
+
return precut;
|
|
342
|
+
const bounded = truncateTokenBudget(Array.from(tokens), maxTokens);
|
|
343
|
+
return _model.detokenize(bounded, false);
|
|
344
|
+
}
|
|
185
345
|
/** Generative yes/no score for one (query, doc) pair. */
|
|
186
346
|
async function scoreGenerative(q, doc) {
|
|
187
|
-
|
|
347
|
+
// Bound query + doc BEFORE building the prompt (flair#811 point 1) — see
|
|
348
|
+
// truncateForModel's doc and the file header for the two-layer rationale.
|
|
349
|
+
// Each is tokenized/bounded independently, then concatenated into the
|
|
350
|
+
// template; GENERATIVE_TEMPLATE_OVERHEAD_TOKENS' margin absorbs the small
|
|
351
|
+
// boundary-tokenization variance a separate-then-concatenate cut can
|
|
352
|
+
// introduce (BPE isn't always compositional across a splice point).
|
|
353
|
+
const boundedQuery = truncateForModel(q, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
|
|
354
|
+
const boundedDoc = truncateForModel(doc, GENERATIVE_DOC_CHAR_PRECUT, GENERATIVE_DOC_BUDGET_TOKENS);
|
|
355
|
+
const tokens = _model.tokenize(buildQwenPrompt(boundedQuery, boundedDoc), true);
|
|
356
|
+
// Belt-and-suspenders: the per-field bounding above reserves
|
|
357
|
+
// GENERATIVE_TEMPLATE_OVERHEAD_TOKENS of margin for the template, but a
|
|
358
|
+
// splice-boundary tokenization surprise is still conceivable. We can't
|
|
359
|
+
// truncate the COMBINED prompt itself (it would cut the required
|
|
360
|
+
// assistant-turn suffix the model needs to answer at the right position),
|
|
361
|
+
// so if it's still over budget here, throw cleanly and let the caller fall
|
|
362
|
+
// open — better than handing an oversized prompt to controlledEvaluate,
|
|
363
|
+
// which doesn't throw on overflow the way rankAll does and could silently
|
|
364
|
+
// context-shift/corrupt instead (a plausible contributor to the "empty
|
|
365
|
+
// logits" symptom in #811, alongside the documented dual-backend issue).
|
|
366
|
+
if (tokens.length > GENERATIVE_CONTEXT_SIZE) {
|
|
367
|
+
throw new Error(`generative reranker prompt (${tokens.length} tokens) exceeds context size (${GENERATIVE_CONTEXT_SIZE}) after truncation`);
|
|
368
|
+
}
|
|
188
369
|
// Reset the sequence so each pair is scored independently (deterministic) and
|
|
189
370
|
// a prior eval can't poison this one ("Eval has failed" after a bad state).
|
|
190
371
|
await resetSequence();
|
|
@@ -228,8 +409,15 @@ export async function rerankScores(query, docs) {
|
|
|
228
409
|
if (_state !== "ready")
|
|
229
410
|
throw new Error("reranker not ready");
|
|
230
411
|
if (_mode === "rank") {
|
|
231
|
-
|
|
232
|
-
|
|
412
|
+
// Bound query + every doc BEFORE rankAll (flair#811 point 1) — rankAll
|
|
413
|
+
// computes ALL documents' token lengths up front and throws "The input
|
|
414
|
+
// lengths of some of the given documents exceed the context size" if
|
|
415
|
+
// ANY one exceeds RANK_CONTEXT_SIZE (verified against node-llama-cpp
|
|
416
|
+
// 3.18.1's LlamaRankingContext.rankAll). truncateForModel makes that
|
|
417
|
+
// effectively unreachable instead of routine on real memory content.
|
|
418
|
+
const boundedQuery = truncateForModel(query, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
|
|
419
|
+
const trimmed = docs.map((d) => truncateForModel(String(d ?? ""), RANK_DOC_CHAR_PRECUT, RANK_DOC_BUDGET_TOKENS));
|
|
420
|
+
return runExclusive(() => _rankCtx.rankAll(boundedQuery, trimmed));
|
|
233
421
|
}
|
|
234
422
|
// generative — score sequentially under the engine lock (single shared
|
|
235
423
|
// sequence; deterministic). The whole batch holds the lock for one search so
|
|
@@ -13,8 +13,8 @@ turning it on (`FLAIR_RERANK_ENABLED=true`) or running the recall-bench A/B.
|
|
|
13
13
|
|
|
14
14
|
| `FLAIR_RERANK_MODEL` | GGUF filename (under `models/`) | Source | Inference mode |
|
|
15
15
|
|---|---|---|---|
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
16
|
+
| `jina-reranker-v2` (**default**, working) | `jina-reranker-v2-base.Q8_0.gguf` | `gpustack/jina-reranker-v2-base-multilingual-GGUF` (q8_0) | rank-pooling cross-encoder |
|
|
17
|
+
| `qwen3-reranker-0.6b-q8` (**experimental** — see Known limitation) | `Qwen3-Reranker-0.6B-q8_0.gguf` | `Mungert/Qwen3-Reranker-0.6B-GGUF` (q8_0) | generative yes/no |
|
|
18
18
|
|
|
19
19
|
Download into `models/` next to the embedding GGUF, e.g.:
|
|
20
20
|
|
|
@@ -36,11 +36,17 @@ vector order — it never blocks or breaks search.
|
|
|
36
36
|
| Env var | Default | Meaning |
|
|
37
37
|
|---|---|---|
|
|
38
38
|
| `FLAIR_RERANK_ENABLED` | unset (**OFF**) | Master flag. `"true"` to enable. |
|
|
39
|
-
| `FLAIR_RERANK_MODEL` | `
|
|
39
|
+
| `FLAIR_RERANK_MODEL` | `jina-reranker-v2` | Model + inference mode (table above). Re-read on every rerank call — changing it takes effect on the NEXT call (that call pays a one-time model-(re)load cost and can itself fall back to vector order if the load exceeds `FLAIR_RERANK_BUDGET_MS`; subsequent calls run at normal latency). |
|
|
40
40
|
| `FLAIR_RERANK_TOPN` | `50` | Candidate count fed to the reranker; caps the HNSW fetch. |
|
|
41
41
|
| `FLAIR_RERANK_BUDGET_MS` | `2500` | Hard latency budget; exceeded → vector order. |
|
|
42
42
|
| `FLAIR_RERANK_MIN_CANDIDATES` | `2` | Skip rerank below this many candidates. |
|
|
43
43
|
|
|
44
|
+
Candidate document (and query) text is truncated to a per-model context budget
|
|
45
|
+
before it ever reaches the engine — see `resources/rerank-provider.ts`'s file
|
|
46
|
+
header ("Context-budget truncation"). This is not operator-configurable
|
|
47
|
+
(the budgets are derived from each mode's fixed context size); flagged here
|
|
48
|
+
only so a truncated rerank input isn't a surprise.
|
|
49
|
+
|
|
44
50
|
## Why this serving path (not Ollama, not a microservice)
|
|
45
51
|
|
|
46
52
|
- **In-process node-llama-cpp** is the same engine the embedding engine already
|
|
@@ -62,6 +68,34 @@ detects this (`out.length === 0`), throws, and **fails open to vector order** (i
|
|
|
62
68
|
never writes corrupt scores). Net effect today: with `FLAIR_RERANK_MODEL=qwen3-...`
|
|
63
69
|
the rerank stage cleanly no-ops inside Harper.
|
|
64
70
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
flair#811 (the live-corpus Phase-1 gate) found the qwen3 path erroring on every
|
|
72
|
+
call in production and root-caused two compounding issues, fixed in that PR:
|
|
73
|
+
|
|
74
|
+
1. **Context overflow on real documents.** The offline pilot's 16-doc fixture was
|
|
75
|
+
short synthetic prose; real memory content routinely exceeds either model's
|
|
76
|
+
small context window (1024 tokens for the generative context, 2048 for the
|
|
77
|
+
rank context). `resources/rerank-provider.ts` now truncates every (query, doc)
|
|
78
|
+
pair to a budget derived from each mode's actual context size before it ever
|
|
79
|
+
reaches the engine (see that file's header). This doesn't fix the dual-backend
|
|
80
|
+
empty-logits limitation above, but it removes overflow as a SEPARATE, more
|
|
81
|
+
easily hit failure mode — and may have been a contributing cause of the
|
|
82
|
+
empty-logits symptom itself (an overflowing `controlledEvaluate` call doesn't
|
|
83
|
+
throw the way `rankAll` does; it's plausible an oversized prompt silently
|
|
84
|
+
context-shifted rather than decoding cleanly, though we couldn't confirm
|
|
85
|
+
this without a live repro).
|
|
86
|
+
2. **Config not re-read after first init.** The provider used to cache which
|
|
87
|
+
model was loaded PERMANENTLY on first successful (or failed) init — a later
|
|
88
|
+
change to `FLAIR_RERANK_MODEL` had no effect until the process restarted, so
|
|
89
|
+
"configured model X, served model Y" could persist silently for the life of
|
|
90
|
+
the process. Now re-validated on every call (`needsReinit()`); a config
|
|
91
|
+
change is picked up on the next rerank.
|
|
92
|
+
|
|
93
|
+
**Given the above, `jina-reranker-v2` is now the DEFAULT model** — its rank-API
|
|
94
|
+
path (`createRankingContext()` / `rankAll`) completes inside Harper. `qwen3` stays
|
|
95
|
+
selectable and documented for whoever revisits the dual-backend isolation
|
|
96
|
+
question; it is not the default until the empty-logits limitation is actually
|
|
97
|
+
fixed (truncation alone doesn't fix it — see point 1 above, it only removes a
|
|
98
|
+
compounding cause).
|
|
99
|
+
|
|
100
|
+
See the integration PR / flair#811 for the live recall-bench A/B numbers and the
|
|
101
|
+
go/no-go read on `jina-reranker-v2` as the default.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|
|
@@ -76,6 +76,9 @@
|
|
|
76
76
|
"@node-llama-cpp/win-arm64": "3.18.1",
|
|
77
77
|
"@node-llama-cpp/win-x64": "3.18.1"
|
|
78
78
|
},
|
|
79
|
+
"overrides": {
|
|
80
|
+
"react-native-fs": "npm:empty-npm-package@1.0.0"
|
|
81
|
+
},
|
|
79
82
|
"devDependencies": {
|
|
80
83
|
"@playwright/test": "1.59.1",
|
|
81
84
|
"@types/node": "24.11.0",
|
package/schemas/memory.graphql
CHANGED
|
@@ -4,7 +4,25 @@ type Memory @table(database: "flair") {
|
|
|
4
4
|
content: String!
|
|
5
5
|
contentHash: String @indexed
|
|
6
6
|
visibility: String
|
|
7
|
-
embedding: [Float] @indexed(type: "HNSW")
|
|
7
|
+
embedding: [Float] @indexed(type: "HNSW", M: 16) # M:16 is Harper's own HNSW default
|
|
8
|
+
# (HierarchicalNavigableSmallWorld M default) — declaring it explicitly is a
|
|
9
|
+
# ZERO behavior / graph-quality change. Its ONLY effect is the recall-heal
|
|
10
|
+
# trigger (resources/migrations/graph-heal.ts): Harper persists each HNSW
|
|
11
|
+
# attribute's option descriptor and, on boot, structurally diffs it against
|
|
12
|
+
# the schema (databases.ts canonicalizeIndexOptions — sorts keys, coerces
|
|
13
|
+
# numeric strings, but does NOT inject defaults, so `{type:"HNSW"}` and
|
|
14
|
+
# `{type:"HNSW",M:16}` are DIFFERENT canonical keys). A store whose descriptor
|
|
15
|
+
# was persisted WITHOUT options (every pre-this-change install) therefore sees
|
|
16
|
+
# a structural diff on the first boot after upgrade → runIndexing clears the
|
|
17
|
+
# graph store and rebuilds it CLEANLY from the already-correct stored vectors
|
|
18
|
+
# (lastIndexedKey reset to undefined). This heals graphs left stale by Harper's
|
|
19
|
+
# incremental HNSW update after a bulk in-place re-embed (the July embedding-
|
|
20
|
+
# stamp re-embed corrupted prod's reverse edges; a fresh index of the SAME
|
|
21
|
+
# vectors finds the true neighbors, the incrementally-updated one didn't).
|
|
22
|
+
# NO re-embed — the stored vectors are untouched; only the graph is rebuilt.
|
|
23
|
+
# Fires once per instance (the descriptor is then persisted to match); brief
|
|
24
|
+
# vector-search 503 window during the rebuild (~seconds; BM25/direct keep
|
|
25
|
+
# serving). Do NOT remove the `M: 16` — reverting it removes the upgrade heal.
|
|
8
26
|
embeddingModel: String @indexed # ops-1l18: previously written (resources/Memory.ts stamps every
|
|
9
27
|
# write) but never DECLARED in this schema, so it was storable but not
|
|
10
28
|
# queryable — Harper's Table.search({conditions}) rejects a condition on
|