@evandu/super-pi 0.1.84 → 0.1.85
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/agents/super-pi-knowledge/index.ts +40 -0
- package/assets/knowledge-model/README.md +37 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/index-B_jOwjyp.js +72 -0
- package/dist/client/assets/index-CNNGl4eF.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +50 -11
- package/dist/server/application/run-service.js +206 -77
- package/dist/server/command-handler.js +16 -4
- package/dist/server/http-server.js +59 -1
- package/dist/server/index.js +7 -0
- package/dist/server/knowledge-vectors.d.ts +133 -0
- package/dist/server/knowledge-vectors.js +302 -0
- package/dist/server/knowledge.d.ts +263 -0
- package/dist/server/knowledge.js +743 -0
- package/dist/server/persistence/app-state-repository.d.ts +25 -1
- package/dist/server/persistence/sqlite-state-repository.js +47 -4
- package/dist/server/runtime.d.ts +48 -1
- package/dist/server/runtime.js +83 -7
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/types.d.ts +97 -65
- package/package.json +163 -155
- package/dist/client/assets/index-8Pf3MG2d.js +0 -72
- package/dist/client/assets/index-Jnj15dhT.css +0 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model, and the only one this feature can use.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS ONE. `bge-small-zh` scores 0.618 nDCG@10 averaged over the Chinese retrieval tasks it is
|
|
5
|
+
* benchmarked on - against 0.695 for `bge-base-zh`, which is four times the download. `gte-multilingual-base`
|
|
6
|
+
* scores higher on both Chinese and English, at 324 MB against this one's 23 MB. For a workspace-sized
|
|
7
|
+
* corpus the next thing that would help is not a bigger model but fusing this one with the lexical
|
|
8
|
+
* channels, which costs nothing and is what `KnowledgeBase.search` does.
|
|
9
|
+
*/
|
|
10
|
+
export declare const KNOWLEDGE_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
11
|
+
/**
|
|
12
|
+
* HOW BIG ONE EMBEDDED PIECE MAY BE, in characters.
|
|
13
|
+
*
|
|
14
|
+
* THIS NUMBER IS A MODEL LIMIT, NOT A TUNING KNOB, and getting it wrong is invisible. The model
|
|
15
|
+
* declares `max_position_embeddings: 512`, and a Chinese character costs roughly one token, so a piece
|
|
16
|
+
* is a little under half the window: 300 characters leaves room for the `[CLS]`/`[SEP]` pair and for
|
|
17
|
+
* tokenisation that splits a few characters into two. The retrieval chunks are 1200 characters - more
|
|
18
|
+
* than double this - which is fine for something a MODEL reads and fatal for something a MODEL
|
|
19
|
+
* ENCODES: handed a 1200-character chunk, roughly 60% of it is silently discarded past the window.
|
|
20
|
+
* English would survive it, so the degradation would land only on Chinese: the language this project
|
|
21
|
+
* is written in.
|
|
22
|
+
*/
|
|
23
|
+
export declare const EMBED_PIECE_CHARS = 300;
|
|
24
|
+
export type VectorRecord = {
|
|
25
|
+
chunk: number;
|
|
26
|
+
values: number[];
|
|
27
|
+
};
|
|
28
|
+
/** What the settings panel shows, so "off" and "broken here" are never the same silent nothing. */
|
|
29
|
+
export type VectorStatus = {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
ready: boolean;
|
|
32
|
+
indexed: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
bundled?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Splits a retrieval chunk into pieces small enough for the model's window.
|
|
38
|
+
*
|
|
39
|
+
* WHY NOT SHRINK THE RETRIEVAL CHUNKS INSTEAD. 1200 characters is the right size for a passage a model
|
|
40
|
+
* READS - it holds enough context to answer from. Encoding is a different constraint from reading, and
|
|
41
|
+
* conflating them would trade retrieval quality for a convenience the encoder does not need.
|
|
42
|
+
*/
|
|
43
|
+
export declare function embedPieces(text: string): string[];
|
|
44
|
+
/** Element-wise max over the pieces, then unit length. See `pool` for why max and not mean. */
|
|
45
|
+
export declare function pool(vectors: number[][]): number[];
|
|
46
|
+
export declare function cosine(a: number[], b: number[]): number;
|
|
47
|
+
/** One document's vectors, on disk next to the document they describe. */
|
|
48
|
+
export type VectorDocument = {
|
|
49
|
+
digest: string;
|
|
50
|
+
records: VectorRecord[];
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The optional vector half of retrieval.
|
|
54
|
+
*
|
|
55
|
+
* EVERY FAILURE HERE IS A FALLBACK TO LEXICAL, NEVER AN ERROR. The model is an optional npm package
|
|
56
|
+
* that a skipped optional install can take away, and it is a 512-token window
|
|
57
|
+
* wrapped around a Chinese corpus. Any of those failing is a reason to answer from the lexical
|
|
58
|
+
* channels and say so - never a reason to fail a search that the lexical channels can already serve.
|
|
59
|
+
*/
|
|
60
|
+
export declare class VectorIndex {
|
|
61
|
+
private readonly directory;
|
|
62
|
+
private extractor?;
|
|
63
|
+
private loading?;
|
|
64
|
+
private failure?;
|
|
65
|
+
private cache;
|
|
66
|
+
private pending;
|
|
67
|
+
private queue;
|
|
68
|
+
private background?;
|
|
69
|
+
constructor(directory: string);
|
|
70
|
+
/**
|
|
71
|
+
* Where the shipped weights live, relative to this module.
|
|
72
|
+
*
|
|
73
|
+
* `dist/server/knowledge-vectors.js` is two levels below the package root both in a published
|
|
74
|
+
* package and in this repository's own build - the same `../../assets/...` shape `runtime.ts` uses to
|
|
75
|
+
* reach `agents/`. The directory keeps the upstream id in its path because that is how the library
|
|
76
|
+
* looks the model up under `localModelPath`.
|
|
77
|
+
*/
|
|
78
|
+
bundledModel(): string;
|
|
79
|
+
enabled(): boolean;
|
|
80
|
+
status(indexed: number): VectorStatus;
|
|
81
|
+
private workspace;
|
|
82
|
+
private folder;
|
|
83
|
+
private file;
|
|
84
|
+
/**
|
|
85
|
+
* Loads the model once, from the copy that ships with this package. The `dtype` is not optional: on
|
|
86
|
+
* Node the library defaults to fp32, which for this model is 90 MB rather than 23.
|
|
87
|
+
*
|
|
88
|
+
* THERE IS NO NETWORK HERE, and that is the whole point of vendoring the weights. The first version
|
|
89
|
+
* downloaded the model on first use, which put a 23 MB fetch behind a host the machine may not be
|
|
90
|
+
* able to reach, on the first search, for every person who installed the package. Shipping the
|
|
91
|
+
* weights turns a failure mode into a file, and makes the feature work on a machine that has never
|
|
92
|
+
* been online since it was installed.
|
|
93
|
+
*/
|
|
94
|
+
private load;
|
|
95
|
+
private embed;
|
|
96
|
+
/** Vectors already stored for one workspace, keyed by document then chunk index. */
|
|
97
|
+
private read;
|
|
98
|
+
count(workspaceId: string): Promise<number>;
|
|
99
|
+
/** Drops a document's vectors. Called whenever its content changes or it is deleted. */
|
|
100
|
+
forget(workspaceId: string, documentId: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Embeds anything not already stored, WITHOUT waiting for it.
|
|
103
|
+
*
|
|
104
|
+
* WHY IT IS NOT AWAITED. This runs after an import or on the first search of a session, and the
|
|
105
|
+
* first run of it downloads a model. Neither should make a person wait: the import returns as soon
|
|
106
|
+
* as the document is stored and the search returns lexical hits, and a later search is simply better
|
|
107
|
+
* because by then the vectors exist.
|
|
108
|
+
*
|
|
109
|
+
* WHY REQUESTS QUEUE RATHER THAN DROP. One job runs at a time - two would load the same model twice
|
|
110
|
+
* and race each other's writes - but a request arriving mid-job has to be remembered. Dropping it
|
|
111
|
+
* would mean a document imported while a background index was in flight stayed unindexed until the
|
|
112
|
+
* next restart, which is the kind of bug that only shows up on a busy machine weeks later.
|
|
113
|
+
*/
|
|
114
|
+
schedule(workspaceId: string, documents: Array<{
|
|
115
|
+
id: string;
|
|
116
|
+
digest: string;
|
|
117
|
+
chunks: Array<{
|
|
118
|
+
index: number;
|
|
119
|
+
text: string;
|
|
120
|
+
}>;
|
|
121
|
+
}>): void;
|
|
122
|
+
/**
|
|
123
|
+
* Cosine of every stored chunk against the query, best first.
|
|
124
|
+
*
|
|
125
|
+
* WHY THE RAW COSINE IS NOT RE-NORMALISED BY THE BEST HIT. Every lexical channel is divided by its
|
|
126
|
+
* own maximum, and that is right for them because BM25 scores are unbounded and have no absolute
|
|
127
|
+
* meaning. Cosine is the opposite: it is already a bounded, interpretable quantity, and dividing it
|
|
128
|
+
* by the best hit of the query would turn "nothing here is close" into "the best one is 1.0". The
|
|
129
|
+
* knowledge base's most useful property is that it can say it does not know, and rescaling is exactly
|
|
130
|
+
* how that gets spent.
|
|
131
|
+
*/
|
|
132
|
+
search(workspaceId: string, query: string, keys: Array<[string, string]>): Promise<Map<string, number> | null>;
|
|
133
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* The model, and the only one this feature can use.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS ONE. `bge-small-zh` scores 0.618 nDCG@10 averaged over the Chinese retrieval tasks it is
|
|
9
|
+
* benchmarked on - against 0.695 for `bge-base-zh`, which is four times the download. `gte-multilingual-base`
|
|
10
|
+
* scores higher on both Chinese and English, at 324 MB against this one's 23 MB. For a workspace-sized
|
|
11
|
+
* corpus the next thing that would help is not a bigger model but fusing this one with the lexical
|
|
12
|
+
* channels, which costs nothing and is what `KnowledgeBase.search` does.
|
|
13
|
+
*/
|
|
14
|
+
export const KNOWLEDGE_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
15
|
+
/**
|
|
16
|
+
* HOW BIG ONE EMBEDDED PIECE MAY BE, in characters.
|
|
17
|
+
*
|
|
18
|
+
* THIS NUMBER IS A MODEL LIMIT, NOT A TUNING KNOB, and getting it wrong is invisible. The model
|
|
19
|
+
* declares `max_position_embeddings: 512`, and a Chinese character costs roughly one token, so a piece
|
|
20
|
+
* is a little under half the window: 300 characters leaves room for the `[CLS]`/`[SEP]` pair and for
|
|
21
|
+
* tokenisation that splits a few characters into two. The retrieval chunks are 1200 characters - more
|
|
22
|
+
* than double this - which is fine for something a MODEL reads and fatal for something a MODEL
|
|
23
|
+
* ENCODES: handed a 1200-character chunk, roughly 60% of it is silently discarded past the window.
|
|
24
|
+
* English would survive it, so the degradation would land only on Chinese: the language this project
|
|
25
|
+
* is written in.
|
|
26
|
+
*/
|
|
27
|
+
export const EMBED_PIECE_CHARS = 300;
|
|
28
|
+
const EMBED_PIECE_OVERLAP = 40;
|
|
29
|
+
/** Per query. Cosine similarity of unit vectors; beyond this the passage is noise. */
|
|
30
|
+
const MIN_COSINE = 0.15;
|
|
31
|
+
/**
|
|
32
|
+
* Splits a retrieval chunk into pieces small enough for the model's window.
|
|
33
|
+
*
|
|
34
|
+
* WHY NOT SHRINK THE RETRIEVAL CHUNKS INSTEAD. 1200 characters is the right size for a passage a model
|
|
35
|
+
* READS - it holds enough context to answer from. Encoding is a different constraint from reading, and
|
|
36
|
+
* conflating them would trade retrieval quality for a convenience the encoder does not need.
|
|
37
|
+
*/
|
|
38
|
+
export function embedPieces(text) {
|
|
39
|
+
const pieces = [];
|
|
40
|
+
for (let offset = 0; offset < text.length; offset += EMBED_PIECE_CHARS - EMBED_PIECE_OVERLAP) {
|
|
41
|
+
const piece = text.slice(offset, offset + EMBED_PIECE_CHARS).trim();
|
|
42
|
+
if (piece)
|
|
43
|
+
pieces.push(piece);
|
|
44
|
+
if (offset + EMBED_PIECE_CHARS >= text.length)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return pieces.length ? pieces : [text.slice(0, EMBED_PIECE_CHARS)];
|
|
48
|
+
}
|
|
49
|
+
/** Element-wise max over the pieces, then unit length. See `pool` for why max and not mean. */
|
|
50
|
+
export function pool(vectors) {
|
|
51
|
+
if (!vectors.length)
|
|
52
|
+
return [];
|
|
53
|
+
const pooled = new Array(vectors[0].length).fill(Number.NEGATIVE_INFINITY);
|
|
54
|
+
for (const vector of vectors) {
|
|
55
|
+
for (let i = 0; i < vector.length; i++)
|
|
56
|
+
if (vector[i] > pooled[i])
|
|
57
|
+
pooled[i] = vector[i];
|
|
58
|
+
}
|
|
59
|
+
let norm = 0;
|
|
60
|
+
for (const value of pooled)
|
|
61
|
+
norm += value * value;
|
|
62
|
+
norm = Math.sqrt(norm) || 1;
|
|
63
|
+
return pooled.map(value => value / norm);
|
|
64
|
+
}
|
|
65
|
+
export function cosine(a, b) {
|
|
66
|
+
let dot = 0;
|
|
67
|
+
for (let i = 0; i < a.length && i < b.length; i++)
|
|
68
|
+
dot += a[i] * b[i];
|
|
69
|
+
return dot;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The optional vector half of retrieval.
|
|
73
|
+
*
|
|
74
|
+
* EVERY FAILURE HERE IS A FALLBACK TO LEXICAL, NEVER AN ERROR. The model is an optional npm package
|
|
75
|
+
* that a skipped optional install can take away, and it is a 512-token window
|
|
76
|
+
* wrapped around a Chinese corpus. Any of those failing is a reason to answer from the lexical
|
|
77
|
+
* channels and say so - never a reason to fail a search that the lexical channels can already serve.
|
|
78
|
+
*/
|
|
79
|
+
export class VectorIndex {
|
|
80
|
+
directory;
|
|
81
|
+
extractor;
|
|
82
|
+
loading;
|
|
83
|
+
failure;
|
|
84
|
+
cache = new Map();
|
|
85
|
+
pending = new Set();
|
|
86
|
+
queue = [];
|
|
87
|
+
background;
|
|
88
|
+
constructor(directory) {
|
|
89
|
+
this.directory = directory;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Where the shipped weights live, relative to this module.
|
|
93
|
+
*
|
|
94
|
+
* `dist/server/knowledge-vectors.js` is two levels below the package root both in a published
|
|
95
|
+
* package and in this repository's own build - the same `../../assets/...` shape `runtime.ts` uses to
|
|
96
|
+
* reach `agents/`. The directory keeps the upstream id in its path because that is how the library
|
|
97
|
+
* looks the model up under `localModelPath`.
|
|
98
|
+
*/
|
|
99
|
+
bundledModel() { return resolve(import.meta.dirname, "../../assets/knowledge-model"); }
|
|
100
|
+
enabled() { return process.env.SUPER_PI_KNOWLEDGE_EMBEDDINGS !== "0"; }
|
|
101
|
+
status(indexed) {
|
|
102
|
+
return {
|
|
103
|
+
enabled: this.enabled(), ready: Boolean(this.extractor), indexed, error: this.failure,
|
|
104
|
+
// Whether the shipped weights are on disk, reported before the model is ever loaded: "the
|
|
105
|
+
// package was built without its assets" and "the model failed to load" are different problems.
|
|
106
|
+
bundled: existsSync(this.bundledModel()),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
workspace(workspaceId) { return join(this.directory, Buffer.from(workspaceId).toString("hex")); }
|
|
110
|
+
folder(workspaceId) { return join(this.workspace(workspaceId), "vectors"); }
|
|
111
|
+
file(workspaceId, documentId) { return join(this.folder(workspaceId), `${documentId}.vec.json`); }
|
|
112
|
+
/**
|
|
113
|
+
* Loads the model once, from the copy that ships with this package. The `dtype` is not optional: on
|
|
114
|
+
* Node the library defaults to fp32, which for this model is 90 MB rather than 23.
|
|
115
|
+
*
|
|
116
|
+
* THERE IS NO NETWORK HERE, and that is the whole point of vendoring the weights. The first version
|
|
117
|
+
* downloaded the model on first use, which put a 23 MB fetch behind a host the machine may not be
|
|
118
|
+
* able to reach, on the first search, for every person who installed the package. Shipping the
|
|
119
|
+
* weights turns a failure mode into a file, and makes the feature work on a machine that has never
|
|
120
|
+
* been online since it was installed.
|
|
121
|
+
*/
|
|
122
|
+
load() {
|
|
123
|
+
if (this.extractor)
|
|
124
|
+
return Promise.resolve(this.extractor);
|
|
125
|
+
if (this.failure)
|
|
126
|
+
return Promise.resolve(undefined);
|
|
127
|
+
this.loading ??= (async () => {
|
|
128
|
+
try {
|
|
129
|
+
if (!existsSync(this.bundledModel()))
|
|
130
|
+
throw new Error(`随包分发的模型缺失:${this.bundledModel()}`);
|
|
131
|
+
// THE SPECIFIER GOES THROUGH A VARIABLE, and that is deliberate. Written as a literal,
|
|
132
|
+
// `tsc` resolves it while compiling - so a machine whose optional install skipped this package
|
|
133
|
+
// could not build the server at all, which makes an "optional" dependency mandatory in the
|
|
134
|
+
// only sense that matters to a person who installed it. Node resolves the variable at RUN time
|
|
135
|
+
// instead, where its absence is already handled: the throw below is the same branch that
|
|
136
|
+
// handles a missing model.
|
|
137
|
+
const specifier = "@huggingface/transformers";
|
|
138
|
+
const { pipeline, env } = await import(specifier);
|
|
139
|
+
// The library resolves `<localModelPath>/<model id>/onnx/...`, which is why the vendored tree
|
|
140
|
+
// keeps the `Xenova/bge-small-zh-v1.5` directory rather than being flattened.
|
|
141
|
+
env.localModelPath = this.bundledModel();
|
|
142
|
+
env.allowRemoteModels = false;
|
|
143
|
+
this.extractor = await pipeline("feature-extraction", KNOWLEDGE_MODEL, { dtype: "q8" });
|
|
144
|
+
return this.extractor;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
this.failure = error instanceof Error ? error.message : String(error);
|
|
148
|
+
console.warn(`Super Pi: 语义检索不可用,将只用关键词检索:${this.failure}`);
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
this.loading = undefined;
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
return this.loading;
|
|
156
|
+
}
|
|
157
|
+
async embed(texts) {
|
|
158
|
+
if (!texts.length || !this.enabled())
|
|
159
|
+
return null;
|
|
160
|
+
const extractor = await this.load();
|
|
161
|
+
if (!extractor)
|
|
162
|
+
return null;
|
|
163
|
+
try {
|
|
164
|
+
const output = await extractor(texts, { pooling: "cls", normalize: true });
|
|
165
|
+
const dimension = output.dims[output.dims.length - 1];
|
|
166
|
+
return Array.from({ length: output.dims[0] }, (_, row) => Array.from(output.data.slice(row * dimension, (row + 1) * dimension)));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
this.failure = error instanceof Error ? error.message : String(error);
|
|
170
|
+
console.warn(`Super Pi: 生成向量失败,将只用关键词检索:${this.failure}`);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Vectors already stored for one workspace, keyed by document then chunk index. */
|
|
175
|
+
async read(workspaceId, documentId) {
|
|
176
|
+
try {
|
|
177
|
+
const document = JSON.parse(await readFile(this.file(workspaceId, documentId), "utf8"));
|
|
178
|
+
return Array.isArray(document?.records) ? document : null;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error.code === "ENOENT")
|
|
182
|
+
return null;
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async count(workspaceId) {
|
|
187
|
+
try {
|
|
188
|
+
const names = (await readdir(this.folder(workspaceId))).filter(name => name.endsWith(".vec.json"));
|
|
189
|
+
let total = 0;
|
|
190
|
+
for (const name of names) {
|
|
191
|
+
const document = await this.read(workspaceId, name.slice(0, -".vec.json".length));
|
|
192
|
+
total += document?.records.length ?? 0;
|
|
193
|
+
}
|
|
194
|
+
return total;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Drops a document's vectors. Called whenever its content changes or it is deleted. */
|
|
201
|
+
async forget(workspaceId, documentId) {
|
|
202
|
+
this.cache.delete(`${workspaceId}/${documentId}`);
|
|
203
|
+
await unlink(this.file(workspaceId, documentId)).catch(() => { });
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Embeds anything not already stored, WITHOUT waiting for it.
|
|
207
|
+
*
|
|
208
|
+
* WHY IT IS NOT AWAITED. This runs after an import or on the first search of a session, and the
|
|
209
|
+
* first run of it downloads a model. Neither should make a person wait: the import returns as soon
|
|
210
|
+
* as the document is stored and the search returns lexical hits, and a later search is simply better
|
|
211
|
+
* because by then the vectors exist.
|
|
212
|
+
*
|
|
213
|
+
* WHY REQUESTS QUEUE RATHER THAN DROP. One job runs at a time - two would load the same model twice
|
|
214
|
+
* and race each other's writes - but a request arriving mid-job has to be remembered. Dropping it
|
|
215
|
+
* would mean a document imported while a background index was in flight stayed unindexed until the
|
|
216
|
+
* next restart, which is the kind of bug that only shows up on a busy machine weeks later.
|
|
217
|
+
*/
|
|
218
|
+
schedule(workspaceId, documents) {
|
|
219
|
+
if (!this.enabled() || !documents.length)
|
|
220
|
+
return;
|
|
221
|
+
this.queue.push({ workspaceId, documents });
|
|
222
|
+
if (this.background)
|
|
223
|
+
return;
|
|
224
|
+
this.background = (async () => {
|
|
225
|
+
while (this.queue.length) {
|
|
226
|
+
const next = this.queue.shift();
|
|
227
|
+
try {
|
|
228
|
+
for (const document of next.documents) {
|
|
229
|
+
const key = `${next.workspaceId}/${document.id}`;
|
|
230
|
+
if (this.pending.has(key))
|
|
231
|
+
continue;
|
|
232
|
+
this.pending.add(key);
|
|
233
|
+
const stored = await this.read(next.workspaceId, document.id);
|
|
234
|
+
if (stored?.digest === document.digest && stored.records.length === document.chunks.length) {
|
|
235
|
+
this.pending.delete(key);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const vectors = [];
|
|
239
|
+
for (const chunk of document.chunks) {
|
|
240
|
+
const embedded = await this.embed(embedPieces(chunk.text));
|
|
241
|
+
if (!embedded) {
|
|
242
|
+
this.pending.delete(key);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
vectors.push({ chunk: chunk.index, values: pool(embedded) });
|
|
246
|
+
}
|
|
247
|
+
const folder = this.folder(next.workspaceId);
|
|
248
|
+
await mkdir(folder, { recursive: true });
|
|
249
|
+
const file = this.file(next.workspaceId, document.id), temporary = `${file}.${randomUUID()}.tmp`;
|
|
250
|
+
await writeFile(temporary, JSON.stringify({ digest: document.digest, records: vectors }), { encoding: "utf8", mode: 0o600 });
|
|
251
|
+
await rename(temporary, file);
|
|
252
|
+
this.cache.set(key, new Map(vectors.map(record => [record.chunk, record])));
|
|
253
|
+
this.pending.delete(key);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
console.warn(`Super Pi: 语义索引失败,将只用关键词检索:${error instanceof Error ? error.message : String(error)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
})().finally(() => { this.background = undefined; });
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Cosine of every stored chunk against the query, best first.
|
|
264
|
+
*
|
|
265
|
+
* WHY THE RAW COSINE IS NOT RE-NORMALISED BY THE BEST HIT. Every lexical channel is divided by its
|
|
266
|
+
* own maximum, and that is right for them because BM25 scores are unbounded and have no absolute
|
|
267
|
+
* meaning. Cosine is the opposite: it is already a bounded, interpretable quantity, and dividing it
|
|
268
|
+
* by the best hit of the query would turn "nothing here is close" into "the best one is 1.0". The
|
|
269
|
+
* knowledge base's most useful property is that it can say it does not know, and rescaling is exactly
|
|
270
|
+
* how that gets spent.
|
|
271
|
+
*/
|
|
272
|
+
async search(workspaceId, query, keys) {
|
|
273
|
+
if (!this.enabled() || !keys.length)
|
|
274
|
+
return null;
|
|
275
|
+
const documentIds = new Set(keys.map(([, documentId]) => documentId));
|
|
276
|
+
// First query of the session: read what was indexed last time rather than assuming nothing is.
|
|
277
|
+
for (const documentId of documentIds) {
|
|
278
|
+
if (this.cache.has(`${workspaceId}/${documentId}`))
|
|
279
|
+
continue;
|
|
280
|
+
const document = await this.read(workspaceId, documentId);
|
|
281
|
+
if (document)
|
|
282
|
+
this.cache.set(`${workspaceId}/${documentId}`, new Map(document.records.map(record => [record.chunk, record])));
|
|
283
|
+
}
|
|
284
|
+
const available = keys.filter(([, documentId]) => this.cache.get(`${workspaceId}/${documentId}`)?.size);
|
|
285
|
+
if (!available.length)
|
|
286
|
+
return null;
|
|
287
|
+
const vector = await this.embed([query]);
|
|
288
|
+
if (!vector?.[0])
|
|
289
|
+
return null;
|
|
290
|
+
const scores = new Map();
|
|
291
|
+
for (const [key, documentId] of available) {
|
|
292
|
+
const chunk = Number(key.split("#")[1]);
|
|
293
|
+
const record = this.cache.get(`${workspaceId}/${documentId}`)?.get(chunk);
|
|
294
|
+
if (!record)
|
|
295
|
+
continue;
|
|
296
|
+
const similarity = cosine(vector[0], record.values);
|
|
297
|
+
if (similarity >= MIN_COSINE)
|
|
298
|
+
scores.set(key, similarity);
|
|
299
|
+
}
|
|
300
|
+
return scores;
|
|
301
|
+
}
|
|
302
|
+
}
|