@gmickel/gno 1.6.0 → 1.7.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 +1 -1
- package/assets/skill/SKILL.md +1 -1
- package/assets/skill/cli-reference.md +3 -2
- package/assets/skill/mcp-reference.md +2 -3
- package/package.json +3 -2
- package/src/cli/commands/doctor.ts +179 -1
- package/src/cli/commands/embed.ts +217 -242
- package/src/cli/program.ts +3 -1
- package/src/embed/backlog.ts +92 -45
- package/src/embed/fingerprint.ts +37 -0
- package/src/embed/retry.ts +137 -0
- package/src/mcp/tools/index.ts +5 -1
- package/src/mcp/tools/query.ts +2 -0
- package/src/pipeline/hybrid.ts +1 -1
- package/src/pipeline/types.ts +3 -1
- package/src/sdk/embed.ts +134 -59
- package/src/serve/routes/api.ts +2 -0
- package/src/store/migrations/008-vector-fingerprints.ts +25 -0
- package/src/store/migrations/index.ts +2 -1
- package/src/store/sqlite/adapter.ts +20 -6
- package/src/store/types.ts +1 -0
- package/src/store/vector/freshness.ts +34 -0
- package/src/store/vector/sqlite-vec.ts +5 -2
- package/src/store/vector/stats.ts +20 -2
- package/src/store/vector/types.ts +3 -0
package/src/embed/backlog.ts
CHANGED
|
@@ -10,13 +10,16 @@ import type { StoreResult } from "../store/types";
|
|
|
10
10
|
import type {
|
|
11
11
|
BacklogItem,
|
|
12
12
|
VectorIndexPort,
|
|
13
|
-
VectorRow,
|
|
14
13
|
VectorStatsPort,
|
|
15
14
|
} from "../store/vector";
|
|
16
15
|
|
|
17
|
-
import { formatDocForEmbedding } from "../pipeline/contextual";
|
|
18
16
|
import { err, ok } from "../store/types";
|
|
19
|
-
import {
|
|
17
|
+
import { getEmbeddingFingerprint } from "./fingerprint";
|
|
18
|
+
import {
|
|
19
|
+
chunkRetryKey,
|
|
20
|
+
embedAndStoreBatch,
|
|
21
|
+
MAX_EMBED_CHUNK_ATTEMPTS,
|
|
22
|
+
} from "./retry";
|
|
20
23
|
|
|
21
24
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
25
|
// Types
|
|
@@ -56,19 +59,86 @@ export async function embedBacklog(
|
|
|
56
59
|
): Promise<StoreResult<EmbedBacklogResult>> {
|
|
57
60
|
const { statsPort, embedPort, vectorIndex, modelUri, collection } = deps;
|
|
58
61
|
const batchSize = deps.batchSize ?? 32;
|
|
62
|
+
const embedFingerprint = getEmbeddingFingerprint({
|
|
63
|
+
modelUri,
|
|
64
|
+
dimensions: vectorIndex.dimensions,
|
|
65
|
+
});
|
|
59
66
|
|
|
60
67
|
let embedded = 0;
|
|
61
68
|
let errors = 0;
|
|
62
69
|
let cursor: Cursor | undefined;
|
|
70
|
+
const retryQueue = new Map<string, { item: BacklogItem; attempts: number }>();
|
|
71
|
+
|
|
72
|
+
const enqueueRetryItems = (items: BacklogItem[], attempts: number): void => {
|
|
73
|
+
for (const item of items) {
|
|
74
|
+
const key = chunkRetryKey(item);
|
|
75
|
+
const existing = retryQueue.get(key);
|
|
76
|
+
retryQueue.set(key, {
|
|
77
|
+
item,
|
|
78
|
+
attempts: Math.max(existing?.attempts ?? 0, attempts),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const drainRetryQueue = async (): Promise<number> => {
|
|
84
|
+
if (retryQueue.size === 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let retryEmbedded = 0;
|
|
89
|
+
const entries = [...retryQueue.values()].filter(
|
|
90
|
+
(entry) => entry.attempts < MAX_EMBED_CHUNK_ATTEMPTS
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
for (let idx = 0; idx < entries.length; idx += batchSize) {
|
|
94
|
+
const slice = entries.slice(idx, idx + batchSize);
|
|
95
|
+
for (const entry of slice) {
|
|
96
|
+
retryQueue.delete(chunkRetryKey(entry.item));
|
|
97
|
+
entry.attempts += 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const retryResult = await embedAndStoreBatch({
|
|
101
|
+
embedPort,
|
|
102
|
+
vectorIndex,
|
|
103
|
+
items: slice.map((entry) => entry.item),
|
|
104
|
+
modelUri,
|
|
105
|
+
embedFingerprint,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
embedded += retryResult.embedded;
|
|
109
|
+
errors += retryResult.errors;
|
|
110
|
+
retryEmbedded += retryResult.embedded;
|
|
111
|
+
|
|
112
|
+
const retryByKey = new Set(
|
|
113
|
+
retryResult.retryItems.map((item) => chunkRetryKey(item))
|
|
114
|
+
);
|
|
115
|
+
for (const entry of slice) {
|
|
116
|
+
if (!retryByKey.has(chunkRetryKey(entry.item))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (entry.attempts >= MAX_EMBED_CHUNK_ATTEMPTS) {
|
|
120
|
+
errors += 1;
|
|
121
|
+
} else {
|
|
122
|
+
retryQueue.set(chunkRetryKey(entry.item), entry);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return retryEmbedded;
|
|
128
|
+
};
|
|
63
129
|
|
|
64
130
|
try {
|
|
65
131
|
while (true) {
|
|
66
132
|
// Get next batch using seek pagination
|
|
67
|
-
const batchResult = await statsPort.getBacklog(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
133
|
+
const batchResult = await statsPort.getBacklog(
|
|
134
|
+
modelUri,
|
|
135
|
+
embedFingerprint,
|
|
136
|
+
{
|
|
137
|
+
limit: batchSize,
|
|
138
|
+
after: cursor,
|
|
139
|
+
collection,
|
|
140
|
+
}
|
|
141
|
+
);
|
|
72
142
|
|
|
73
143
|
if (!batchResult.ok) {
|
|
74
144
|
return err("QUERY_FAILED", batchResult.error.message);
|
|
@@ -85,48 +155,25 @@ export async function embedBacklog(
|
|
|
85
155
|
cursor = { mirrorHash: lastItem.mirrorHash, seq: lastItem.seq };
|
|
86
156
|
}
|
|
87
157
|
|
|
88
|
-
|
|
89
|
-
const
|
|
158
|
+
const beforeEmbedded = embedded;
|
|
159
|
+
const batchStoreResult = await embedAndStoreBatch({
|
|
90
160
|
embedPort,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
if (!embedResult.ok) {
|
|
101
|
-
errors += batch.length;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const vectors: VectorRow[] = [];
|
|
106
|
-
for (const [idx, item] of batch.entries()) {
|
|
107
|
-
const embedding = embedResult.value.vectors[idx];
|
|
108
|
-
if (!embedding) {
|
|
109
|
-
errors += 1;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
vectors.push({
|
|
113
|
-
mirrorHash: item.mirrorHash,
|
|
114
|
-
seq: item.seq,
|
|
115
|
-
model: modelUri,
|
|
116
|
-
embedding: new Float32Array(embedding),
|
|
117
|
-
});
|
|
118
|
-
}
|
|
161
|
+
vectorIndex,
|
|
162
|
+
items: batch,
|
|
163
|
+
modelUri,
|
|
164
|
+
embedFingerprint,
|
|
165
|
+
});
|
|
166
|
+
embedded += batchStoreResult.embedded;
|
|
167
|
+
errors += batchStoreResult.errors;
|
|
168
|
+
enqueueRetryItems(batchStoreResult.retryItems, 1);
|
|
119
169
|
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
if (!storeResult.ok) {
|
|
123
|
-
errors += vectors.length;
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
embedded += vectors.length;
|
|
170
|
+
if (embedded > beforeEmbedded) {
|
|
171
|
+
await drainRetryQueue();
|
|
127
172
|
}
|
|
128
173
|
}
|
|
129
174
|
|
|
175
|
+
await drainRetryQueue();
|
|
176
|
+
|
|
130
177
|
// Sync vec index once at end if any vec0 writes failed
|
|
131
178
|
let syncError: string | undefined;
|
|
132
179
|
if (vectorIndex.vecDirty) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding freshness fingerprint.
|
|
3
|
+
*
|
|
4
|
+
* @module src/embed/fingerprint
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getEmbeddingCompatibilityProfile } from "../llm/embedding-compatibility";
|
|
8
|
+
|
|
9
|
+
export const EMBEDDING_CONTEXTUAL_FORMAT_VERSION = "contextual-embedding-v1";
|
|
10
|
+
export const EMBEDDING_CHUNKING_STRATEGY_VERSION = "markdown-char-semantic-v1";
|
|
11
|
+
|
|
12
|
+
export interface EmbeddingFingerprintInput {
|
|
13
|
+
modelUri: string;
|
|
14
|
+
dimensions?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getEmbeddingFingerprint(
|
|
18
|
+
input: EmbeddingFingerprintInput
|
|
19
|
+
): string {
|
|
20
|
+
const profile = getEmbeddingCompatibilityProfile(input.modelUri);
|
|
21
|
+
const payload = {
|
|
22
|
+
chunking: EMBEDDING_CHUNKING_STRATEGY_VERSION,
|
|
23
|
+
contextualFormatting: EMBEDDING_CONTEXTUAL_FORMAT_VERSION,
|
|
24
|
+
dimensions: input.dimensions ?? null,
|
|
25
|
+
modelUri: input.modelUri,
|
|
26
|
+
profile: {
|
|
27
|
+
batchEmbeddingTrusted: profile.batchEmbeddingTrusted,
|
|
28
|
+
documentFormat: profile.documentFormat,
|
|
29
|
+
id: profile.id,
|
|
30
|
+
queryFormat: profile.queryFormat,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return new Bun.CryptoHasher("sha256")
|
|
35
|
+
.update(JSON.stringify(payload))
|
|
36
|
+
.digest("hex");
|
|
37
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { EmbeddingPort } from "../llm/types";
|
|
2
|
+
import type { BacklogItem, VectorIndexPort, VectorRow } from "../store/vector";
|
|
3
|
+
|
|
4
|
+
import { formatDocForEmbedding } from "../pipeline/contextual";
|
|
5
|
+
import { embedTextsWithRecovery } from "./batch";
|
|
6
|
+
|
|
7
|
+
export const MAX_EMBED_CHUNK_ATTEMPTS = 2;
|
|
8
|
+
export const MAX_EMBED_FAILURE_SAMPLES = 5;
|
|
9
|
+
|
|
10
|
+
export interface EmbedStoreBatchResult {
|
|
11
|
+
embedded: number;
|
|
12
|
+
errors: number;
|
|
13
|
+
retryItems: BacklogItem[];
|
|
14
|
+
errorSamples: string[];
|
|
15
|
+
suggestion?: string;
|
|
16
|
+
batchFailed: boolean;
|
|
17
|
+
batchError?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function chunkRetryKey(item: Pick<BacklogItem, "mirrorHash" | "seq">) {
|
|
21
|
+
return `${item.mirrorHash}\0${item.seq}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function addUniqueSamples(target: string[], samples: string[]): void {
|
|
25
|
+
for (const sample of samples) {
|
|
26
|
+
if (target.length >= MAX_EMBED_FAILURE_SAMPLES) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
if (!target.includes(sample)) {
|
|
30
|
+
target.push(sample);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatLlmFailure(
|
|
36
|
+
error: { message: string; cause?: unknown } | undefined
|
|
37
|
+
): string {
|
|
38
|
+
if (!error) {
|
|
39
|
+
return "Unknown embedding failure";
|
|
40
|
+
}
|
|
41
|
+
const cause =
|
|
42
|
+
error.cause &&
|
|
43
|
+
typeof error.cause === "object" &&
|
|
44
|
+
"message" in error.cause &&
|
|
45
|
+
typeof error.cause.message === "string"
|
|
46
|
+
? error.cause.message
|
|
47
|
+
: typeof error.cause === "string"
|
|
48
|
+
? error.cause
|
|
49
|
+
: "";
|
|
50
|
+
return cause && cause !== error.message
|
|
51
|
+
? `${error.message} - ${cause}`
|
|
52
|
+
: error.message;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function embedAndStoreBatch(params: {
|
|
56
|
+
embedPort: EmbeddingPort;
|
|
57
|
+
vectorIndex: VectorIndexPort;
|
|
58
|
+
items: BacklogItem[];
|
|
59
|
+
modelUri: string;
|
|
60
|
+
embedFingerprint: string;
|
|
61
|
+
}): Promise<EmbedStoreBatchResult> {
|
|
62
|
+
const { embedPort, vectorIndex, items, modelUri, embedFingerprint } = params;
|
|
63
|
+
const embedResult = await embedTextsWithRecovery(
|
|
64
|
+
embedPort,
|
|
65
|
+
items.map((item) =>
|
|
66
|
+
formatDocForEmbedding(item.text, item.title ?? undefined, modelUri)
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!embedResult.ok) {
|
|
71
|
+
const formattedError = formatLlmFailure(embedResult.error);
|
|
72
|
+
return {
|
|
73
|
+
embedded: 0,
|
|
74
|
+
errors: embedResult.error.retryable ? 0 : items.length,
|
|
75
|
+
retryItems: embedResult.error.retryable ? items : [],
|
|
76
|
+
errorSamples: [formattedError],
|
|
77
|
+
suggestion: embedResult.error.retryable
|
|
78
|
+
? "Try rerunning the same command. If failures persist, rerun with `gno --verbose embed --batch-size 1` to isolate failing chunks."
|
|
79
|
+
: embedResult.error.suggestion,
|
|
80
|
+
batchFailed: true,
|
|
81
|
+
batchError: formattedError,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const vectors: VectorRow[] = [];
|
|
86
|
+
const retryItems: BacklogItem[] = [];
|
|
87
|
+
for (const [idx, item] of items.entries()) {
|
|
88
|
+
const embedding = embedResult.value.vectors[idx];
|
|
89
|
+
if (!embedding) {
|
|
90
|
+
retryItems.push(item);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
vectors.push({
|
|
94
|
+
mirrorHash: item.mirrorHash,
|
|
95
|
+
seq: item.seq,
|
|
96
|
+
model: modelUri,
|
|
97
|
+
embedFingerprint,
|
|
98
|
+
embedding: new Float32Array(embedding),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (vectors.length === 0) {
|
|
103
|
+
return {
|
|
104
|
+
embedded: 0,
|
|
105
|
+
errors: 0,
|
|
106
|
+
retryItems,
|
|
107
|
+
errorSamples: embedResult.value.failureSamples,
|
|
108
|
+
suggestion: embedResult.value.retrySuggestion,
|
|
109
|
+
batchFailed: embedResult.value.batchFailed,
|
|
110
|
+
batchError: embedResult.value.batchError,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const storeResult = await vectorIndex.upsertVectors(vectors);
|
|
115
|
+
if (!storeResult.ok) {
|
|
116
|
+
return {
|
|
117
|
+
embedded: 0,
|
|
118
|
+
errors: vectors.length,
|
|
119
|
+
retryItems,
|
|
120
|
+
errorSamples: [storeResult.error.message],
|
|
121
|
+
suggestion:
|
|
122
|
+
"Store write failed. Rerun `gno embed` once more; if it repeats, run `gno doctor` and `gno vec sync`.",
|
|
123
|
+
batchFailed: embedResult.value.batchFailed,
|
|
124
|
+
batchError: embedResult.value.batchError,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
embedded: vectors.length,
|
|
130
|
+
errors: 0,
|
|
131
|
+
retryItems,
|
|
132
|
+
errorSamples: embedResult.value.failureSamples,
|
|
133
|
+
suggestion: embedResult.value.retrySuggestion,
|
|
134
|
+
batchFailed: embedResult.value.batchFailed,
|
|
135
|
+
batchError: embedResult.value.batchError,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -456,7 +456,11 @@ export const queryInputSchema = z.object({
|
|
|
456
456
|
noGraph: z
|
|
457
457
|
.boolean()
|
|
458
458
|
.optional()
|
|
459
|
-
.describe("
|
|
459
|
+
.describe("Compatibility no-op unless graph is also true"),
|
|
460
|
+
graph: z
|
|
461
|
+
.boolean()
|
|
462
|
+
.optional()
|
|
463
|
+
.describe("Enable bounded one-hop graph neighbor expansion"),
|
|
460
464
|
tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"),
|
|
461
465
|
tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
|
|
462
466
|
});
|
package/src/mcp/tools/query.ts
CHANGED
|
@@ -51,6 +51,7 @@ interface QueryInput {
|
|
|
51
51
|
expand?: boolean;
|
|
52
52
|
rerank?: boolean;
|
|
53
53
|
noGraph?: boolean;
|
|
54
|
+
graph?: boolean;
|
|
54
55
|
tagsAll?: string[];
|
|
55
56
|
tagsAny?: string[];
|
|
56
57
|
}
|
|
@@ -272,6 +273,7 @@ export function handleQuery(
|
|
|
272
273
|
author: args.author,
|
|
273
274
|
noExpand,
|
|
274
275
|
noRerank,
|
|
276
|
+
graph: args.graph === true,
|
|
275
277
|
noGraph: args.noGraph || args.fast,
|
|
276
278
|
queryModes,
|
|
277
279
|
tagsAll: normalizeTagFilters(args.tagsAll),
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -554,7 +554,7 @@ export async function searchHybrid(
|
|
|
554
554
|
includeSimilar: vectorAvailable,
|
|
555
555
|
limit,
|
|
556
556
|
candidateLimit,
|
|
557
|
-
disabled: options.noGraph,
|
|
557
|
+
disabled: !options.graph || options.noGraph,
|
|
558
558
|
lang: options.lang,
|
|
559
559
|
tagsAll: options.tagsAll,
|
|
560
560
|
tagsAny: options.tagsAny,
|
package/src/pipeline/types.ts
CHANGED
|
@@ -174,7 +174,9 @@ export type HybridSearchOptions = SearchOptions & {
|
|
|
174
174
|
candidateLimit?: number;
|
|
175
175
|
/** Enable explain output */
|
|
176
176
|
explain?: boolean;
|
|
177
|
-
/**
|
|
177
|
+
/** Enable bounded one-hop graph candidate expansion */
|
|
178
|
+
graph?: boolean;
|
|
179
|
+
/** Compatibility no-op unless graph is also true */
|
|
178
180
|
noGraph?: boolean;
|
|
179
181
|
/** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
|
|
180
182
|
queryLanguageHint?: string;
|
package/src/sdk/embed.ts
CHANGED
|
@@ -19,15 +19,15 @@ import type {
|
|
|
19
19
|
import type { GnoEmbedOptions, GnoEmbedResult } from "./types";
|
|
20
20
|
|
|
21
21
|
import { embedBacklog } from "../embed";
|
|
22
|
-
import {
|
|
22
|
+
import { getEmbeddingFingerprint } from "../embed/fingerprint";
|
|
23
|
+
import {
|
|
24
|
+
chunkRetryKey,
|
|
25
|
+
embedAndStoreBatch,
|
|
26
|
+
MAX_EMBED_CHUNK_ATTEMPTS,
|
|
27
|
+
} from "../embed/retry";
|
|
23
28
|
import { resolveModelUri } from "../llm/registry";
|
|
24
|
-
import { formatDocForEmbedding } from "../pipeline/contextual";
|
|
25
29
|
import { err, ok } from "../store/types";
|
|
26
|
-
import {
|
|
27
|
-
createVectorIndexPort,
|
|
28
|
-
createVectorStatsPort,
|
|
29
|
-
type VectorRow,
|
|
30
|
-
} from "../store/vector";
|
|
30
|
+
import { createVectorIndexPort, createVectorStatsPort } from "../store/vector";
|
|
31
31
|
import { sdkError } from "./errors";
|
|
32
32
|
|
|
33
33
|
interface EmbedRuntimeOptions {
|
|
@@ -121,6 +121,68 @@ async function forceEmbedAll(
|
|
|
121
121
|
let embedded = 0;
|
|
122
122
|
let errors = 0;
|
|
123
123
|
let cursor: { mirrorHash: string; seq: number } | undefined;
|
|
124
|
+
const retryQueue = new Map<string, { item: BacklogItem; attempts: number }>();
|
|
125
|
+
const embedFingerprint = getEmbeddingFingerprint({
|
|
126
|
+
modelUri,
|
|
127
|
+
dimensions: vectorIndex.dimensions,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const enqueueRetryItems = (items: BacklogItem[], attempts: number): void => {
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const key = chunkRetryKey(item);
|
|
133
|
+
const existing = retryQueue.get(key);
|
|
134
|
+
retryQueue.set(key, {
|
|
135
|
+
item,
|
|
136
|
+
attempts: Math.max(existing?.attempts ?? 0, attempts),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const drainRetryQueue = async (): Promise<number> => {
|
|
142
|
+
if (retryQueue.size === 0) {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let retryEmbedded = 0;
|
|
147
|
+
const entries = [...retryQueue.values()].filter(
|
|
148
|
+
(entry) => entry.attempts < MAX_EMBED_CHUNK_ATTEMPTS
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
for (let idx = 0; idx < entries.length; idx += batchSize) {
|
|
152
|
+
const slice = entries.slice(idx, idx + batchSize);
|
|
153
|
+
for (const entry of slice) {
|
|
154
|
+
retryQueue.delete(chunkRetryKey(entry.item));
|
|
155
|
+
entry.attempts += 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const retryResult = await embedAndStoreBatch({
|
|
159
|
+
embedPort,
|
|
160
|
+
vectorIndex,
|
|
161
|
+
items: slice.map((entry) => entry.item),
|
|
162
|
+
modelUri,
|
|
163
|
+
embedFingerprint,
|
|
164
|
+
});
|
|
165
|
+
embedded += retryResult.embedded;
|
|
166
|
+
errors += retryResult.errors;
|
|
167
|
+
retryEmbedded += retryResult.embedded;
|
|
168
|
+
|
|
169
|
+
const retryByKey = new Set(
|
|
170
|
+
retryResult.retryItems.map((item) => chunkRetryKey(item))
|
|
171
|
+
);
|
|
172
|
+
for (const entry of slice) {
|
|
173
|
+
if (!retryByKey.has(chunkRetryKey(entry.item))) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (entry.attempts >= MAX_EMBED_CHUNK_ATTEMPTS) {
|
|
177
|
+
errors += 1;
|
|
178
|
+
} else {
|
|
179
|
+
retryQueue.set(chunkRetryKey(entry.item), entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return retryEmbedded;
|
|
185
|
+
};
|
|
124
186
|
|
|
125
187
|
while (true) {
|
|
126
188
|
const batchResult = await getActiveChunks(db, batchSize, cursor);
|
|
@@ -140,45 +202,27 @@ async function forceEmbedAll(
|
|
|
140
202
|
cursor = { mirrorHash: lastItem.mirrorHash, seq: lastItem.seq };
|
|
141
203
|
}
|
|
142
204
|
|
|
143
|
-
const
|
|
205
|
+
const beforeEmbedded = embedded;
|
|
206
|
+
const embedResult = await embedAndStoreBatch({
|
|
144
207
|
embedPort,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
if (!embedResult.ok) {
|
|
155
|
-
errors += batch.length;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
208
|
+
vectorIndex,
|
|
209
|
+
items: batch,
|
|
210
|
+
modelUri,
|
|
211
|
+
embedFingerprint,
|
|
212
|
+
});
|
|
213
|
+
embedded += embedResult.embedded;
|
|
214
|
+
errors += embedResult.errors;
|
|
215
|
+
enqueueRetryItems(embedResult.retryItems, 1);
|
|
158
216
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const embedding = embedResult.value.vectors[idx];
|
|
162
|
-
if (!embedding) {
|
|
163
|
-
errors += 1;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
vectors.push({
|
|
167
|
-
mirrorHash: item.mirrorHash,
|
|
168
|
-
seq: item.seq,
|
|
169
|
-
model: modelUri,
|
|
170
|
-
embedding: new Float32Array(embedding),
|
|
171
|
-
});
|
|
217
|
+
if (embedded > beforeEmbedded) {
|
|
218
|
+
await drainRetryQueue();
|
|
172
219
|
}
|
|
220
|
+
}
|
|
173
221
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
embedded += vectors.length;
|
|
181
|
-
}
|
|
222
|
+
await drainRetryQueue();
|
|
223
|
+
if (retryQueue.size > 0) {
|
|
224
|
+
errors += retryQueue.size;
|
|
225
|
+
retryQueue.clear();
|
|
182
226
|
}
|
|
183
227
|
|
|
184
228
|
if (vectorIndex.vecDirty) {
|
|
@@ -217,24 +261,25 @@ export async function runEmbed(
|
|
|
217
261
|
const db = runtime.store.getRawDb();
|
|
218
262
|
const stats: VectorStatsPort = createVectorStatsPort(db);
|
|
219
263
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
264
|
+
let totalToEmbed = 0;
|
|
265
|
+
if (force) {
|
|
266
|
+
const forceCount = await getActiveChunkCount(db);
|
|
267
|
+
if (!forceCount.ok) {
|
|
268
|
+
throw sdkError("STORE", forceCount.error.message, {
|
|
269
|
+
cause: forceCount.error.cause,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
228
272
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
273
|
+
totalToEmbed = forceCount.value;
|
|
274
|
+
if (totalToEmbed === 0 || dryRun) {
|
|
275
|
+
return {
|
|
276
|
+
embedded: totalToEmbed,
|
|
277
|
+
errors: 0,
|
|
278
|
+
duration: 0,
|
|
279
|
+
model: modelUri,
|
|
280
|
+
searchAvailable: await checkVecAvailable(db),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
238
283
|
}
|
|
239
284
|
|
|
240
285
|
const embedResult = await runtime.llm.createEmbeddingPort(modelUri, {
|
|
@@ -266,6 +311,36 @@ export async function runEmbed(
|
|
|
266
311
|
}
|
|
267
312
|
|
|
268
313
|
const vectorIndex = vectorResult.value;
|
|
314
|
+
if (!force) {
|
|
315
|
+
const embedFingerprint = getEmbeddingFingerprint({
|
|
316
|
+
modelUri,
|
|
317
|
+
dimensions: vectorIndex.dimensions,
|
|
318
|
+
});
|
|
319
|
+
const backlogResult = await stats.countBacklog(
|
|
320
|
+
modelUri,
|
|
321
|
+
embedFingerprint,
|
|
322
|
+
{
|
|
323
|
+
collection: options.collection,
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
if (!backlogResult.ok) {
|
|
327
|
+
throw sdkError("STORE", backlogResult.error.message, {
|
|
328
|
+
cause: backlogResult.error.cause,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
totalToEmbed = backlogResult.value;
|
|
333
|
+
if (totalToEmbed === 0 || dryRun) {
|
|
334
|
+
return {
|
|
335
|
+
embedded: totalToEmbed,
|
|
336
|
+
errors: 0,
|
|
337
|
+
duration: 0,
|
|
338
|
+
model: modelUri,
|
|
339
|
+
searchAvailable: vectorIndex.searchAvailable,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
269
344
|
const startedAt = Date.now();
|
|
270
345
|
let result: { embedded: number; errors: number };
|
|
271
346
|
if (force) {
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -173,6 +173,7 @@ export interface QueryRequestBody {
|
|
|
173
173
|
noExpand?: boolean;
|
|
174
174
|
noRerank?: boolean;
|
|
175
175
|
noGraph?: boolean;
|
|
176
|
+
graph?: boolean;
|
|
176
177
|
/** Comma-separated tags - filter to docs having ALL (AND) */
|
|
177
178
|
tagsAll?: string;
|
|
178
179
|
/** Comma-separated tags - filter to docs having ANY (OR) */
|
|
@@ -3311,6 +3312,7 @@ export async function handleQuery(
|
|
|
3311
3312
|
queryModes: normalizedQueryModes,
|
|
3312
3313
|
noExpand: body.noExpand,
|
|
3313
3314
|
noRerank: body.noRerank,
|
|
3315
|
+
graph: body.graph === true,
|
|
3314
3316
|
noGraph: body.noGraph,
|
|
3315
3317
|
tagsAll,
|
|
3316
3318
|
tagsAny,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: vector embedding freshness fingerprints.
|
|
3
|
+
*
|
|
4
|
+
* @module src/store/migrations/008-vector-fingerprints
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "bun:sqlite";
|
|
8
|
+
|
|
9
|
+
import type { Migration } from "./runner";
|
|
10
|
+
|
|
11
|
+
export const migration: Migration = {
|
|
12
|
+
version: 8,
|
|
13
|
+
name: "vector_fingerprints",
|
|
14
|
+
|
|
15
|
+
up(db: Database): void {
|
|
16
|
+
db.exec(`
|
|
17
|
+
ALTER TABLE content_vectors ADD COLUMN embed_fingerprint TEXT NOT NULL DEFAULT ''
|
|
18
|
+
`);
|
|
19
|
+
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_vectors_freshness
|
|
22
|
+
ON content_vectors(model, embed_fingerprint, mirror_hash, seq, embedded_at)
|
|
23
|
+
`);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -21,6 +21,7 @@ import { migration as m004 } from "./004-doc-links";
|
|
|
21
21
|
import { migration as m005 } from "./005-graph-indexes";
|
|
22
22
|
import { migration as m006 } from "./006-document-metadata";
|
|
23
23
|
import { migration as m007 } from "./007-document-date-fields";
|
|
24
|
+
import { migration as m008 } from "./008-vector-fingerprints";
|
|
24
25
|
|
|
25
26
|
/** All migrations in order */
|
|
26
|
-
export const migrations = [m001, m002, m003, m004, m005, m006, m007];
|
|
27
|
+
export const migrations = [m001, m002, m003, m004, m005, m006, m007, m008];
|