dsh-shared-kb 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/cordis.patch.yml +11 -0
- package/lib/client.js +61 -0
- package/lib/core/atomic.js +87 -0
- package/lib/core/errors.js +39 -0
- package/lib/core/hubconfig.js +151 -0
- package/lib/core/paths.js +129 -0
- package/lib/icon.svg +7 -0
- package/lib/index.js +202 -0
- package/lib/indexer/embedder.js +325 -0
- package/lib/indexer/sqlite-index.js +431 -0
- package/lib/indexer/tokenize.js +151 -0
- package/lib/locale/zh-CN.json +4 -0
- package/lib/parse/chunk.js +202 -0
- package/lib/parse/parser.js +643 -0
- package/lib/search/search.js +194 -0
- package/lib/service/hub.js +791 -0
- package/lib/store/docs.js +906 -0
- package/lib/store/lease.js +251 -0
- package/lib/store/manifest.js +263 -0
- package/lib/tools/index.js +759 -0
- package/lib/ui/app.js +682 -0
- package/lib/ui/icon.svg +7 -0
- package/lib/ui/index.html +133 -0
- package/lib/ui/style.css +292 -0
- package/lib/web/api.js +225 -0
- package/lib/web/http.js +114 -0
- package/lib/web/server.js +149 -0
- package/package.json +83 -4
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机索引库(SQLite / node:sqlite)。
|
|
3
|
+
*
|
|
4
|
+
* 关键决策:索引**不进共享盘**。
|
|
5
|
+
*
|
|
6
|
+
* 共享盘(SMB / UNC / NAS)上放一个多进程同时写的 SQLite 文件是灾难:
|
|
7
|
+
* SQLite 的 WAL 依赖共享内存映射与 fcntl 锁,SMB 实现普遍不支持或
|
|
8
|
+
* 语义不一致,典型后果是索引静默损坏、无法打开、或整库被标记为
|
|
9
|
+
* malformed 之后每次打开都要重建。
|
|
10
|
+
*
|
|
11
|
+
* 所以:**资料在共享盘(唯一真相),索引在本机**(每台机器一份)。
|
|
12
|
+
* 每台机器按根目录路径哈希出自己的缓存目录:<home>/indexes/<hash>/kb-<name>.sqlite
|
|
13
|
+
* 索引记录它对应的 manifest rev 与各文档 version;启动/检索时只比对
|
|
14
|
+
* 版本号做增量刷新,别人在别的机器上导了资料,我这里第一次检索就把
|
|
15
|
+
* 那几篇补进来,不需要重建全库。
|
|
16
|
+
*
|
|
17
|
+
* 表结构:
|
|
18
|
+
* parts(doc_id, part, title, heading, text, text_indexed, page, chars, tokens, vec)
|
|
19
|
+
* parts_fts ← FTS5 虚表(对 text_indexed 建索引)
|
|
20
|
+
* docs(doc_id, version, title, updated_at, chars)
|
|
21
|
+
* meta(key, value) ← provider 名/维度、schema 版本、构建时间
|
|
22
|
+
* vecs(doc_id, part, v BLOB) ← 向量单独存,避免 FTS 表被大 BLOB 拖慢
|
|
23
|
+
*/
|
|
24
|
+
import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { HubError } from "../core/errors.js";
|
|
27
|
+
import { indexText } from "./tokenize.js";
|
|
28
|
+
|
|
29
|
+
export const INDEX_SCHEMA = 3;
|
|
30
|
+
|
|
31
|
+
let sqliteModulePromise = null;
|
|
32
|
+
|
|
33
|
+
/** 懒加载 node:sqlite;不可用时抛可诊断错误(不静默降级成「没有检索」)。 */
|
|
34
|
+
export async function loadSqlite() {
|
|
35
|
+
if (!sqliteModulePromise) {
|
|
36
|
+
sqliteModulePromise = import("node:sqlite").then(
|
|
37
|
+
(mod) => mod,
|
|
38
|
+
(error) => {
|
|
39
|
+
throw new HubError(
|
|
40
|
+
"SQLITE_UNAVAILABLE",
|
|
41
|
+
`当前 Node 运行时没有 node:sqlite(需要 >=22.5,且需 --experimental-sqlite 或已稳定内置):${String(error?.message ?? error)}`,
|
|
42
|
+
{ status: 500 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return sqliteModulePromise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function indexPathFor(indexRoot, kbName) {
|
|
51
|
+
return join(indexRoot, `kb-${kbName}.sqlite`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 打开(或创建)某个库的本机索引。
|
|
56
|
+
* @param {string} path sqlite 文件路径
|
|
57
|
+
* @param {{provider: string, dim: number}} identity 嵌入身份;与库内记录不一致就整体重建
|
|
58
|
+
*/
|
|
59
|
+
export async function openIndex(path, identity) {
|
|
60
|
+
const { DatabaseSync } = await loadSqlite();
|
|
61
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
62
|
+
let db;
|
|
63
|
+
try {
|
|
64
|
+
db = new DatabaseSync(path);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// 文件损坏:删掉重建,而不是让用户卡在「检索一直报错」
|
|
67
|
+
await rm(path, { force: true }).catch(() => {});
|
|
68
|
+
db = new DatabaseSync(path);
|
|
69
|
+
}
|
|
70
|
+
db.exec("pragma journal_mode=WAL");
|
|
71
|
+
db.exec("pragma synchronous=NORMAL");
|
|
72
|
+
db.exec("pragma busy_timeout=8000");
|
|
73
|
+
db.exec("pragma temp_store=MEMORY");
|
|
74
|
+
|
|
75
|
+
ensureSchema(db);
|
|
76
|
+
|
|
77
|
+
const stored = readMeta(db);
|
|
78
|
+
const mismatch =
|
|
79
|
+
stored.schema !== INDEX_SCHEMA ||
|
|
80
|
+
stored.provider !== identity.provider ||
|
|
81
|
+
Number(stored.dim) !== Number(identity.dim);
|
|
82
|
+
if (mismatch) {
|
|
83
|
+
wipe(db);
|
|
84
|
+
writeMeta(db, {
|
|
85
|
+
schema: INDEX_SCHEMA,
|
|
86
|
+
provider: identity.provider,
|
|
87
|
+
dim: String(identity.dim),
|
|
88
|
+
createdAt: new Date().toISOString(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new IndexHandle(db, identity);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ensureSchema(db) {
|
|
96
|
+
db.exec(`
|
|
97
|
+
create table if not exists meta (key text primary key, value text not null);
|
|
98
|
+
create table if not exists docs (
|
|
99
|
+
doc_id text primary key,
|
|
100
|
+
version integer not null,
|
|
101
|
+
title text not null default '',
|
|
102
|
+
updated_at text not null default '',
|
|
103
|
+
chars integer not null default 0
|
|
104
|
+
);
|
|
105
|
+
create table if not exists parts (
|
|
106
|
+
doc_id text not null,
|
|
107
|
+
part integer not null,
|
|
108
|
+
title text not null default '',
|
|
109
|
+
heading text not null default '',
|
|
110
|
+
text text not null default '',
|
|
111
|
+
page integer,
|
|
112
|
+
chars integer not null default 0,
|
|
113
|
+
tokens integer not null default 0,
|
|
114
|
+
primary key (doc_id, part)
|
|
115
|
+
);
|
|
116
|
+
create table if not exists vecs (
|
|
117
|
+
doc_id text not null,
|
|
118
|
+
part integer not null,
|
|
119
|
+
v blob not null,
|
|
120
|
+
primary key (doc_id, part)
|
|
121
|
+
);
|
|
122
|
+
create virtual table if not exists parts_fts using fts5(
|
|
123
|
+
doc_id UNINDEXED,
|
|
124
|
+
part UNINDEXED,
|
|
125
|
+
body,
|
|
126
|
+
title,
|
|
127
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
128
|
+
);
|
|
129
|
+
create index if not exists parts_doc on parts(doc_id);
|
|
130
|
+
`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function wipe(db) {
|
|
134
|
+
db.exec("delete from parts_fts; delete from parts; delete from vecs; delete from docs; delete from meta;");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readMeta(db) {
|
|
138
|
+
const out = {};
|
|
139
|
+
try {
|
|
140
|
+
for (const row of db.prepare("select key, value from meta").all()) out[row.key] = row.value;
|
|
141
|
+
} catch {
|
|
142
|
+
/* 表可能刚被删 */
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeMeta(db, entries) {
|
|
148
|
+
const stmt = db.prepare("insert into meta(key, value) values(?, ?) on conflict(key) do update set value=excluded.value");
|
|
149
|
+
for (const [key, value] of Object.entries(entries)) stmt.run(key, String(value));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readMetaValue(db, key) {
|
|
153
|
+
try {
|
|
154
|
+
const row = db.prepare("select value from meta where key = ?").get(key);
|
|
155
|
+
return row?.value ?? null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 把 Float32Array 与 BLOB 互转。存原始字节,比 JSON 省 3-4 倍空间。 */
|
|
162
|
+
function vectorToBuffer(vector) {
|
|
163
|
+
return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
164
|
+
}
|
|
165
|
+
function bufferToVector(buffer) {
|
|
166
|
+
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
167
|
+
return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class IndexHandle {
|
|
171
|
+
constructor(db, identity) {
|
|
172
|
+
this.db = db;
|
|
173
|
+
this.identity = identity;
|
|
174
|
+
this.closed = false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 索引里当前有哪些文档、各是哪一版(用于与 manifest 比对增量)。 */
|
|
178
|
+
indexedVersions() {
|
|
179
|
+
const out = new Map();
|
|
180
|
+
for (const row of this.db.prepare("select doc_id, version from docs").all()) {
|
|
181
|
+
out.set(row.doc_id, Number(row.version));
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getStamp() {
|
|
187
|
+
return readMetaValue(this.db, "manifestRev");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 用一批文档替换索引内容。
|
|
192
|
+
* @param {{docId: string, version: number, title: string, updatedAt: string, chars: number, parts: object[], vectors?: Float32Array[]}[]} docs
|
|
193
|
+
* @param {{manifestRev: number}} meta
|
|
194
|
+
*/
|
|
195
|
+
upsertDocs(docs, meta = {}) {
|
|
196
|
+
const delFts = this.db.prepare("delete from parts_fts where doc_id = ?");
|
|
197
|
+
const delParts = this.db.prepare("delete from parts where doc_id = ?");
|
|
198
|
+
const delVecs = this.db.prepare("delete from vecs where doc_id = ?");
|
|
199
|
+
const upsertDoc = this.db.prepare(
|
|
200
|
+
"insert into docs(doc_id, version, title, updated_at, chars) values(?,?,?,?,?) " +
|
|
201
|
+
"on conflict(doc_id) do update set version=excluded.version, title=excluded.title, updated_at=excluded.updated_at, chars=excluded.chars"
|
|
202
|
+
);
|
|
203
|
+
const insPart = this.db.prepare(
|
|
204
|
+
"insert into parts(doc_id, part, title, heading, text, page, chars, tokens) values(?,?,?,?,?,?,?,?)"
|
|
205
|
+
);
|
|
206
|
+
const insFts = this.db.prepare("insert into parts_fts(doc_id, part, body, title) values(?,?,?,?)");
|
|
207
|
+
const insVec = this.db.prepare("insert into vecs(doc_id, part, v) values(?,?,?)");
|
|
208
|
+
|
|
209
|
+
this.db.exec("begin immediate");
|
|
210
|
+
try {
|
|
211
|
+
for (const doc of docs) {
|
|
212
|
+
delFts.run(doc.docId);
|
|
213
|
+
delParts.run(doc.docId);
|
|
214
|
+
delVecs.run(doc.docId);
|
|
215
|
+
upsertDoc.run(doc.docId, Number(doc.version) || 1, String(doc.title ?? ""), String(doc.updatedAt ?? ""), Number(doc.chars) || 0);
|
|
216
|
+
const parts = Array.isArray(doc.parts) ? doc.parts : [];
|
|
217
|
+
for (const part of parts) {
|
|
218
|
+
const index = Number(part.index) || 0;
|
|
219
|
+
insPart.run(
|
|
220
|
+
doc.docId,
|
|
221
|
+
index,
|
|
222
|
+
String(doc.title ?? ""),
|
|
223
|
+
String(part.heading ?? ""),
|
|
224
|
+
String(part.text ?? ""),
|
|
225
|
+
part.page == null ? null : Number(part.page),
|
|
226
|
+
Number(part.chars) || String(part.text ?? "").length,
|
|
227
|
+
Number(part.tokens) || 0
|
|
228
|
+
);
|
|
229
|
+
insFts.run(doc.docId, index, indexText(part.text ?? ""), indexText(`${doc.title ?? ""} ${part.heading ?? ""}`));
|
|
230
|
+
}
|
|
231
|
+
const vectors = Array.isArray(doc.vectors) ? doc.vectors : [];
|
|
232
|
+
for (let i = 0; i < parts.length && i < vectors.length; i += 1) {
|
|
233
|
+
if (vectors[i]) insVec.run(doc.docId, Number(parts[i].index) || i + 1, vectorToBuffer(vectors[i]));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (meta.manifestRev !== undefined) {
|
|
237
|
+
this.db.prepare("insert into meta(key, value) values('manifestRev', ?) on conflict(key) do update set value=excluded.value").run(
|
|
238
|
+
String(meta.manifestRev)
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (meta.builtAt) {
|
|
242
|
+
this.db
|
|
243
|
+
.prepare("insert into meta(key, value) values('builtAt', ?) on conflict(key) do update set value=excluded.value")
|
|
244
|
+
.run(String(meta.builtAt));
|
|
245
|
+
}
|
|
246
|
+
this.db.exec("commit");
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.db.exec("rollback");
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 从索引移除若干文档(软删/彻底删)。 */
|
|
254
|
+
removeDocs(docIds) {
|
|
255
|
+
const delFts = this.db.prepare("delete from parts_fts where doc_id = ?");
|
|
256
|
+
const delParts = this.db.prepare("delete from parts where doc_id = ?");
|
|
257
|
+
const delVecs = this.db.prepare("delete from vecs where doc_id = ?");
|
|
258
|
+
const delDoc = this.db.prepare("delete from docs where doc_id = ?");
|
|
259
|
+
this.db.exec("begin immediate");
|
|
260
|
+
try {
|
|
261
|
+
for (const docId of docIds) {
|
|
262
|
+
delFts.run(docId);
|
|
263
|
+
delParts.run(docId);
|
|
264
|
+
delVecs.run(docId);
|
|
265
|
+
delDoc.run(docId);
|
|
266
|
+
}
|
|
267
|
+
this.db.exec("commit");
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this.db.exec("rollback");
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
docCount() {
|
|
275
|
+
try {
|
|
276
|
+
return Number(this.db.prepare("select count(*) c from docs").get()?.c ?? 0);
|
|
277
|
+
} catch {
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
partCount() {
|
|
283
|
+
try {
|
|
284
|
+
return Number(this.db.prepare("select count(*) c from parts").get()?.c ?? 0);
|
|
285
|
+
} catch {
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 全文检索(BM25)。
|
|
292
|
+
* @param {string} matchExpr FTS5 MATCH 表达式
|
|
293
|
+
* @param {{limit?: number, docIds?: string[]}} options
|
|
294
|
+
*/
|
|
295
|
+
searchFullText(matchExpr, options = {}) {
|
|
296
|
+
const limit = Math.max(1, Math.min(200, Math.round(Number(options.limit) || 40)));
|
|
297
|
+
if (!matchExpr) return [];
|
|
298
|
+
const allow = Array.isArray(options.docIds) && options.docIds.length > 0 ? new Set(options.docIds) : null;
|
|
299
|
+
const sql =
|
|
300
|
+
"select parts_fts.doc_id as doc_id, parts_fts.part as part, bm25(parts_fts) as score, " +
|
|
301
|
+
"parts.text as text, parts.title as title, parts.heading as heading, parts.page as page " +
|
|
302
|
+
"from parts_fts join parts on parts.doc_id = parts_fts.doc_id and parts.part = parts_fts.part " +
|
|
303
|
+
"where parts_fts match ? order by score limit ?";
|
|
304
|
+
let rows;
|
|
305
|
+
try {
|
|
306
|
+
rows = this.db.prepare(sql).all(matchExpr, allow ? limit * 4 : limit);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// 非法 MATCH 表达式(用户输入带引号/特殊字符)不能让检索整体失败
|
|
309
|
+
throw new HubError("QUERY_SYNTAX", `检索表达式无法解析:${String(error?.message ?? error)}`, { status: 400 });
|
|
310
|
+
}
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
if (allow && !allow.has(row.doc_id)) continue;
|
|
314
|
+
out.push({
|
|
315
|
+
docId: String(row.doc_id),
|
|
316
|
+
part: Number(row.part),
|
|
317
|
+
// bm25 越小越相关;取负号变成「越大越相关」,方便与向量分数融合
|
|
318
|
+
score: -Number(row.score),
|
|
319
|
+
text: String(row.text ?? ""),
|
|
320
|
+
title: String(row.title ?? ""),
|
|
321
|
+
heading: String(row.heading ?? ""),
|
|
322
|
+
page: row.page == null ? null : Number(row.page),
|
|
323
|
+
});
|
|
324
|
+
if (out.length >= limit) break;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 向量检索(全表扫描 + 点积)。
|
|
331
|
+
*
|
|
332
|
+
* 为什么不用 sqlite-vec:额外原生扩展会带来安装与跨平台负担,
|
|
333
|
+
* 而知识库规模(几千到几万块)下,Float32 全扫一遍在内存里是毫秒级。
|
|
334
|
+
* 超过阈值(默认 20 万块)时上层会提示「建议开启分库」而不是硬扛。
|
|
335
|
+
*/
|
|
336
|
+
searchVector(queryVector, options = {}) {
|
|
337
|
+
const limit = Math.max(1, Math.min(200, Math.round(Number(options.limit) || 40)));
|
|
338
|
+
const allow = Array.isArray(options.docIds) && options.docIds.length > 0 ? new Set(options.docIds) : null;
|
|
339
|
+
const rows = this.db.prepare("select v.doc_id as doc_id, v.part as part, v.v as v from vecs v").all();
|
|
340
|
+
const scored = [];
|
|
341
|
+
const q = queryVector;
|
|
342
|
+
for (const row of rows) {
|
|
343
|
+
if (allow && !allow.has(row.doc_id)) continue;
|
|
344
|
+
const vector = bufferToVector(row.v);
|
|
345
|
+
if (vector.length !== q.length) continue;
|
|
346
|
+
let sum = 0;
|
|
347
|
+
for (let i = 0; i < vector.length; i += 1) sum += vector[i] * q[i];
|
|
348
|
+
scored.push({ docId: String(row.doc_id), part: Number(row.part), score: sum });
|
|
349
|
+
}
|
|
350
|
+
scored.sort((a, b) => b.score - a.score);
|
|
351
|
+
const top = scored.slice(0, limit);
|
|
352
|
+
const detail = new Map();
|
|
353
|
+
if (top.length > 0) {
|
|
354
|
+
const stmt = this.db.prepare(
|
|
355
|
+
"select text, title, heading, page from parts where doc_id = ? and part = ?"
|
|
356
|
+
);
|
|
357
|
+
for (const hit of top) {
|
|
358
|
+
const row = stmt.get(hit.docId, hit.part);
|
|
359
|
+
if (row) {
|
|
360
|
+
detail.set(`${hit.docId}#${hit.part}`, {
|
|
361
|
+
text: String(row.text ?? ""),
|
|
362
|
+
title: String(row.title ?? ""),
|
|
363
|
+
heading: String(row.heading ?? ""),
|
|
364
|
+
page: row.page == null ? null : Number(row.page),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return top.map((hit) => ({
|
|
370
|
+
...hit,
|
|
371
|
+
...(detail.get(`${hit.docId}#${hit.part}`) ?? { text: "", title: "", heading: "", page: null }),
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 取某篇文档的全文块(供精读)。 */
|
|
376
|
+
readParts(docId, { from = 1, to = Infinity } = {}) {
|
|
377
|
+
const rows = this.db
|
|
378
|
+
.prepare("select part, text, heading, page, chars from parts where doc_id = ? and part >= ? and part <= ? order by part")
|
|
379
|
+
.all(docId, from, to === Infinity ? 1e9 : to);
|
|
380
|
+
return rows.map((row) => ({
|
|
381
|
+
index: Number(row.part),
|
|
382
|
+
text: String(row.text ?? ""),
|
|
383
|
+
heading: String(row.heading ?? ""),
|
|
384
|
+
page: row.page == null ? null : Number(row.page),
|
|
385
|
+
chars: Number(row.chars) || 0,
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
close() {
|
|
390
|
+
if (this.closed) return;
|
|
391
|
+
this.closed = true;
|
|
392
|
+
try {
|
|
393
|
+
this.db.close();
|
|
394
|
+
} catch {
|
|
395
|
+
/* 已关闭 */
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 索引健康信息(给状态页与 kb_index_status 用)。 */
|
|
401
|
+
export async function indexFileInfo(path) {
|
|
402
|
+
try {
|
|
403
|
+
const s = await stat(path);
|
|
404
|
+
return { exists: true, size: s.size, mtime: new Date(s.mtimeMs).toISOString() };
|
|
405
|
+
} catch {
|
|
406
|
+
return { exists: false, size: 0, mtime: null };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 索引文件迁移辅助:把一个索引文件挪到备份名(重建前留底)。 */
|
|
411
|
+
export async function moveIndexAside(path) {
|
|
412
|
+
const backup = `${path}.bak-${Date.now()}`;
|
|
413
|
+
try {
|
|
414
|
+
await rename(path, backup);
|
|
415
|
+
return backup;
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** 从磁盘读一个 .f32 文件(兼容旧格式,供将来导入用)。 */
|
|
422
|
+
export async function readVectorFile(path, dim) {
|
|
423
|
+
const buffer = await readFile(path);
|
|
424
|
+
const count = Math.floor(buffer.length / (dim * 4));
|
|
425
|
+
const out = [];
|
|
426
|
+
for (let i = 0; i < count; i += 1) {
|
|
427
|
+
const offset = i * dim * 4;
|
|
428
|
+
out.push(new Float32Array(buffer.buffer, buffer.byteOffset + offset, dim));
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中文全文索引的分词策略。
|
|
3
|
+
*
|
|
4
|
+
* SQLite FTS5 的 unicode61 分词器不认识中文:它会把整段连续汉字当成一个词,
|
|
5
|
+
* 于是「知识库检索」在索引里是一个 token,用户搜「知识库」命中不了。
|
|
6
|
+
*
|
|
7
|
+
* 两条路:
|
|
8
|
+
* a) 用 trigram tokenizer(FTS5 内置):任意 3 字符子串都能命中,中文效果好,
|
|
9
|
+
* 但索引体积约为原文 3-4 倍,且不支持前缀/短语查询的良好语义;
|
|
10
|
+
* b) 自己预分词后存进普通 unicode61 列:索引小、可控、能做精确短语匹配。
|
|
11
|
+
*
|
|
12
|
+
* 这里选 b,理由:知识库常有标准号、型号、条款号这类必须精确匹配的内容,
|
|
13
|
+
* trigram 会把 "GB/T 19963.2-2024" 切得像噪音;预分词可以同时保留
|
|
14
|
+
* 中文二元组 + 英文词 + 数字串,并且让「标准号不同写法」有统一入口。
|
|
15
|
+
*
|
|
16
|
+
* 分词规则(输出用空格连接的 token 串,供 FTS5 索引):
|
|
17
|
+
* 1. Intl.Segmenter 中文分词(Node 内置,无需第三方词典)
|
|
18
|
+
* 2. 汉字连续段额外产出二元组(bigram):解决分词器把「知识库」切成
|
|
19
|
+
* 「知识」「库」后搜「识库」命不中的问题
|
|
20
|
+
* 3. 英文/数字:整词 + 去分隔符压紧形式(GB/T → gbt)
|
|
21
|
+
* 4. 数字串数字面量(含小数、区间),保证「19963.2」可查
|
|
22
|
+
*
|
|
23
|
+
* 检索侧复用同一函数:索引与查询用同一套分词,才不会「索引里有、查不到」。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
let segmenter = null;
|
|
27
|
+
function getSegmenter() {
|
|
28
|
+
if (segmenter === null) {
|
|
29
|
+
try {
|
|
30
|
+
segmenter = new Intl.Segmenter("zh-Hans", { granularity: "word" });
|
|
31
|
+
} catch {
|
|
32
|
+
segmenter = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return segmenter || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
|
39
|
+
const CJK_RUN = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g;
|
|
40
|
+
|
|
41
|
+
/** 归一化:全角转半角、大小写统一、去零宽字符。 */
|
|
42
|
+
export function normalizeForIndex(input) {
|
|
43
|
+
return String(input ?? "")
|
|
44
|
+
.normalize("NFKC")
|
|
45
|
+
.replace(/[\u200b-\u200f\u2060\ufeff]/g, "")
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把文本切成用于索引/查询的 token 列表。
|
|
51
|
+
* @param {string} input
|
|
52
|
+
* @param {{bigram?: boolean}} [options] bigram 默认 true(索引侧开、查询侧也开)
|
|
53
|
+
*/
|
|
54
|
+
export function tokenize(input, options = {}) {
|
|
55
|
+
const text = normalizeForIndex(input);
|
|
56
|
+
const tokens = new Set();
|
|
57
|
+
if (text.trim() === "") return [];
|
|
58
|
+
|
|
59
|
+
const bigram = options.bigram !== false;
|
|
60
|
+
|
|
61
|
+
// 1. Segmenter 词级
|
|
62
|
+
const seg = getSegmenter();
|
|
63
|
+
if (seg) {
|
|
64
|
+
for (const item of seg.segment(text)) {
|
|
65
|
+
const word = String(item.segment ?? "").trim();
|
|
66
|
+
if (word === "") continue;
|
|
67
|
+
if (word.length === 1 && !CJK.test(word)) continue;
|
|
68
|
+
if (/^[\p{L}\p{N}]+$/u.test(word)) tokens.add(word);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. 汉字二元组
|
|
73
|
+
if (bigram) {
|
|
74
|
+
for (const run of text.match(CJK_RUN) ?? []) {
|
|
75
|
+
if (run.length === 1) {
|
|
76
|
+
tokens.add(run);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (let i = 0; i + 1 < run.length; i += 1) tokens.add(run.slice(i, i + 2));
|
|
80
|
+
// 三字以上再补首尾二字组合,覆盖「检索增强」类复合词
|
|
81
|
+
if (run.length >= 4) {
|
|
82
|
+
tokens.add(run.slice(0, 2));
|
|
83
|
+
tokens.add(run.slice(-2));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. 英文词 / 数字 / 型号 / 标准号
|
|
89
|
+
for (const word of text.match(/[a-z][a-z0-9_+#.-]{0,40}/g) ?? []) {
|
|
90
|
+
if (word.length < 2) continue;
|
|
91
|
+
tokens.add(word);
|
|
92
|
+
const compact = word.replace(/[^a-z0-9]/g, "");
|
|
93
|
+
if (compact.length >= 2 && compact !== word) tokens.add(compact);
|
|
94
|
+
}
|
|
95
|
+
for (const num of text.match(/\d+(?:[.,]\d+)*(?:[-–~]\d+(?:[.,]\d+)*)?/g) ?? []) {
|
|
96
|
+
tokens.add(num);
|
|
97
|
+
const stripped = num.replace(/[.,]$/, "");
|
|
98
|
+
if (stripped !== num) tokens.add(stripped);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return [...tokens];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 索引侧:token 串(空格分隔),直接存进 FTS5 的文本列。 */
|
|
105
|
+
export function indexText(input) {
|
|
106
|
+
return tokenize(input).join(" ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 查询侧:把用户输入转成 FTS5 MATCH 表达式。
|
|
111
|
+
*
|
|
112
|
+
* 逐 token 用 OR 连接,整串再用引号包住避免语法注入;
|
|
113
|
+
* 同时给你「短语优先」的变体(相邻 token 用 AND),供上层做二级排序。
|
|
114
|
+
*/
|
|
115
|
+
export function buildMatchQuery(input, { mode = "or" } = {}) {
|
|
116
|
+
const tokens = tokenize(input);
|
|
117
|
+
if (tokens.length === 0) return null;
|
|
118
|
+
const quoted = tokens.map((token) => `"${token.replace(/"/g, '""')}"`);
|
|
119
|
+
if (mode === "and") return quoted.join(" AND ");
|
|
120
|
+
if (mode === "phrase") return quoted.join(" ");
|
|
121
|
+
return quoted.join(" OR ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 判断查询是否只含「无意义 token」(如纯标点),避免生成空 MATCH 报错。
|
|
126
|
+
*/
|
|
127
|
+
export function hasQueryTokens(input) {
|
|
128
|
+
return tokenize(input).length > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 把原文里命中的 token 高亮出来(返回片段),用于检索结果展示。
|
|
133
|
+
* 不用 FTS5 的 snippet(),因为索引列存的是分词后的串,不是原文。
|
|
134
|
+
*/
|
|
135
|
+
export function makeSnippet(text, query, { before = 40, after = 60, maxLen = 220 } = {}) {
|
|
136
|
+
const source = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
137
|
+
if (source === "") return "";
|
|
138
|
+
const tokens = tokenize(query, { bigram: false });
|
|
139
|
+
const lower = normalizeForIndex(source);
|
|
140
|
+
let hit = -1;
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
const at = lower.indexOf(token);
|
|
143
|
+
if (at >= 0 && (hit < 0 || at < hit)) hit = at;
|
|
144
|
+
}
|
|
145
|
+
if (hit < 0) return source.length > maxLen ? source.slice(0, maxLen) + "…" : source;
|
|
146
|
+
const start = Math.max(0, hit - before);
|
|
147
|
+
const end = Math.min(source.length, hit + after);
|
|
148
|
+
const prefix = start > 0 ? "…" : "";
|
|
149
|
+
const suffix = end < source.length ? "…" : "";
|
|
150
|
+
return prefix + source.slice(start, end) + suffix;
|
|
151
|
+
}
|