@tpsdev-ai/flair 0.30.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -377
- package/dist/cli.js +1434 -288
- package/dist/deploy.js +212 -24
- package/dist/fabric-upgrade.js +16 -1
- package/dist/federation/scheduler.js +500 -0
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +35 -87
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryBootstrap.js +7 -8
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/SemanticSearch.js +17 -45
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/embeddings-boot.js +10 -12
- package/dist/resources/embeddings-provider.js +10 -7
- package/dist/resources/health.js +24 -19
- package/dist/resources/in-process.js +234 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/migration-boot.js +80 -10
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/runner.js +29 -2
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/dist/resources/semantic-retrieval-core.js +5 -4
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +35 -87
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +5 -0
- package/docs/embedding-in-a-harper-app.md +303 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +36 -0
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +99 -4
- package/package.json +1 -11
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
|
@@ -1,569 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* rerank-provider.ts
|
|
3
|
-
*
|
|
4
|
-
* In-process cross-encoder reranker for Flair's recall path. Mirrors the
|
|
5
|
-
* embeddings-provider.ts shape: dynamic import of node-llama-cpp (deferred to
|
|
6
|
-
* first use to dodge Harper 5.x's VM-sandbox linker race), lazy singleton init,
|
|
7
|
-
* and graceful passthrough on ANY failure — the reranker is best-effort and must
|
|
8
|
-
* NEVER block or break recall.
|
|
9
|
-
*
|
|
10
|
-
* Two inference modes (selected by FLAIR_RERANK_MODEL):
|
|
11
|
-
*
|
|
12
|
-
* 1. "jina-reranker-v2" (DEFAULT, working) — jina-reranker-v2 IS a rank-pooling
|
|
13
|
-
* cross-encoder; its gpustack GGUF loads via createRankingContext()/rankAll
|
|
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.
|
|
68
|
-
*
|
|
69
|
-
* Serving path = the same in-process node-llama-cpp the embedding engine ships.
|
|
70
|
-
* No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
|
|
71
|
-
* new auth boundary.
|
|
72
|
-
*
|
|
73
|
-
* GGUF files are NOT committed; they are provisioned into the models dir
|
|
74
|
-
* alongside the embedding GGUF — resolved via the SAME `resolveModelsDir()`
|
|
75
|
-
* the embedding engine uses (FLAIR_MODELS_DIR → <ROOTPATH>/models →
|
|
76
|
-
* <cwd>/models → ~/.flair/data/models; flair#815 — this file used to hardcode
|
|
77
|
-
* <cwd>/models, so any deployment whose cwd wasn't the models location failed
|
|
78
|
-
* init and silently fell open to vector order). See docs/rerank-provisioning.md
|
|
79
|
-
* for download sources.
|
|
80
|
-
*/
|
|
81
|
-
import { join } from "node:path";
|
|
82
|
-
import { resolveModelsDir } from "./models-dir.js";
|
|
83
|
-
// Known reranker models → GGUF filename + inference mode. jina is the DEFAULT
|
|
84
|
-
// (working — see file header, flair#811): its rank-pooling path completes
|
|
85
|
-
// inside Harper. qwen3 is EXPERIMENTAL: its generative path is validated
|
|
86
|
-
// offline but reliably fails open inside Harper today (empty logits).
|
|
87
|
-
const MODELS = {
|
|
88
|
-
"jina-reranker-v2": { file: "jina-reranker-v2-base.Q8_0.gguf", mode: "rank" },
|
|
89
|
-
"qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
|
|
90
|
-
};
|
|
91
|
-
const DEFAULT_MODEL = "jina-reranker-v2";
|
|
92
|
-
// Official Qwen3-Reranker prompt scaffold (generative yes/no judgement).
|
|
93
|
-
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';
|
|
94
|
-
const QWEN_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
|
|
95
|
-
const QWEN_INSTRUCT = "Given a web search query, retrieve relevant passages that answer the query";
|
|
96
|
-
function buildQwenPrompt(q, doc) {
|
|
97
|
-
return `${QWEN_PREFIX}<Instruct>: ${QWEN_INSTRUCT}\n<Query>: ${q}\n<Document>: ${doc}${QWEN_SUFFIX}`;
|
|
98
|
-
}
|
|
99
|
-
// ── Context-budget truncation (flair#811 point 1) ──────────────────────────
|
|
100
|
-
// See the file header for the two-layer rationale. Budgets are derived from
|
|
101
|
-
// the context size each mode actually requests, minus reserved overhead —
|
|
102
|
-
// not a flat guess detached from either number.
|
|
103
|
-
/** Context size requested for the generative (Qwen3) context. NUMERIC (not
|
|
104
|
-
* "auto"), so node-llama-cpp grants exactly this many tokens — verified
|
|
105
|
-
* against node-llama-cpp 3.18.1's resolveContextContextSizeOption: a numeric
|
|
106
|
-
* contextSize is granted as-is (or context creation throws
|
|
107
|
-
* InsufficientMemoryError); only "auto"/object requests get VRAM-adaptive
|
|
108
|
-
* shrinking. */
|
|
109
|
-
const GENERATIVE_CONTEXT_SIZE = 1024;
|
|
110
|
-
/** Context size requested for the rank (jina) context — see
|
|
111
|
-
* createRankingContext() below. Also numeric, same guarantee. */
|
|
112
|
-
const RANK_CONTEXT_SIZE = 2048;
|
|
113
|
-
/** Reserved tokens for the Qwen3 prompt scaffold (QWEN_PREFIX + INSTRUCT +
|
|
114
|
-
* SUFFIX + <|im_start|>/<|im_end|> special tokens). Rough estimate from the
|
|
115
|
-
* literal scaffold text is ~130-140 tokens; 180 leaves real headroom above
|
|
116
|
-
* that estimate rather than sitting right on top of it — we don't have the
|
|
117
|
-
* actual Qwen3-Reranker tokenizer available to measure exactly (no GGUF in
|
|
118
|
-
* this worktree), so this errs generous. Belt-and-suspenders: scoreGenerative
|
|
119
|
-
* also hard-checks the FINAL built prompt against GENERATIVE_CONTEXT_SIZE
|
|
120
|
-
* and throws (fail-open) rather than proceeding if this margin ever isn't
|
|
121
|
-
* enough — see there. */
|
|
122
|
-
const GENERATIVE_TEMPLATE_OVERHEAD_TOKENS = 180;
|
|
123
|
-
/** Rank mode's DEFAULT template (no GGUF `chat_template.rerank` metadata) is
|
|
124
|
-
* just BOS + EOS + SEP + EOS — ~4 tokens. But if the loaded GGUF DOES carry
|
|
125
|
-
* a custom rerank template (LlamaRankingContext prefers it when present),
|
|
126
|
-
* that template's literal text adds more; we can't inspect the actual jina
|
|
127
|
-
* GGUF's metadata from this worktree (no model files here), so this reserves
|
|
128
|
-
* well above the no-template case. If the real template needs more than
|
|
129
|
-
* this, `rankAll()` still throws its own clean, caught error (existing
|
|
130
|
-
* fail-open path) rather than corrupting anything. */
|
|
131
|
-
const RANK_TEMPLATE_OVERHEAD_TOKENS = 64;
|
|
132
|
-
/** Queries are normally a handful of words. Bounding them caps the worst
|
|
133
|
-
* case so a pathologically long query can never eat the whole doc budget or
|
|
134
|
-
* overflow the context on its own. */
|
|
135
|
-
const MAX_QUERY_TOKENS = 128;
|
|
136
|
-
/** Per-mode doc token budgets: context size minus template overhead minus
|
|
137
|
-
* the reserved query budget. This is the number `truncateForModel()` enforces
|
|
138
|
-
* exactly (via the model's own tokenizer) for candidate document text. */
|
|
139
|
-
const GENERATIVE_DOC_BUDGET_TOKENS = GENERATIVE_CONTEXT_SIZE - GENERATIVE_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
|
|
140
|
-
const RANK_DOC_BUDGET_TOKENS = RANK_CONTEXT_SIZE - RANK_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
|
|
141
|
-
// Cheap CHAR pre-cuts — layer 1 (see file header). Deliberately generous
|
|
142
|
-
// (not a tight token-accurate estimate): their only job is keeping us from
|
|
143
|
-
// ever tokenizing a pathological multi-MB memory blob before layer 2
|
|
144
|
-
// (`truncateForModel`'s exact token-level cut) does the real enforcement.
|
|
145
|
-
const GENERATIVE_DOC_CHAR_PRECUT = 2000;
|
|
146
|
-
const RANK_DOC_CHAR_PRECUT = 5000;
|
|
147
|
-
const QUERY_CHAR_PRECUT = 800;
|
|
148
|
-
/** Pure, trivial char-length cap — layer 1 of truncation. Returns `text`
|
|
149
|
-
* UNCHANGED (same reference) when already within budget, so callers can
|
|
150
|
-
* skip unnecessary work; otherwise returns the first `maxChars` characters.
|
|
151
|
-
* Exported for unit testing (see test/unit/rerank-provider.test.ts). */
|
|
152
|
-
export function truncateChars(text, maxChars) {
|
|
153
|
-
if (text.length <= maxChars)
|
|
154
|
-
return text;
|
|
155
|
-
return text.slice(0, Math.max(0, maxChars));
|
|
156
|
-
}
|
|
157
|
-
/** Pure, trivial token-array cap — layer 2's core slice. Exported for unit
|
|
158
|
-
* testing independent of a real tokenizer. */
|
|
159
|
-
export function truncateTokenBudget(tokens, maxTokens) {
|
|
160
|
-
if (tokens.length <= maxTokens)
|
|
161
|
-
return tokens;
|
|
162
|
-
return tokens.slice(0, Math.max(0, maxTokens));
|
|
163
|
-
}
|
|
164
|
-
let _state = "uninitialized";
|
|
165
|
-
let _initError;
|
|
166
|
-
let _warnedOnce = false;
|
|
167
|
-
let _modelKey = "";
|
|
168
|
-
let _mode;
|
|
169
|
-
// node-llama-cpp handles (kept alive as a singleton like the embedding engine).
|
|
170
|
-
let _nlc = null;
|
|
171
|
-
let _llama = null;
|
|
172
|
-
let _model = null;
|
|
173
|
-
// generative mode handles
|
|
174
|
-
let _ctx = null;
|
|
175
|
-
let _seq = null;
|
|
176
|
-
let _yesToks = [];
|
|
177
|
-
let _noToks = [];
|
|
178
|
-
// rank mode handle
|
|
179
|
-
let _rankCtx = null;
|
|
180
|
-
// Diagnostics surfaced via getRerankStatus() / health.ts.
|
|
181
|
-
let _lastLatencyMs = null;
|
|
182
|
-
let _fallbackCount = 0;
|
|
183
|
-
let _rerankCount = 0;
|
|
184
|
-
function resolveModelKey() {
|
|
185
|
-
const requested = process.env.FLAIR_RERANK_MODEL?.trim();
|
|
186
|
-
if (requested && MODELS[requested])
|
|
187
|
-
return requested;
|
|
188
|
-
return DEFAULT_MODEL;
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Absolute path the reranker GGUF is expected at: `<models dir>/<file>`,
|
|
192
|
-
* where the models dir is the shared `resolveModelsDir()` (models-dir.ts,
|
|
193
|
-
* same resolution the embedding engine uses) — the single documented source
|
|
194
|
-
* of truth (FLAIR_MODELS_DIR override, ROOTPATH/models, cwd/models backward
|
|
195
|
-
* compat, ~/.flair/data/models default).
|
|
196
|
-
* flair#815: this used to be a hardcoded `<cwd>/models/<file>`, which broke
|
|
197
|
-
* (fail-open, silently) in any deployment where Harper's cwd wasn't the
|
|
198
|
-
* models location — including the recall harness's ephemeral Harpers.
|
|
199
|
-
* Exported for unit testing (see test/unit/rerank-provider.test.ts).
|
|
200
|
-
*/
|
|
201
|
-
export function resolveRerankModelPath(file) {
|
|
202
|
-
return join(resolveModelsDir(), file);
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Decide whether `ensureInit()` needs to (re)run the engine init sequence —
|
|
206
|
-
* the flair#811 point-3 fix. Pure so the decision matrix is directly
|
|
207
|
-
* unit-testable without touching the native engine.
|
|
208
|
-
*
|
|
209
|
-
* - Never initialized → always init.
|
|
210
|
-
* - Currently loaded model differs from what's now configured → always
|
|
211
|
-
* (re)init, regardless of whether the PREVIOUS attempt was "ready" or
|
|
212
|
-
* "failed" — a config change deserves a fresh attempt under the new
|
|
213
|
-
* config. This is the fix: the old code short-circuited on `_state ===
|
|
214
|
-
* "ready"`/`"failed"` unconditionally, so a later FLAIR_RERANK_MODEL
|
|
215
|
-
* change (or a transient first-call failure under a config that was since
|
|
216
|
-
* corrected) had NO effect for the life of the process.
|
|
217
|
-
* - Same model, already "ready" → no-op (don't reload a loaded GGUF).
|
|
218
|
-
* - Same model, already "failed" → no-op (don't retry-storm a config that's
|
|
219
|
-
* still broken; avoids hammering a persistently-unavailable engine on
|
|
220
|
-
* every search).
|
|
221
|
-
*/
|
|
222
|
-
export function needsReinit(state, cachedModelKey, requestedModelKey) {
|
|
223
|
-
if (state === "uninitialized")
|
|
224
|
-
return true;
|
|
225
|
-
if (cachedModelKey !== requestedModelKey)
|
|
226
|
-
return true;
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
/** Discover the platform addon binary the same way embeddings-provider.ts does. */
|
|
230
|
-
async function findAddonPath() {
|
|
231
|
-
const { existsSync } = await import("node:fs");
|
|
232
|
-
const platforms = ["linux-x64", "mac-arm64-metal", "mac-arm64", "win-x64", "linux-arm64", "mac-x64"];
|
|
233
|
-
for (const platform of platforms) {
|
|
234
|
-
const candidate = join(process.cwd(), "node_modules", "@node-llama-cpp", platform, "bins", platform, "llama-addon.node");
|
|
235
|
-
if (existsSync(candidate))
|
|
236
|
-
return candidate;
|
|
237
|
-
}
|
|
238
|
-
return undefined;
|
|
239
|
-
}
|
|
240
|
-
async function ensureInit() {
|
|
241
|
-
const requested = resolveModelKey();
|
|
242
|
-
if (!needsReinit(_state, _modelKey, requested))
|
|
243
|
-
return;
|
|
244
|
-
// Reinitializing under a new config (or first-ever init) — drop any
|
|
245
|
-
// previously loaded engine handles and let a fresh init attempt warn again
|
|
246
|
-
// if IT fails too (a new config's failure is a new fact, not a repeat of
|
|
247
|
-
// the old one).
|
|
248
|
-
if (_state === "ready")
|
|
249
|
-
await disposeHandles().catch(() => { });
|
|
250
|
-
_state = "uninitialized";
|
|
251
|
-
_warnedOnce = false;
|
|
252
|
-
try {
|
|
253
|
-
_modelKey = requested;
|
|
254
|
-
const spec = MODELS[_modelKey];
|
|
255
|
-
_mode = spec.mode;
|
|
256
|
-
const { existsSync } = await import("node:fs");
|
|
257
|
-
const modelPath = resolveRerankModelPath(spec.file);
|
|
258
|
-
if (!existsSync(modelPath)) {
|
|
259
|
-
throw new Error(`reranker GGUF not found: ${modelPath} (provision per docs/rerank-provisioning.md; set FLAIR_MODELS_DIR to override the models dir)`);
|
|
260
|
-
}
|
|
261
|
-
// Dynamic import — deferred to avoid Harper 5.x VM linker race (same reason
|
|
262
|
-
// embeddings-provider.ts defers harper-fabric-embeddings).
|
|
263
|
-
if (!_nlc)
|
|
264
|
-
_nlc = await import("node-llama-cpp");
|
|
265
|
-
// node-llama-cpp finds its prebuilt binary from process.cwd()/node_modules
|
|
266
|
-
// when run inside the Flair app dir (Harper's runtime cwd). We don't need to
|
|
267
|
-
// pass the addon path explicitly to getLlama() — but we surface it for
|
|
268
|
-
// diagnostics and as a guard that a binary exists at all.
|
|
269
|
-
const addonPath = await findAddonPath();
|
|
270
|
-
if (!addonPath) {
|
|
271
|
-
throw new Error("no @node-llama-cpp platform addon found under node_modules");
|
|
272
|
-
}
|
|
273
|
-
_llama = await _nlc.getLlama();
|
|
274
|
-
_model = await _llama.loadModel({ modelPath });
|
|
275
|
-
if (_mode === "generative") {
|
|
276
|
-
_ctx = await _model.createContext({ contextSize: GENERATIVE_CONTEXT_SIZE });
|
|
277
|
-
_seq = _ctx.getSequence();
|
|
278
|
-
// Resolve yes/no token ids (both case variants — the post-</think>
|
|
279
|
-
// position puts mass on "yes"/"Yes").
|
|
280
|
-
_yesToks = [_model.tokenize("yes", false)[0], _model.tokenize("Yes", false)[0]].filter((t) => t != null);
|
|
281
|
-
_noToks = [_model.tokenize("no", false)[0], _model.tokenize("No", false)[0]].filter((t) => t != null);
|
|
282
|
-
if (_yesToks.length === 0 || _noToks.length === 0) {
|
|
283
|
-
throw new Error("failed to resolve yes/no token ids for generative reranker");
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
else {
|
|
287
|
-
// rank-pooling cross-encoder (jina). Reject if the GGUF isn't a ranking model.
|
|
288
|
-
if (!_model.fileInsights?.supportsRanking) {
|
|
289
|
-
throw new Error(`model ${spec.file} does not support ranking (not a rank-pooling cross-encoder)`);
|
|
290
|
-
}
|
|
291
|
-
// Context must fit query + the longest document (jina concatenates
|
|
292
|
-
// them). 512 is too small for real memories; RANK_CONTEXT_SIZE (2048)
|
|
293
|
-
// is what RANK_DOC_BUDGET_TOKENS/truncateForModel() are derived from —
|
|
294
|
-
// see the file header.
|
|
295
|
-
_rankCtx = await _model.createRankingContext({ contextSize: RANK_CONTEXT_SIZE });
|
|
296
|
-
}
|
|
297
|
-
_state = "ready";
|
|
298
|
-
}
|
|
299
|
-
catch (err) {
|
|
300
|
-
_state = "failed";
|
|
301
|
-
_initError = err?.message || String(err);
|
|
302
|
-
if (!_warnedOnce) {
|
|
303
|
-
console.warn(`[rerank] WARN: reranker unavailable, recall falls back to vector order. Error: ${_initError}`);
|
|
304
|
-
_warnedOnce = true;
|
|
305
|
-
}
|
|
306
|
-
// Best-effort cleanup of any partial handles.
|
|
307
|
-
await disposeHandles().catch(() => { });
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
async function disposeHandles() {
|
|
311
|
-
try {
|
|
312
|
-
if (_rankCtx) {
|
|
313
|
-
await _rankCtx.dispose();
|
|
314
|
-
_rankCtx = null;
|
|
315
|
-
}
|
|
316
|
-
if (_ctx) {
|
|
317
|
-
await _ctx.dispose();
|
|
318
|
-
_ctx = null;
|
|
319
|
-
_seq = null;
|
|
320
|
-
}
|
|
321
|
-
if (_model) {
|
|
322
|
-
await _model.dispose();
|
|
323
|
-
_model = null;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
catch { /* ignore */ }
|
|
327
|
-
}
|
|
328
|
-
/** Reset (or recreate) the shared generative sequence to a clean KV state. */
|
|
329
|
-
async function resetSequence() {
|
|
330
|
-
try {
|
|
331
|
-
if (_seq && typeof _seq.clearHistory === "function") {
|
|
332
|
-
await _seq.clearHistory();
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
catch { /* fall through to recreate */ }
|
|
337
|
-
// clearHistory unavailable or threw — recreate the sequence from the context.
|
|
338
|
-
try {
|
|
339
|
-
_seq?.dispose?.();
|
|
340
|
-
}
|
|
341
|
-
catch { /* ignore */ }
|
|
342
|
-
_seq = _ctx.getSequence();
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* Bound `text` to at most `maxTokens` tokens using the loaded model's own
|
|
346
|
-
* tokenizer — layer 2 of the truncation scheme (see file header). Layer 1's
|
|
347
|
-
* cheap char pre-cut runs first (`truncateChars`, avoids tokenizing
|
|
348
|
-
* pathological multi-MB content); if the pre-cut string still tokenizes over
|
|
349
|
-
* budget (dense code/CJK/emoji content packs more tokens per char than the
|
|
350
|
-
* pre-cut assumes), the token array itself is cut and detokenized back to
|
|
351
|
-
* text. This is the actual guarantee: whatever this returns tokenizes to
|
|
352
|
-
* AT MOST `maxTokens` tokens (specialTokens=false — plain content text, no
|
|
353
|
-
* markup interpretation), full stop. Not exported/pure (needs `_model`); its
|
|
354
|
-
* two building blocks (`truncateChars`, `truncateTokenBudget`) are each unit
|
|
355
|
-
* tested directly.
|
|
356
|
-
*/
|
|
357
|
-
function truncateForModel(text, maxChars, maxTokens) {
|
|
358
|
-
const precut = truncateChars(text, maxChars);
|
|
359
|
-
const tokens = _model.tokenize(precut, false);
|
|
360
|
-
if (tokens.length <= maxTokens)
|
|
361
|
-
return precut;
|
|
362
|
-
const bounded = truncateTokenBudget(Array.from(tokens), maxTokens);
|
|
363
|
-
return _model.detokenize(bounded, false);
|
|
364
|
-
}
|
|
365
|
-
/** Generative yes/no score for one (query, doc) pair. */
|
|
366
|
-
async function scoreGenerative(q, doc) {
|
|
367
|
-
// Bound query + doc BEFORE building the prompt (flair#811 point 1) — see
|
|
368
|
-
// truncateForModel's doc and the file header for the two-layer rationale.
|
|
369
|
-
// Each is tokenized/bounded independently, then concatenated into the
|
|
370
|
-
// template; GENERATIVE_TEMPLATE_OVERHEAD_TOKENS' margin absorbs the small
|
|
371
|
-
// boundary-tokenization variance a separate-then-concatenate cut can
|
|
372
|
-
// introduce (BPE isn't always compositional across a splice point).
|
|
373
|
-
const boundedQuery = truncateForModel(q, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
|
|
374
|
-
const boundedDoc = truncateForModel(doc, GENERATIVE_DOC_CHAR_PRECUT, GENERATIVE_DOC_BUDGET_TOKENS);
|
|
375
|
-
const tokens = _model.tokenize(buildQwenPrompt(boundedQuery, boundedDoc), true);
|
|
376
|
-
// Belt-and-suspenders: the per-field bounding above reserves
|
|
377
|
-
// GENERATIVE_TEMPLATE_OVERHEAD_TOKENS of margin for the template, but a
|
|
378
|
-
// splice-boundary tokenization surprise is still conceivable. We can't
|
|
379
|
-
// truncate the COMBINED prompt itself (it would cut the required
|
|
380
|
-
// assistant-turn suffix the model needs to answer at the right position),
|
|
381
|
-
// so if it's still over budget here, throw cleanly and let the caller fall
|
|
382
|
-
// open — better than handing an oversized prompt to controlledEvaluate,
|
|
383
|
-
// which doesn't throw on overflow the way rankAll does and could silently
|
|
384
|
-
// context-shift/corrupt instead (a plausible contributor to the "empty
|
|
385
|
-
// logits" symptom in #811, alongside the documented dual-backend issue).
|
|
386
|
-
if (tokens.length > GENERATIVE_CONTEXT_SIZE) {
|
|
387
|
-
throw new Error(`generative reranker prompt (${tokens.length} tokens) exceeds context size (${GENERATIVE_CONTEXT_SIZE}) after truncation`);
|
|
388
|
-
}
|
|
389
|
-
// Reset the sequence so each pair is scored independently (deterministic) and
|
|
390
|
-
// a prior eval can't poison this one ("Eval has failed" after a bad state).
|
|
391
|
-
await resetSequence();
|
|
392
|
-
const input = tokens.map((t, i) => i === tokens.length - 1 ? [t, { generateNext: { probabilities: true } }] : t);
|
|
393
|
-
const out = await _seq.controlledEvaluate(input);
|
|
394
|
-
const lastDefined = [...out].reverse().find((x) => x !== undefined);
|
|
395
|
-
const probs = lastDefined?.next?.probabilities;
|
|
396
|
-
// Defensive: under some runtimes (observed in Harper's resource worker with a
|
|
397
|
-
// second native llama backend already resident — HFE's raw-addon embedding
|
|
398
|
-
// engine) controlledEvaluate returns an empty result array (no decoded
|
|
399
|
-
// logits). Treat that as "can't score" and signal the caller to fall open,
|
|
400
|
-
// rather than silently returning 0 (which would corrupt the ranking).
|
|
401
|
-
if (out.length === 0 || !probs) {
|
|
402
|
-
throw new Error("generative reranker produced no logits (empty controlledEvaluate result)");
|
|
403
|
-
}
|
|
404
|
-
const pYes = _yesToks.reduce((s, t) => s + (probs.get(t) ?? 0), 0);
|
|
405
|
-
const pNo = _noToks.reduce((s, t) => s + (probs.get(t) ?? 0), 0);
|
|
406
|
-
return pYes + pNo > 0 ? pYes / (pYes + pNo) : 0;
|
|
407
|
-
}
|
|
408
|
-
// Serialize all engine work. node-llama-cpp's context/sequence is single-use:
|
|
409
|
-
// concurrent controlledEvaluate calls on the shared sequence corrupt the KV
|
|
410
|
-
// cache ("Eval has failed"). Harper calls SemanticSearch.post() concurrently,
|
|
411
|
-
// so we funnel every rerank through one in-process queue. Each search still
|
|
412
|
-
// gets the full engine; they just don't overlap on the hardware. The latency
|
|
413
|
-
// budget in rerankCandidates bounds how long any one search waits.
|
|
414
|
-
let _engineChain = Promise.resolve();
|
|
415
|
-
function runExclusive(fn) {
|
|
416
|
-
const next = _engineChain.then(fn, fn);
|
|
417
|
-
// Keep the chain alive regardless of this task's outcome.
|
|
418
|
-
_engineChain = next.then(() => undefined, () => undefined);
|
|
419
|
-
return next;
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Score query against a batch of documents. Returns an array of scores in the
|
|
423
|
-
* same order as `docs`, in [0,1]. Higher = more relevant. Throws on engine
|
|
424
|
-
* error (caller catches and falls back to vector order). Engine access is
|
|
425
|
-
* serialized — see runExclusive.
|
|
426
|
-
*/
|
|
427
|
-
export async function rerankScores(query, docs) {
|
|
428
|
-
await ensureInit();
|
|
429
|
-
if (_state !== "ready")
|
|
430
|
-
throw new Error("reranker not ready");
|
|
431
|
-
if (_mode === "rank") {
|
|
432
|
-
// Bound query + every doc BEFORE rankAll (flair#811 point 1) — rankAll
|
|
433
|
-
// computes ALL documents' token lengths up front and throws "The input
|
|
434
|
-
// lengths of some of the given documents exceed the context size" if
|
|
435
|
-
// ANY one exceeds RANK_CONTEXT_SIZE (verified against node-llama-cpp
|
|
436
|
-
// 3.18.1's LlamaRankingContext.rankAll). truncateForModel makes that
|
|
437
|
-
// effectively unreachable instead of routine on real memory content.
|
|
438
|
-
const boundedQuery = truncateForModel(query, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
|
|
439
|
-
const trimmed = docs.map((d) => truncateForModel(String(d ?? ""), RANK_DOC_CHAR_PRECUT, RANK_DOC_BUDGET_TOKENS));
|
|
440
|
-
return runExclusive(() => _rankCtx.rankAll(boundedQuery, trimmed));
|
|
441
|
-
}
|
|
442
|
-
// generative — score sequentially under the engine lock (single shared
|
|
443
|
-
// sequence; deterministic). The whole batch holds the lock for one search so
|
|
444
|
-
// its sequence resets aren't interleaved with another search's.
|
|
445
|
-
return runExclusive(async () => {
|
|
446
|
-
const scores = [];
|
|
447
|
-
for (const d of docs) {
|
|
448
|
-
scores.push(await scoreGenerative(query, String(d ?? "")));
|
|
449
|
-
}
|
|
450
|
-
return scores;
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Rerank `candidates` (each must carry `content`) against `query`. Reorders in
|
|
455
|
-
* place by rerank score and overwrites `_score` with it (so downstream margin
|
|
456
|
-
* measurement reads the rerank score); preserves the original semantic score as
|
|
457
|
-
* `_semScore`. `_rawScore` is intentionally NOT touched (recall-bench's raw mode
|
|
458
|
-
* must stay reproducible).
|
|
459
|
-
*
|
|
460
|
-
* FAIL-OPEN: on init failure, timeout, or any throw, returns the input array
|
|
461
|
-
* UNCHANGED (caller then applies the existing vector-order sort). Never throws.
|
|
462
|
-
*/
|
|
463
|
-
export async function rerankCandidates(query, candidates, opts) {
|
|
464
|
-
const top = candidates.slice(0, Math.max(0, opts.topN));
|
|
465
|
-
if (top.length < 2)
|
|
466
|
-
return candidates;
|
|
467
|
-
const t0 = Date.now();
|
|
468
|
-
let timer;
|
|
469
|
-
try {
|
|
470
|
-
const docs = top.map((c) => String(c.content ?? ""));
|
|
471
|
-
const timeout = new Promise((resolve) => {
|
|
472
|
-
timer = setTimeout(() => resolve(null), Math.max(1, opts.budgetMs));
|
|
473
|
-
});
|
|
474
|
-
// Swallow a LATE rejection: if the budget wins the race, the scoring promise
|
|
475
|
-
// is still pending and may reject afterwards — attach a no-op catch so it
|
|
476
|
-
// doesn't surface as an unhandled rejection.
|
|
477
|
-
const scoring = rerankScores(query, docs);
|
|
478
|
-
scoring.catch(() => { });
|
|
479
|
-
const scores = await Promise.race([scoring, timeout]);
|
|
480
|
-
if (timer)
|
|
481
|
-
clearTimeout(timer);
|
|
482
|
-
if (scores === null) {
|
|
483
|
-
// Budget exceeded — abandon rerank, keep vector order. No partial reorder.
|
|
484
|
-
_fallbackCount++;
|
|
485
|
-
return candidates;
|
|
486
|
-
}
|
|
487
|
-
// Attach rerank score, preserve the semantic score for diagnostics.
|
|
488
|
-
const reranked = top.map((c, i) => {
|
|
489
|
-
const sem = c._score;
|
|
490
|
-
c._semScore = sem;
|
|
491
|
-
c._score = Math.round((scores[i] ?? 0) * 1000) / 1000;
|
|
492
|
-
return c;
|
|
493
|
-
});
|
|
494
|
-
reranked.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
|
|
495
|
-
// Candidates beyond topN (not reranked) keep their vector position AFTER the
|
|
496
|
-
// reranked block — they were already the tail of the vector-ordered pool.
|
|
497
|
-
const tail = candidates.slice(top.length);
|
|
498
|
-
_lastLatencyMs = Date.now() - t0;
|
|
499
|
-
_rerankCount++;
|
|
500
|
-
return [...reranked, ...tail];
|
|
501
|
-
}
|
|
502
|
-
catch (err) {
|
|
503
|
-
if (timer)
|
|
504
|
-
clearTimeout(timer);
|
|
505
|
-
_fallbackCount++;
|
|
506
|
-
if (!_warnedOnce) {
|
|
507
|
-
console.warn(`[rerank] WARN: rerank threw, falling back to vector order. Error: ${err?.message || err}`);
|
|
508
|
-
_warnedOnce = true;
|
|
509
|
-
}
|
|
510
|
-
return candidates;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
/** Is the master flag on? (Default OFF.) */
|
|
514
|
-
export function isRerankEnabled() {
|
|
515
|
-
return process.env.FLAIR_RERANK_ENABLED === "true";
|
|
516
|
-
}
|
|
517
|
-
/** Candidate count fed to the reranker. Caps the HNSW fetch. */
|
|
518
|
-
export function getRerankTopN() {
|
|
519
|
-
const v = Number(process.env.FLAIR_RERANK_TOPN);
|
|
520
|
-
return Number.isFinite(v) && v > 0 ? Math.floor(v) : 50;
|
|
521
|
-
}
|
|
522
|
-
/** Hard latency budget for the whole rerank stage (ms). */
|
|
523
|
-
export function getRerankBudgetMs() {
|
|
524
|
-
const v = Number(process.env.FLAIR_RERANK_BUDGET_MS);
|
|
525
|
-
return Number.isFinite(v) && v > 0 ? Math.floor(v) : 2500;
|
|
526
|
-
}
|
|
527
|
-
/** Skip rerank below this many candidates (nothing to reorder). */
|
|
528
|
-
export function getRerankMinCandidates() {
|
|
529
|
-
const v = Number(process.env.FLAIR_RERANK_MIN_CANDIDATES);
|
|
530
|
-
return Number.isFinite(v) && v >= 2 ? Math.floor(v) : 2;
|
|
531
|
-
}
|
|
532
|
-
/** Status surface for health.ts. */
|
|
533
|
-
export function getRerankStatus() {
|
|
534
|
-
return {
|
|
535
|
-
enabled: isRerankEnabled(),
|
|
536
|
-
model: _modelKey || resolveModelKey(),
|
|
537
|
-
mode: _mode ?? "uninitialized",
|
|
538
|
-
state: _state,
|
|
539
|
-
topN: getRerankTopN(),
|
|
540
|
-
budgetMs: getRerankBudgetMs(),
|
|
541
|
-
lastLatencyMs: _lastLatencyMs,
|
|
542
|
-
rerankCount: _rerankCount,
|
|
543
|
-
fallbackCount: _fallbackCount,
|
|
544
|
-
error: _initError,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
// ── Test seam ────────────────────────────────────────────────────────────────
|
|
548
|
-
// Exported pure helpers so the deterministic scoring path can be unit-tested
|
|
549
|
-
// without loading a 600MB GGUF (mirrors the pilot's deterministic approach:
|
|
550
|
-
// given fixed yes/no probabilities, the score + reorder math is exact).
|
|
551
|
-
/** P(yes)/(P(yes)+P(no)) — the generative reranker's scoring function. */
|
|
552
|
-
export function yesNoScore(pYes, pNo) {
|
|
553
|
-
return pYes + pNo > 0 ? pYes / (pYes + pNo) : 0;
|
|
554
|
-
}
|
|
555
|
-
/**
|
|
556
|
-
* Pure reorder used by rerankCandidates: attach scores, overwrite `_score`,
|
|
557
|
-
* preserve `_semScore`, leave `_rawScore` untouched, sort desc, append the
|
|
558
|
-
* non-reranked tail. Exported for deterministic unit tests.
|
|
559
|
-
*/
|
|
560
|
-
export function applyRerank(candidates, scores, topN) {
|
|
561
|
-
const top = candidates.slice(0, Math.max(0, topN));
|
|
562
|
-
const reranked = top.map((c, i) => {
|
|
563
|
-
c._semScore = c._score;
|
|
564
|
-
c._score = Math.round((scores[i] ?? 0) * 1000) / 1000;
|
|
565
|
-
return c;
|
|
566
|
-
});
|
|
567
|
-
reranked.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
|
|
568
|
-
return [...reranked, ...candidates.slice(top.length)];
|
|
569
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# Reranker model provisioning
|
|
2
|
-
|
|
3
|
-
Flair's optional cross-encoder rerank stage (`resources/rerank-provider.ts`, gated
|
|
4
|
-
behind `FLAIR_RERANK_ENABLED`) loads its model GGUF **in-process** via the same
|
|
5
|
-
node-llama-cpp engine the embedding engine ships. The GGUF is **not** committed to
|
|
6
|
-
the repo (`*.gguf` is gitignored) — it is provisioned into `models/` manually,
|
|
7
|
-
exactly like the embedding model.
|
|
8
|
-
|
|
9
|
-
The reranker is **OFF by default**. You only need to provision a GGUF if you are
|
|
10
|
-
turning it on (`FLAIR_RERANK_ENABLED=true`) or running the recall-bench A/B.
|
|
11
|
-
|
|
12
|
-
## Models
|
|
13
|
-
|
|
14
|
-
| `FLAIR_RERANK_MODEL` | GGUF filename (under `models/`) | Source | Inference mode |
|
|
15
|
-
|---|---|---|---|
|
|
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
|
-
|
|
19
|
-
Download into `models/` next to the embedding GGUF, e.g.:
|
|
20
|
-
|
|
21
|
-
```sh
|
|
22
|
-
# quality model (Qwen3 generative path)
|
|
23
|
-
huggingface-cli download Mungert/Qwen3-Reranker-0.6B-GGUF \
|
|
24
|
-
Qwen3-Reranker-0.6B-q8_0.gguf --local-dir models/
|
|
25
|
-
|
|
26
|
-
# latency model (jina rank API)
|
|
27
|
-
huggingface-cli download gpustack/jina-reranker-v2-base-multilingual-GGUF \
|
|
28
|
-
jina-reranker-v2-base.Q8_0.gguf --local-dir models/
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
If the GGUF is missing, the provider logs one warning and recall falls back to
|
|
32
|
-
vector order — it never blocks or breaks search.
|
|
33
|
-
|
|
34
|
-
## Config
|
|
35
|
-
|
|
36
|
-
| Env var | Default | Meaning |
|
|
37
|
-
|---|---|---|
|
|
38
|
-
| `FLAIR_RERANK_ENABLED` | unset (**OFF**) | Master flag. `"true"` to enable. |
|
|
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
|
-
| `FLAIR_RERANK_TOPN` | `50` | Candidate count fed to the reranker; caps the HNSW fetch. |
|
|
41
|
-
| `FLAIR_RERANK_BUDGET_MS` | `2500` | Hard latency budget; exceeded → vector order. |
|
|
42
|
-
| `FLAIR_RERANK_MIN_CANDIDATES` | `2` | Skip rerank below this many candidates. |
|
|
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
|
-
|
|
50
|
-
## Why this serving path (not Ollama, not a microservice)
|
|
51
|
-
|
|
52
|
-
- **In-process node-llama-cpp** is the same engine the embedding engine already
|
|
53
|
-
ships — no new infra, no network hop, no auth boundary. The reranker GGUF lives
|
|
54
|
-
next to the embedding GGUF and loads via the same addon-discovery pattern as
|
|
55
|
-
`embeddings-provider.ts`.
|
|
56
|
-
- **Ollama is out:** it has no rerank endpoint and silently drops next-token
|
|
57
|
-
logprobs, so it can serve neither the jina rank path nor the Qwen3 generative
|
|
58
|
-
yes/no path. (Verified live against newton's Ollama 0.30.10.)
|
|
59
|
-
|
|
60
|
-
## Known limitation — Qwen3 generative path inside Harper
|
|
61
|
-
|
|
62
|
-
The Qwen3 generative path scores `P(yes)/(P(yes)+P(no))` via node-llama-cpp's
|
|
63
|
-
`controlledEvaluate` with next-token probabilities. This works standalone and in a
|
|
64
|
-
plain Node worker thread, but **inside Harper's resource runtime — where HFE's
|
|
65
|
-
embedding engine has already initialized a separate native llama backend —
|
|
66
|
-
`controlledEvaluate` returns an empty result (no decoded logits).** The provider
|
|
67
|
-
detects this (`out.length === 0`), throws, and **fails open to vector order** (it
|
|
68
|
-
never writes corrupt scores). Net effect today: with `FLAIR_RERANK_MODEL=qwen3-...`
|
|
69
|
-
the rerank stage cleanly no-ops inside Harper.
|
|
70
|
-
|
|
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.
|