memorix 1.7.1 → 1.7.3
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/CHANGELOG.md +43 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/assets/star-history-dark.svg +4 -4
- package/assets/star-history-light.svg +4 -4
- package/assets/star-history.json +47 -3
- package/dist/cli/index.js +423 -74
- package/dist/index.js +396 -73
- package/dist/maintenance-runner.js +393 -69
- package/dist/media-audio-runner.js +393 -69
- package/dist/media-video-runner.js +393 -69
- package/dist/memcode-runtime/CHANGELOG.md +43 -1
- package/dist/sdk.js +395 -72
- package/dist/vector-backfill-runner.js +393 -69
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONFIGURATION.md +57 -1
- package/docs/DEVELOPMENT.md +6 -2
- package/docs/PERFORMANCE.md +13 -4
- package/docs/README.md +3 -1
- package/docs/SETUP.md +2 -1
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/config-migrate.ts +7 -0
- package/src/cli/commands/doctor.ts +5 -1
- package/src/cli/commands/init.ts +17 -0
- package/src/cli/commands/status.ts +2 -0
- package/src/config/resolved-config.ts +110 -12
- package/src/config/toml-loader.ts +8 -0
- package/src/config/yaml-loader.ts +9 -0
- package/src/llm/provider.ts +33 -7
- package/src/multimodal/image-loader.ts +6 -3
- package/src/rerank/http-provider.ts +125 -0
- package/src/rerank/index.ts +81 -0
- package/src/rerank/policy.ts +88 -0
- package/src/store/orama-store.ts +111 -65
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional HTTP rerank client.
|
|
3
|
+
*
|
|
4
|
+
* Posts a Cohere-compatible `{model, query, documents}` body to
|
|
5
|
+
* `{base_url}/rerank` (or an explicit `/rerank` path). Compatible with
|
|
6
|
+
* any `/rerank` endpoint that accepts that body. The model id is
|
|
7
|
+
* whatever the user configured — Memorix does not rewrite it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveRerankTimeoutMs } from './policy.js';
|
|
11
|
+
|
|
12
|
+
export type NeuralRerankProviderName = 'http';
|
|
13
|
+
|
|
14
|
+
export interface NeuralRerankRequestConfig {
|
|
15
|
+
provider: NeuralRerankProviderName;
|
|
16
|
+
model?: string;
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RerankedDocument {
|
|
24
|
+
index: number;
|
|
25
|
+
score: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Join a provider base URL with `/rerank`, leaving an explicit path intact.
|
|
30
|
+
*/
|
|
31
|
+
export function buildRerankUrl(baseUrl: string): string {
|
|
32
|
+
const trimmed = baseUrl.trim().replace(/\/+$/, '');
|
|
33
|
+
if (trimmed.endsWith('/rerank')) return trimmed;
|
|
34
|
+
return `${trimmed}/rerank`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Accept Cohere-compatible `{results:[{index,relevance_score}]}` and TEI `[{index,score}]`.
|
|
39
|
+
*/
|
|
40
|
+
export function parseRerankResponse(payload: unknown): RerankedDocument[] {
|
|
41
|
+
const rows = extractRows(payload);
|
|
42
|
+
if (rows.length === 0) {
|
|
43
|
+
throw new Error('Neural rerank response contained no ranked results');
|
|
44
|
+
}
|
|
45
|
+
return rows.sort((a, b) => b.score - a.score);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractRows(payload: unknown): RerankedDocument[] {
|
|
49
|
+
if (Array.isArray(payload)) {
|
|
50
|
+
return payload.map(rowFromUnknown).filter((row): row is RerankedDocument => row !== null);
|
|
51
|
+
}
|
|
52
|
+
if (payload && typeof payload === 'object' && 'results' in payload) {
|
|
53
|
+
const results = (payload as { results: unknown }).results;
|
|
54
|
+
if (Array.isArray(results)) {
|
|
55
|
+
return results.map(rowFromUnknown).filter((row): row is RerankedDocument => row !== null);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error('Neural rerank response was not Cohere or TEI shaped');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rowFromUnknown(value: unknown): RerankedDocument | null {
|
|
62
|
+
if (!value || typeof value !== 'object') return null;
|
|
63
|
+
const row = value as { index?: unknown; relevance_score?: unknown; score?: unknown };
|
|
64
|
+
if (typeof row.index !== 'number' || !Number.isFinite(row.index)) return null;
|
|
65
|
+
const score = typeof row.relevance_score === 'number'
|
|
66
|
+
? row.relevance_score
|
|
67
|
+
: typeof row.score === 'number'
|
|
68
|
+
? row.score
|
|
69
|
+
: null;
|
|
70
|
+
if (score === null || !Number.isFinite(score)) return null;
|
|
71
|
+
return { index: row.index, score };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildRerankBody(query: string, documents: string[], model?: string): Record<string, unknown> {
|
|
75
|
+
const body: Record<string, unknown> = {
|
|
76
|
+
query,
|
|
77
|
+
documents,
|
|
78
|
+
top_n: documents.length,
|
|
79
|
+
return_documents: false,
|
|
80
|
+
};
|
|
81
|
+
const trimmed = model?.trim();
|
|
82
|
+
if (trimmed) body.model = trimmed;
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POST query + documents to a compatible `/rerank` endpoint and return ranked indexes.
|
|
88
|
+
*/
|
|
89
|
+
export async function rerankViaHttp(
|
|
90
|
+
query: string,
|
|
91
|
+
documents: string[],
|
|
92
|
+
config: NeuralRerankRequestConfig,
|
|
93
|
+
): Promise<RerankedDocument[]> {
|
|
94
|
+
if (documents.length === 0) return [];
|
|
95
|
+
|
|
96
|
+
const url = buildRerankUrl(config.baseUrl);
|
|
97
|
+
const headers: Record<string, string> = {
|
|
98
|
+
'content-type': 'application/json',
|
|
99
|
+
};
|
|
100
|
+
if (config.apiKey) {
|
|
101
|
+
headers.authorization = `Bearer ${config.apiKey}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timeoutMs = resolveRerankTimeoutMs(config.timeoutMs);
|
|
106
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
110
|
+
const response = await fetchImpl(url, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers,
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
body: JSON.stringify(buildRerankBody(query, documents, config.model)),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw new Error(`Neural rerank HTTP ${response.status} from ${url}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return parseRerankResponse(await response.json());
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional HTTP rerank lane. LLM rerank stays in quality.ts when HTTP is off.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getResolvedRerankLane, type ResolvedLaneOptions } from '../config/resolved-config.js';
|
|
6
|
+
import { rerankViaHttp } from './http-provider.js';
|
|
7
|
+
import { resolveRerankTimeoutMs } from './policy.js';
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_RERANK_TIMEOUT_MS,
|
|
11
|
+
MIN_RERANK_CANDIDATES,
|
|
12
|
+
parseRerankTimeoutMs,
|
|
13
|
+
resolveRerankTimeoutMs,
|
|
14
|
+
shouldAttemptHttpRerank,
|
|
15
|
+
shouldAttemptLlmRerank,
|
|
16
|
+
} from './policy.js';
|
|
17
|
+
|
|
18
|
+
/** Minimal search hit the HTTP reranker can reorder. */
|
|
19
|
+
export interface NeuralRerankCandidate {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
type: string;
|
|
23
|
+
score: number;
|
|
24
|
+
narrative?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_RERANK = 10;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when an HTTP `/rerank` endpoint is configured.
|
|
31
|
+
*/
|
|
32
|
+
export function isNeuralRerankEnabled(options: ResolvedLaneOptions = {}): boolean {
|
|
33
|
+
const lane = getResolvedRerankLane(options);
|
|
34
|
+
return lane.provider === 'http' && Boolean(lane.baseUrl);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reorder search candidates via the configured remote reranker.
|
|
39
|
+
* Returns null when the provider is off or the HTTP call cannot be used.
|
|
40
|
+
*/
|
|
41
|
+
export async function neuralRerankCandidates(
|
|
42
|
+
query: string,
|
|
43
|
+
candidates: NeuralRerankCandidate[],
|
|
44
|
+
options: ResolvedLaneOptions = {},
|
|
45
|
+
): Promise<NeuralRerankCandidate[] | null> {
|
|
46
|
+
if (!isNeuralRerankEnabled(options) || candidates.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
const lane = getResolvedRerankLane(options);
|
|
49
|
+
if (!lane.canSendRequest || !lane.baseUrl) return null;
|
|
50
|
+
const toRerank = candidates.slice(0, MAX_RERANK);
|
|
51
|
+
const rest = candidates.slice(MAX_RERANK);
|
|
52
|
+
const documents = toRerank.map((candidate) => candidateText(candidate));
|
|
53
|
+
|
|
54
|
+
const ranked = await rerankViaHttp(query, documents, {
|
|
55
|
+
provider: 'http',
|
|
56
|
+
model: lane.model,
|
|
57
|
+
baseUrl: lane.baseUrl!,
|
|
58
|
+
apiKey: lane.apiKey,
|
|
59
|
+
timeoutMs: resolveRerankTimeoutMs(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const seen = new Set<number>();
|
|
63
|
+
const reranked: NeuralRerankCandidate[] = [];
|
|
64
|
+
for (const row of ranked) {
|
|
65
|
+
const candidate = toRerank[row.index];
|
|
66
|
+
if (!candidate || seen.has(row.index)) continue;
|
|
67
|
+
reranked.push(candidate);
|
|
68
|
+
seen.add(row.index);
|
|
69
|
+
}
|
|
70
|
+
for (const [index, candidate] of toRerank.entries()) {
|
|
71
|
+
if (!seen.has(index)) reranked.push(candidate);
|
|
72
|
+
}
|
|
73
|
+
reranked.push(...rest);
|
|
74
|
+
return reranked;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function candidateText(candidate: NeuralRerankCandidate): string {
|
|
78
|
+
const narrative = candidate.narrative?.trim();
|
|
79
|
+
if (narrative) return `${candidate.title} — ${narrative.slice(0, 200)}`;
|
|
80
|
+
return candidate.title;
|
|
81
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product rules for optional HTTP rerank and the existing LLM rerank path.
|
|
3
|
+
*
|
|
4
|
+
* HTTP rerank is opt-in. When it is configured, thorough search with enough
|
|
5
|
+
* candidates uses the HTTP lane only — a miss or timeout keeps the original
|
|
6
|
+
* order instead of falling through to LLM rerank.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_RERANK_TIMEOUT_MS = 30_000;
|
|
10
|
+
export const RERANK_TIMEOUT_MIN_MS = 1_000;
|
|
11
|
+
export const RERANK_TIMEOUT_MAX_MS = 300_000;
|
|
12
|
+
export const MIN_RERANK_CANDIDATES = 3;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse and validate MEMORIX_RERANK_TIMEOUT_MS.
|
|
16
|
+
*
|
|
17
|
+
* @param raw - Environment value, or undefined when unset
|
|
18
|
+
* @returns Timeout in milliseconds (default 30000)
|
|
19
|
+
*/
|
|
20
|
+
export function parseRerankTimeoutMs(raw: string | undefined): number {
|
|
21
|
+
const value = raw?.trim();
|
|
22
|
+
if (!value) return DEFAULT_RERANK_TIMEOUT_MS;
|
|
23
|
+
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
if (!Number.isInteger(parsed) || Number.isNaN(parsed)) {
|
|
26
|
+
console.warn(
|
|
27
|
+
`[memorix] MEMORIX_RERANK_TIMEOUT_MS="${raw}" is invalid (must be a positive integer between ${RERANK_TIMEOUT_MIN_MS}-${RERANK_TIMEOUT_MAX_MS}ms). Using default ${DEFAULT_RERANK_TIMEOUT_MS}ms.`,
|
|
28
|
+
);
|
|
29
|
+
return DEFAULT_RERANK_TIMEOUT_MS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (parsed < RERANK_TIMEOUT_MIN_MS) return RERANK_TIMEOUT_MIN_MS;
|
|
33
|
+
if (parsed > RERANK_TIMEOUT_MAX_MS) return RERANK_TIMEOUT_MAX_MS;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the rerank timeout from an explicit override or the environment.
|
|
39
|
+
*
|
|
40
|
+
* @param explicitTimeoutMs - Caller-supplied timeout, when present
|
|
41
|
+
* @returns Timeout in milliseconds
|
|
42
|
+
*/
|
|
43
|
+
export function resolveRerankTimeoutMs(explicitTimeoutMs?: number): number {
|
|
44
|
+
if (typeof explicitTimeoutMs === 'number' && Number.isFinite(explicitTimeoutMs) && explicitTimeoutMs > 0) {
|
|
45
|
+
return explicitTimeoutMs;
|
|
46
|
+
}
|
|
47
|
+
return parseRerankTimeoutMs(process.env.MEMORIX_RERANK_TIMEOUT_MS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when thorough search has enough candidates for HTTP rerank.
|
|
52
|
+
*
|
|
53
|
+
* @param input - Retrieval quality, query presence, and hit count
|
|
54
|
+
* @returns Whether the HTTP rerank lane should run
|
|
55
|
+
*/
|
|
56
|
+
export function shouldAttemptHttpRerank(input: {
|
|
57
|
+
quality?: string;
|
|
58
|
+
hasQuery: boolean;
|
|
59
|
+
candidateCount: number;
|
|
60
|
+
}): boolean {
|
|
61
|
+
return input.quality === 'thorough'
|
|
62
|
+
&& input.hasQuery
|
|
63
|
+
&& input.candidateCount >= MIN_RERANK_CANDIDATES;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True when the existing LLM rerank path should run.
|
|
68
|
+
*
|
|
69
|
+
* LLM rerank stays on the original thorough + heavy + close top-2 gate.
|
|
70
|
+
* It never runs when an HTTP rerank lane is configured.
|
|
71
|
+
*
|
|
72
|
+
* @param input - Search gate inputs plus whether HTTP rerank is configured
|
|
73
|
+
* @returns Whether LLM rerank should run
|
|
74
|
+
*/
|
|
75
|
+
export function shouldAttemptLlmRerank(input: {
|
|
76
|
+
quality?: string;
|
|
77
|
+
tier: string;
|
|
78
|
+
hasQuery: boolean;
|
|
79
|
+
candidateCount: number;
|
|
80
|
+
topScore: number;
|
|
81
|
+
secondScore: number;
|
|
82
|
+
httpRerankConfigured: boolean;
|
|
83
|
+
}): boolean {
|
|
84
|
+
if (input.httpRerankConfigured) return false;
|
|
85
|
+
if (input.quality !== 'thorough' || input.tier !== 'heavy' || !input.hasQuery) return false;
|
|
86
|
+
if (input.candidateCount < MIN_RERANK_CANDIDATES) return false;
|
|
87
|
+
return input.topScore > 0 && input.secondScore / input.topScore > 0.7;
|
|
88
|
+
}
|
package/src/store/orama-store.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Vector search (embeddings) will be added in P1 phase.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { create, insert, search, remove, update, count, getByID, type AnyOrama } from '@orama/orama';
|
|
11
|
+
import { create, insert, insertMultiple, search, remove, update, count, getByID, type AnyOrama } from '@orama/orama';
|
|
12
12
|
import type { MemorixDocument, SearchOptions, IndexEntry, KnowledgeLayer, ObservationReader } from '../types.js';
|
|
13
13
|
import { OBSERVATION_ICONS, type ObservationType } from '../types.js';
|
|
14
14
|
import { resolveKnowledgeLayer } from '../skills/mini-skills.js';
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { calculateProjectAffinity, extractProjectKeywords, type AffinityContext, type MemoryContent } from './project-affinity.js';
|
|
24
24
|
import { detectQueryIntent, applyIntentBoost } from '../search/intent-detector.js';
|
|
25
25
|
import { maybeExpandSearchQuery } from '../search/query-expansion.js';
|
|
26
|
+
import { parseRerankTimeoutMs, shouldAttemptHttpRerank, shouldAttemptLlmRerank } from '../rerank/policy.js';
|
|
26
27
|
import { withTimeout, withTimeoutSignal } from '../timeout.js';
|
|
27
28
|
|
|
28
29
|
let db: AnyOrama | null = null;
|
|
@@ -405,7 +406,7 @@ export async function hydrateIndex(
|
|
|
405
406
|
? candidates.map(() => null)
|
|
406
407
|
: await getCachedEmbeddings(candidates.map(({ observation }) => observationEmbeddingText(observation)));
|
|
407
408
|
|
|
408
|
-
|
|
409
|
+
const documents: MemorixDocument[] = [];
|
|
409
410
|
for (let index = 0; index < candidates.length; index++) {
|
|
410
411
|
const { observation: obs, id } = candidates[index];
|
|
411
412
|
try {
|
|
@@ -439,19 +440,39 @@ export async function hydrateIndex(
|
|
|
439
440
|
knowledgeLayer: resolveKnowledgeLayer('observation', obs.sourceDetail, obs.source),
|
|
440
441
|
...(compatibleVector ? { embedding: compatibleVector } : {}),
|
|
441
442
|
};
|
|
442
|
-
|
|
443
|
-
rememberObservationDoc(doc);
|
|
444
|
-
inserted++;
|
|
445
|
-
// Keep the HTTP event loop alive on large corpora so /health still answers.
|
|
446
|
-
if (inserted % 50 === 0) {
|
|
447
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
448
|
-
}
|
|
449
|
-
if (inserted % 200 === 0) {
|
|
450
|
-
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
451
|
-
}
|
|
443
|
+
documents.push(doc);
|
|
452
444
|
} catch { /* skip malformed entries */ }
|
|
453
445
|
}
|
|
454
446
|
|
|
447
|
+
let inserted = 0;
|
|
448
|
+
const hydrationBatchSize = 200;
|
|
449
|
+
for (let start = 0; start < documents.length; start += hydrationBatchSize) {
|
|
450
|
+
const batch = documents.slice(start, start + hydrationBatchSize);
|
|
451
|
+
try {
|
|
452
|
+
// Orama can rebalance its indexes once per batch instead of once per
|
|
453
|
+
// document. Keep batches bounded so HTTP health remains responsive.
|
|
454
|
+
await insertMultiple(database, batch, batch.length);
|
|
455
|
+
for (const doc of batch) rememberObservationDoc(doc);
|
|
456
|
+
inserted += batch.length;
|
|
457
|
+
} catch {
|
|
458
|
+
// A concurrent write or malformed document can partially complete a
|
|
459
|
+
// batch. Preserve the old best-effort behavior without double-inserting.
|
|
460
|
+
for (const doc of batch) {
|
|
461
|
+
if (getByID(database, doc.id)) {
|
|
462
|
+
rememberObservationDoc(doc);
|
|
463
|
+
inserted++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
await insert(database, doc);
|
|
468
|
+
rememberObservationDoc(doc);
|
|
469
|
+
inserted++;
|
|
470
|
+
} catch { /* skip malformed or concurrently inserted entries */ }
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
474
|
+
}
|
|
475
|
+
|
|
455
476
|
deferredCachedVectorHydration = options.skipCachedVectors
|
|
456
477
|
? null
|
|
457
478
|
: deferCachedVectors
|
|
@@ -1034,62 +1055,87 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
1034
1055
|
}
|
|
1035
1056
|
}
|
|
1036
1057
|
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
+
// HTTP rerank: thorough search with enough candidates.
|
|
1059
|
+
// LLM rerank: original thorough + heavy + close top-2 gate, and only when
|
|
1060
|
+
// HTTP rerank is not configured. A configured HTTP miss keeps original order.
|
|
1061
|
+
const httpGate = shouldAttemptHttpRerank({
|
|
1062
|
+
quality,
|
|
1063
|
+
hasQuery: Boolean(hasQuery),
|
|
1064
|
+
candidateCount: intermediate.length,
|
|
1065
|
+
});
|
|
1066
|
+
const llmGateWithoutHttp = shouldAttemptLlmRerank({
|
|
1067
|
+
quality,
|
|
1068
|
+
tier,
|
|
1069
|
+
hasQuery: Boolean(hasQuery),
|
|
1070
|
+
candidateCount: intermediate.length,
|
|
1071
|
+
topScore: intermediate[0]?.score ?? 0,
|
|
1072
|
+
secondScore: intermediate[1]?.score ?? 0,
|
|
1073
|
+
httpRerankConfigured: false,
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
if (httpGate || llmGateWithoutHttp) {
|
|
1077
|
+
const narrativeMap = new Map<string, string>();
|
|
1078
|
+
for (const hit of results.hits) {
|
|
1079
|
+
const doc = hit.document as unknown as MemorixDocument;
|
|
1080
|
+
narrativeMap.set(makeEntryKey(doc.projectId, doc.observationId), doc.narrative);
|
|
1081
|
+
}
|
|
1082
|
+
const RERANK_TOP_K = 5;
|
|
1083
|
+
const toRerank = intermediate.slice(0, RERANK_TOP_K);
|
|
1084
|
+
const candidates = toRerank.map((e, index) => ({
|
|
1085
|
+
id: `r${index + 1}`,
|
|
1086
|
+
title: e.title,
|
|
1087
|
+
type: e.type,
|
|
1088
|
+
score: e.score,
|
|
1089
|
+
narrative: narrativeMap.get(makeEntryKey(e.projectId, e.id)),
|
|
1090
|
+
}));
|
|
1091
|
+
const RERANK_TIMEOUT_MS = parseRerankTimeoutMs(process.env.MEMORIX_RERANK_TIMEOUT_MS);
|
|
1092
|
+
const candidateMap = new Map(candidates.map((candidate, index) => [candidate.id, toRerank[index]]));
|
|
1093
|
+
const { isNeuralRerankEnabled, neuralRerankCandidates } = await import('../rerank/index.js');
|
|
1094
|
+
const httpConfigured = isNeuralRerankEnabled();
|
|
1095
|
+
|
|
1096
|
+
if (httpConfigured && httpGate) {
|
|
1097
|
+
try {
|
|
1098
|
+
const neural = await withTimeout(
|
|
1099
|
+
neuralRerankCandidates(originalQuery!, candidates),
|
|
1100
|
+
RERANK_TIMEOUT_MS,
|
|
1101
|
+
'neural rerank',
|
|
1102
|
+
);
|
|
1103
|
+
if (neural && neural.length > 0) {
|
|
1104
|
+
const rerankedTop = neural
|
|
1105
|
+
.map((row) => candidateMap.get(row.id))
|
|
1106
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
|
1107
|
+
if (rerankedTop.length > 0) {
|
|
1108
|
+
intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
|
|
1109
|
+
lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + neural rerank');
|
|
1110
|
+
mark('rerank(usedNeural=true)');
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch {
|
|
1114
|
+
console.error('[memorix] neural rerank failed or timed out; keeping original order');
|
|
1058
1115
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
'LLM rerank',
|
|
1077
|
-
);
|
|
1078
|
-
mark(`rerank(usedLLM=${usedLLM})`);
|
|
1079
|
-
|
|
1080
|
-
if (usedLLM) {
|
|
1081
|
-
lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
|
|
1082
|
-
const candidateMap = new Map(candidates.map((candidate, index) => [candidate.id, toRerank[index]]));
|
|
1083
|
-
const rerankedTop = reranked
|
|
1084
|
-
.map(r => candidateMap.get(r.id))
|
|
1085
|
-
.filter((e): e is NonNullable<typeof e> => e != null);
|
|
1086
|
-
if (rerankedTop.length > 0) {
|
|
1087
|
-
intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
|
|
1116
|
+
} else if (!httpConfigured && llmGateWithoutHttp) {
|
|
1117
|
+
try {
|
|
1118
|
+
const { rerankResults } = await import('../llm/quality.js');
|
|
1119
|
+
const { reranked, usedLLM } = await withTimeout(
|
|
1120
|
+
rerankResults(originalQuery!, candidates),
|
|
1121
|
+
RERANK_TIMEOUT_MS,
|
|
1122
|
+
'LLM rerank',
|
|
1123
|
+
);
|
|
1124
|
+
mark(`rerank(usedLLM=${usedLLM})`);
|
|
1125
|
+
if (usedLLM) {
|
|
1126
|
+
lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
|
|
1127
|
+
const rerankedTop = reranked
|
|
1128
|
+
.map((row) => candidateMap.get(row.id))
|
|
1129
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
|
1130
|
+
if (rerankedTop.length > 0) {
|
|
1131
|
+
intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
|
|
1132
|
+
}
|
|
1088
1133
|
}
|
|
1134
|
+
} catch {
|
|
1135
|
+
console.error('[memorix] LLM rerank failed or timed out, using original order');
|
|
1089
1136
|
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
console.error('[memorix] LLM rerank failed or timed out, using original order');
|
|
1137
|
+
} else {
|
|
1138
|
+
mark(`rerank(skipped,tier=${tier})`);
|
|
1093
1139
|
}
|
|
1094
1140
|
} else {
|
|
1095
1141
|
mark(`rerank(skipped,tier=${tier})`);
|