@zenera/rag 1.1.9 → 1.1.11
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 +195 -10
- package/dist/command.js +2 -1
- package/dist/common/cache.d.ts +66 -0
- package/dist/common/cache.js +172 -0
- package/dist/common/embedder.d.ts +11 -1
- package/dist/common/embedder.js +6 -17
- package/dist/common/manifest.d.ts +7 -1
- package/dist/common/progress.d.ts +16 -1
- package/dist/common/progress.js +35 -3
- package/dist/common/prose.d.ts +7 -0
- package/dist/common/prose.js +13 -0
- package/dist/docs/assemble.d.ts +52 -0
- package/dist/docs/assemble.js +127 -0
- package/dist/docs/build.d.ts +54 -0
- package/dist/docs/build.js +134 -0
- package/dist/docs/chunk.d.ts +73 -0
- package/dist/docs/chunk.js +586 -0
- package/dist/docs/command.d.ts +3 -0
- package/dist/docs/command.js +575 -0
- package/dist/docs/files.d.ts +94 -0
- package/dist/docs/files.js +80 -0
- package/dist/docs/index.d.ts +13 -0
- package/dist/docs/index.js +13 -0
- package/dist/docs/load.d.ts +42 -0
- package/dist/docs/load.js +203 -0
- package/dist/docs/lookup.d.ts +80 -0
- package/dist/docs/lookup.js +147 -0
- package/dist/docs/outline.d.ts +11 -0
- package/dist/docs/outline.js +55 -0
- package/dist/docs/parse-cache.d.ts +20 -0
- package/dist/docs/parse-cache.js +60 -0
- package/dist/docs/parse-worker.d.ts +13 -0
- package/dist/docs/parse-worker.js +21 -0
- package/dist/docs/parse.d.ts +95 -0
- package/dist/docs/parse.js +372 -0
- package/dist/docs/pool.d.ts +27 -0
- package/dist/docs/pool.js +133 -0
- package/dist/docs/readme.d.ts +6 -0
- package/dist/docs/readme.js +142 -0
- package/dist/docs/render.d.ts +13 -0
- package/dist/docs/render.js +46 -0
- package/dist/docs/repl.d.ts +7 -0
- package/dist/docs/repl.js +130 -0
- package/dist/docs/search.d.ts +92 -0
- package/dist/docs/search.js +251 -0
- package/dist/docs/store.d.ts +79 -0
- package/dist/docs/store.js +214 -0
- package/dist/docs/tools.d.ts +10 -0
- package/dist/docs/tools.js +300 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/schema/build.d.ts +16 -2
- package/dist/schema/build.js +37 -25
- package/dist/schema/command.js +39 -10
- package/dist/schema/query.js +1 -0
- package/dist/schema/readme.js +6 -2
- package/dist/schema/search.d.ts +2 -0
- package/dist/schema/search.js +18 -2
- package/dist/schema/store.d.ts +13 -3
- package/dist/schema/store.js +73 -24
- package/dist/schema/tools.js +21 -2
- package/package.json +17 -4
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { assertSameEmbedding } from "../common/manifest.js";
|
|
2
|
+
import { loose, PatternError } from "../common/match.js";
|
|
3
|
+
import { CHUNK_KINDS, parseLines } from "./chunk.js";
|
|
4
|
+
import { openIndex, readLines, } from "./files.js";
|
|
5
|
+
import { ChunkStore } from "./store.js";
|
|
6
|
+
export const SEARCH_MODES = ['hybrid', 'vector', 'text'];
|
|
7
|
+
export const DEFAULT_LIMIT = 8;
|
|
8
|
+
/** Reciprocal rank fusion; the constant is the usual one and damps the top. */
|
|
9
|
+
const RRF_K = 60;
|
|
10
|
+
/** How much to ask each leg for, so fusion and the caps have room to work. */
|
|
11
|
+
const OVERFETCH = 6;
|
|
12
|
+
/**
|
|
13
|
+
* And an absolute ceiling on it. The multiple exists to survive the JavaScript
|
|
14
|
+
* filter and the diversifier, which is a fixed cost, not one that grows with
|
|
15
|
+
* the limit — at `--limit 32` the multiple alone asked for 192 rows, which on
|
|
16
|
+
* a small corpus is most of it. Every row fetched votes in the fusion at full
|
|
17
|
+
* strength, so an over-deep leg does not add recall, it adds an electorate.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_FETCH = 96;
|
|
20
|
+
/**
|
|
21
|
+
* Floors for the per-section and per-document caps, and the share of the limit
|
|
22
|
+
* they grow by once it is large enough to matter.
|
|
23
|
+
*
|
|
24
|
+
* The caps were constants, which made them a diversity nudge at the default
|
|
25
|
+
* limit of eight and a straitjacket above it: `--limit 20` for a question whose
|
|
26
|
+
* answer is one section of one file returned five chunks of the answer and
|
|
27
|
+
* fifteen of whatever else scored, because the sixth chunk of the right file
|
|
28
|
+
* was evicted in favour of the first chunk of a worse one. Asking for more
|
|
29
|
+
* results is asking to go deeper, and depth on a documented topic means more
|
|
30
|
+
* of the document that covers it.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_PER_SECTION = 3;
|
|
33
|
+
const MAX_PER_FILE = 5;
|
|
34
|
+
const SECTION_SHARE = 0.25;
|
|
35
|
+
const FILE_SHARE = 0.5;
|
|
36
|
+
export class DocsIndex {
|
|
37
|
+
manifest;
|
|
38
|
+
outline;
|
|
39
|
+
#index;
|
|
40
|
+
#store;
|
|
41
|
+
#embedder;
|
|
42
|
+
constructor(index, store, embedder) {
|
|
43
|
+
this.manifest = index.manifest;
|
|
44
|
+
this.outline = index.outline;
|
|
45
|
+
this.#index = index;
|
|
46
|
+
this.#store = store;
|
|
47
|
+
this.#embedder = embedder;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* An embedder is optional because half of what this index is for needs no
|
|
51
|
+
* model at all: listing, grepping and reading are exact, and asking for a
|
|
52
|
+
* credential to do them would be asking for nothing.
|
|
53
|
+
*/
|
|
54
|
+
static async open(dir, embedder) {
|
|
55
|
+
const index = await openIndex(dir);
|
|
56
|
+
if (embedder) {
|
|
57
|
+
assertSameEmbedding(index.manifest, embedder.id);
|
|
58
|
+
}
|
|
59
|
+
return new DocsIndex(index, await ChunkStore.open(dir), embedder);
|
|
60
|
+
}
|
|
61
|
+
/** A document, split the way every line number in this index counts it. */
|
|
62
|
+
lines(name) {
|
|
63
|
+
return readLines(this.#index, name);
|
|
64
|
+
}
|
|
65
|
+
file(name) {
|
|
66
|
+
return this.outline.files.find((f) => f.name === name);
|
|
67
|
+
}
|
|
68
|
+
/** Document names matching the patterns, or all of them when there are none. */
|
|
69
|
+
resolveFiles(patterns = [], exclude = []) {
|
|
70
|
+
const names = this.manifest.sources.map((s) => s.name);
|
|
71
|
+
const keep = patterns.length === 0 ? names : names.filter(anyMatch(patterns));
|
|
72
|
+
return exclude.length === 0 ? keep : keep.filter((name) => !anyMatch(exclude)(name));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Headings the terms name, over the documents still in play. A term is tried
|
|
76
|
+
* as a structure id, then a structure path, then as a pattern over the
|
|
77
|
+
* title — so `--section overview` and `--section sec:3` are the same
|
|
78
|
+
* argument, and neither has to be explained.
|
|
79
|
+
*/
|
|
80
|
+
resolveSections(terms, files) {
|
|
81
|
+
if (terms.length === 0) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const within = new Set(files);
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const file of this.outline.files) {
|
|
87
|
+
if (!within.has(file.name)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const heading of file.headings) {
|
|
91
|
+
const hit = terms.some((term) => term === heading.id ||
|
|
92
|
+
term === heading.path ||
|
|
93
|
+
anyMatch([term])(heading.title));
|
|
94
|
+
if (hit) {
|
|
95
|
+
out.push(heading);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
close() {
|
|
102
|
+
this.#store.close();
|
|
103
|
+
}
|
|
104
|
+
async search(query, signal) {
|
|
105
|
+
const mode = query.mode ?? 'hybrid';
|
|
106
|
+
const text = (query.query ?? '').trim();
|
|
107
|
+
const limit = Math.max(1, query.limit ?? DEFAULT_LIMIT);
|
|
108
|
+
const files = this.resolveFiles(query.files, query.exclude_files);
|
|
109
|
+
const headings = this.resolveSections(query.section ?? [], files);
|
|
110
|
+
const sections = headings.map((h) => h.path);
|
|
111
|
+
// Two ways to ask for nothing: a pattern no document answers to, and a
|
|
112
|
+
// section no document has. Both are told, not silently widened.
|
|
113
|
+
const impossible = files.length === 0 || ((query.section?.length ?? 0) > 0 && sections.length === 0);
|
|
114
|
+
if (impossible || !text) {
|
|
115
|
+
return { matches: [], mode, files, sections, considered: 0 };
|
|
116
|
+
}
|
|
117
|
+
const filter = this.#filter(query, files, sections);
|
|
118
|
+
const excluded = new Set(query.exclude_ids ?? []);
|
|
119
|
+
const fetch = Math.min(limit * OVERFETCH, MAX_FETCH) + excluded.size;
|
|
120
|
+
const vector = mode === 'text' ? [] : await this.#nearest(text, filter, fetch, signal);
|
|
121
|
+
const lexical = mode === 'vector' ? [] : await this.#store.matching(text, filter, fetch);
|
|
122
|
+
const fused = fuse(vector, lexical).filter((match) => !excluded.has(match.id) &&
|
|
123
|
+
files.includes(match.path) &&
|
|
124
|
+
under(match.structurePath, sections));
|
|
125
|
+
return {
|
|
126
|
+
matches: diversify(fused, limit),
|
|
127
|
+
mode,
|
|
128
|
+
files,
|
|
129
|
+
sections,
|
|
130
|
+
considered: fused.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async #nearest(text, filter, limit, signal) {
|
|
134
|
+
if (!this.#embedder) {
|
|
135
|
+
throw new Error('this index was opened without an embedder, so it cannot be searched');
|
|
136
|
+
}
|
|
137
|
+
const response = await this.#embedder.embed({
|
|
138
|
+
input: [text],
|
|
139
|
+
taskType: 'query',
|
|
140
|
+
signal,
|
|
141
|
+
});
|
|
142
|
+
return await this.#store.nearest(Float32Array.from(response.vectors[0]), filter, limit);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The SQL side of the narrowing. A short, safe file list becomes a
|
|
146
|
+
* predicate; a long or awkward one is left to the JavaScript filter that
|
|
147
|
+
* runs afterwards regardless, since that is what makes the answer correct.
|
|
148
|
+
*/
|
|
149
|
+
#filter(query, files, sections) {
|
|
150
|
+
const all = this.manifest.sources.length;
|
|
151
|
+
return {
|
|
152
|
+
kinds: kindsOf(query.kinds),
|
|
153
|
+
paths: files.length < all && files.length <= 64 ? files : undefined,
|
|
154
|
+
prefixes: sections.length > 0 && sections.length <= 64 ? sections : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
const anyMatch = (patterns) => {
|
|
160
|
+
const matchers = patterns.map((pattern) => loose(pattern));
|
|
161
|
+
return (value) => matchers.some((match) => match(value));
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Inside one of these sections, or anywhere when none were asked for. The
|
|
165
|
+
* boundary is a path separator and not a prefix, because `doc/sec:1` is a
|
|
166
|
+
* prefix of `doc/sec:10` and they are different sections.
|
|
167
|
+
*/
|
|
168
|
+
export const under = (path, sections) => sections.length === 0 ||
|
|
169
|
+
sections.some((section) => path === section || path.startsWith(`${section}/`));
|
|
170
|
+
function kindsOf(kinds) {
|
|
171
|
+
if (!kinds || kinds.length === 0) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
for (const kind of kinds) {
|
|
175
|
+
if (!CHUNK_KINDS.includes(kind)) {
|
|
176
|
+
throw new PatternError(`${kind} is not a kind of block: it is one of ${CHUNK_KINDS.join(', ')}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...kinds];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Reciprocal rank fusion over the chunk id. Fusing on the id rather than on a
|
|
183
|
+
* physical row means the ordering survives a compaction of the store, and it is
|
|
184
|
+
* also the only key the two legs are guaranteed to agree on.
|
|
185
|
+
*/
|
|
186
|
+
function fuse(vector, lexical) {
|
|
187
|
+
const merged = new Map();
|
|
188
|
+
const add = (hits, leg) => {
|
|
189
|
+
for (const hit of hits) {
|
|
190
|
+
const existing = merged.get(hit.record.id) ?? match(hit);
|
|
191
|
+
existing.score += 1 / (RRF_K + hit.rank);
|
|
192
|
+
existing.ranks[leg] = hit.rank;
|
|
193
|
+
existing.relevance[leg] = hit.relevance;
|
|
194
|
+
merged.set(hit.record.id, existing);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
add(vector, 'vector');
|
|
198
|
+
add(lexical, 'text');
|
|
199
|
+
return [...merged.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
200
|
+
}
|
|
201
|
+
function match(hit) {
|
|
202
|
+
const { record } = hit;
|
|
203
|
+
return {
|
|
204
|
+
id: record.id,
|
|
205
|
+
path: record.path,
|
|
206
|
+
kind: record.kind,
|
|
207
|
+
headings: record.headings,
|
|
208
|
+
structureId: record.structureId,
|
|
209
|
+
structurePath: record.structurePath,
|
|
210
|
+
lineNumbers: parseLines(record.lineSpec),
|
|
211
|
+
bodyStart: record.bodyStart,
|
|
212
|
+
bodyEnd: record.bodyEnd,
|
|
213
|
+
text: record.text,
|
|
214
|
+
score: 0,
|
|
215
|
+
ranks: {},
|
|
216
|
+
relevance: {},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Caps per section and per document, applied in score order. A question whose
|
|
221
|
+
* answer really is one section still gets it — the cap only stops a run of
|
|
222
|
+
* near-identical neighbours from crowding out the second place a thing is said.
|
|
223
|
+
*/
|
|
224
|
+
function diversify(matches, limit) {
|
|
225
|
+
const sectionCap = Math.max(MAX_PER_SECTION, Math.ceil(limit * SECTION_SHARE));
|
|
226
|
+
const fileCap = Math.max(MAX_PER_FILE, Math.ceil(limit * FILE_SHARE));
|
|
227
|
+
const perSection = new Map();
|
|
228
|
+
const perFile = new Map();
|
|
229
|
+
const kept = [];
|
|
230
|
+
const held = [];
|
|
231
|
+
for (const item of matches) {
|
|
232
|
+
const section = `${item.path}\u0000${parentOf(item.structurePath)}`;
|
|
233
|
+
const sections = perSection.get(section) ?? 0;
|
|
234
|
+
const files = perFile.get(item.path) ?? 0;
|
|
235
|
+
if (sections >= sectionCap || files >= fileCap) {
|
|
236
|
+
held.push(item);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
perSection.set(section, sections + 1);
|
|
240
|
+
perFile.set(item.path, files + 1);
|
|
241
|
+
kept.push(item);
|
|
242
|
+
if (kept.length === limit) {
|
|
243
|
+
return kept;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Better a crowded answer than a short one: what the caps held back fills
|
|
247
|
+
// the rest, still in score order.
|
|
248
|
+
return [...kept, ...held].slice(0, limit);
|
|
249
|
+
}
|
|
250
|
+
const parentOf = (path) => path.slice(0, path.lastIndexOf('/')) || path;
|
|
251
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type Connection, type Table } from '@lancedb/lancedb';
|
|
2
|
+
/**
|
|
3
|
+
* Rows per write.
|
|
4
|
+
*
|
|
5
|
+
* Arrow addresses a batch's buffers with 32-bit offsets, so one batch carrying
|
|
6
|
+
* more than 2 GiB does not error — it panics inside the reader, on a thread
|
|
7
|
+
* whose panic never reaches this one. At 3072 dimensions the vector column
|
|
8
|
+
* alone crosses that at about 175k rows, which a documentation tree reaches.
|
|
9
|
+
*/
|
|
10
|
+
export declare const WRITE_BATCH = 8192;
|
|
11
|
+
export interface ChunkRecord {
|
|
12
|
+
/** `${path}#c${ordinal}` — the fusion key, and stable across compaction */
|
|
13
|
+
id: string;
|
|
14
|
+
/** the document's name in the index: its relative path */
|
|
15
|
+
path: string;
|
|
16
|
+
ordinal: number;
|
|
17
|
+
kind: string;
|
|
18
|
+
/** the full-text document — wider */
|
|
19
|
+
text: string;
|
|
20
|
+
/** what the vector was made from — tighter */
|
|
21
|
+
embedText: string;
|
|
22
|
+
/** the render set, run-length encoded: `1,5,12-40` */
|
|
23
|
+
lineSpec: string;
|
|
24
|
+
bodyStart: number;
|
|
25
|
+
bodyEnd: number;
|
|
26
|
+
structureId: string;
|
|
27
|
+
structurePath: string;
|
|
28
|
+
headings: string;
|
|
29
|
+
tokens: number;
|
|
30
|
+
}
|
|
31
|
+
export interface StoreFilter {
|
|
32
|
+
kinds?: readonly string[];
|
|
33
|
+
/** document names, exactly as the manifest spells them */
|
|
34
|
+
paths?: readonly string[];
|
|
35
|
+
/** structure path prefixes, as `structurePath LIKE 'x%'` */
|
|
36
|
+
prefixes?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export interface Hit {
|
|
39
|
+
record: ChunkRecord;
|
|
40
|
+
/** 0-based position in this leg's own result list */
|
|
41
|
+
rank: number;
|
|
42
|
+
relevance: number;
|
|
43
|
+
}
|
|
44
|
+
export interface WriteResult {
|
|
45
|
+
rows: number;
|
|
46
|
+
fts: boolean;
|
|
47
|
+
vector: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A table built a window at a time.
|
|
51
|
+
*
|
|
52
|
+
* The corpus used to be embedded into one array and handed over in one call,
|
|
53
|
+
* which made peak memory a function of corpus size and, past 2 GiB of vectors,
|
|
54
|
+
* a panic. Rows go in as they are paid for instead, so what is resident is one
|
|
55
|
+
* window rather than all of it.
|
|
56
|
+
*/
|
|
57
|
+
export interface ChunkWriter {
|
|
58
|
+
add(rows: readonly ChunkRecord[], vectors: readonly Float32Array[]): Promise<void>;
|
|
59
|
+
/** Builds the indexes and closes. Throws if nothing was ever added. */
|
|
60
|
+
finish(): Promise<WriteResult>;
|
|
61
|
+
/** For a build that failed, so the connection does not outlive it. */
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
64
|
+
export declare function openChunks(dir: string): Promise<ChunkWriter>;
|
|
65
|
+
export declare class ChunkStore {
|
|
66
|
+
#private;
|
|
67
|
+
constructor(db: Connection, table: Table);
|
|
68
|
+
static open(dir: string): Promise<ChunkStore>;
|
|
69
|
+
/** Nearest neighbours. Every row has a vector, so nothing has to be excluded. */
|
|
70
|
+
nearest(vector: Float32Array, filter: StoreFilter, limit: number): Promise<Hit[]>;
|
|
71
|
+
/**
|
|
72
|
+
* The lexical leg. A missing full-text index, or a query the tokenizer
|
|
73
|
+
* makes nothing of, answers with nothing rather than throwing: a hybrid
|
|
74
|
+
* search that loses one leg is a worse search, not a failed one.
|
|
75
|
+
*/
|
|
76
|
+
matching(text: string, filter: StoreFilter, limit: number): Promise<Hit[]>;
|
|
77
|
+
close(): void;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { connect, Index } from '@lancedb/lancedb';
|
|
2
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
3
|
+
import { CHUNK_KINDS } from "./chunk.js";
|
|
4
|
+
import { lancePath } from "./files.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// The chunk table
|
|
7
|
+
//
|
|
8
|
+
// One row per chunk, carrying both retrieval texts: `text` is what the
|
|
9
|
+
// full-text index reads and `embedText` is what the vector was made from. They
|
|
10
|
+
// are different on purpose and are kept side by side so an index can be
|
|
11
|
+
// re-embedded without re-reading the documents.
|
|
12
|
+
//
|
|
13
|
+
// The two legs are run separately rather than through the built-in hybrid
|
|
14
|
+
// query, and fused in `search.ts`. Three reasons, none of them cosmetic: the
|
|
15
|
+
// built-in fuses on a physical row id, which moves when the table is compacted,
|
|
16
|
+
// where a chunk id does not; `mode` already has to offer a vector-only and a
|
|
17
|
+
// text-only path, so making hybrid the same shape as those two is one mechanism
|
|
18
|
+
// instead of three; and when there is no full-text index, or the query is
|
|
19
|
+
// nonsense to it, degrading has to be a decision rather than an exception.
|
|
20
|
+
//
|
|
21
|
+
// Only closed vocabularies reach the SQL predicate. `kind` is one of seven
|
|
22
|
+
// words. A document name is compared against a safe character set first and, if
|
|
23
|
+
// it does not pass, is simply not put in the predicate at all — the JavaScript
|
|
24
|
+
// filter that runs afterwards is what makes the answer correct, so the clause is
|
|
25
|
+
// only ever an optimisation and there is nothing to escape.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const TABLE = 'chunks';
|
|
28
|
+
/** Below this an IVF index has nothing to train on, and a flat scan is faster. */
|
|
29
|
+
const VECTOR_INDEX_MIN_ROWS = 2000;
|
|
30
|
+
/**
|
|
31
|
+
* Rows per write.
|
|
32
|
+
*
|
|
33
|
+
* Arrow addresses a batch's buffers with 32-bit offsets, so one batch carrying
|
|
34
|
+
* more than 2 GiB does not error — it panics inside the reader, on a thread
|
|
35
|
+
* whose panic never reaches this one. At 3072 dimensions the vector column
|
|
36
|
+
* alone crosses that at about 175k rows, which a documentation tree reaches.
|
|
37
|
+
*/
|
|
38
|
+
export const WRITE_BATCH = 8192;
|
|
39
|
+
const KINDS = new Set(CHUNK_KINDS);
|
|
40
|
+
/** What may go into a string literal in a predicate, and nothing else. */
|
|
41
|
+
const SAFE = /^[\w.:/ -]+$/;
|
|
42
|
+
export async function openChunks(dir) {
|
|
43
|
+
return new Writer(await connect(lancePath(dir)));
|
|
44
|
+
}
|
|
45
|
+
class Writer {
|
|
46
|
+
#db;
|
|
47
|
+
#table;
|
|
48
|
+
#rows = 0;
|
|
49
|
+
constructor(db) {
|
|
50
|
+
this.#db = db;
|
|
51
|
+
}
|
|
52
|
+
async add(rows, vectors) {
|
|
53
|
+
for (let from = 0; from < rows.length; from += WRITE_BATCH) {
|
|
54
|
+
const batch = rows.slice(from, from + WRITE_BATCH).map((row, i) => ({
|
|
55
|
+
...row,
|
|
56
|
+
vector: vectors[from + i],
|
|
57
|
+
}));
|
|
58
|
+
if (this.#table) {
|
|
59
|
+
await this.#table.add(batch);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Every column is always populated — never null — so the Arrow
|
|
63
|
+
// schema is inferred from the first row without a declaration.
|
|
64
|
+
this.#table = await this.#db.createTable(TABLE, batch, { mode: 'overwrite' });
|
|
65
|
+
}
|
|
66
|
+
this.#rows += batch.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async finish() {
|
|
70
|
+
const table = this.#table;
|
|
71
|
+
if (!table) {
|
|
72
|
+
throw new CliError('the documents hold nothing to index', EXIT.invalid, 'they are empty, or every one of them is blank');
|
|
73
|
+
}
|
|
74
|
+
// A writer that panicked did not reject, so a short table is the only
|
|
75
|
+
// evidence left that the rows never landed.
|
|
76
|
+
const stored = await table.countRows();
|
|
77
|
+
if (stored !== this.#rows) {
|
|
78
|
+
throw new CliError(`the table holds ${stored} of ${this.#rows} chunks`, EXIT.failed, 'the write did not finish, and a partial index answers as if it were whole');
|
|
79
|
+
}
|
|
80
|
+
await table.createIndex('text', { config: Index.fts() });
|
|
81
|
+
await table.createIndex('kind', { config: Index.bitmap() });
|
|
82
|
+
for (const column of ['path', 'structurePath']) {
|
|
83
|
+
await table.createIndex(column, { config: Index.btree() });
|
|
84
|
+
}
|
|
85
|
+
const vector = this.#rows >= VECTOR_INDEX_MIN_ROWS;
|
|
86
|
+
if (vector) {
|
|
87
|
+
await table.createIndex('vector');
|
|
88
|
+
}
|
|
89
|
+
this.#db.close();
|
|
90
|
+
return { rows: this.#rows, fts: true, vector };
|
|
91
|
+
}
|
|
92
|
+
close() {
|
|
93
|
+
this.#db.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export class ChunkStore {
|
|
97
|
+
#db;
|
|
98
|
+
#table;
|
|
99
|
+
constructor(db, table) {
|
|
100
|
+
this.#db = db;
|
|
101
|
+
this.#table = table;
|
|
102
|
+
}
|
|
103
|
+
static async open(dir) {
|
|
104
|
+
const db = await connect(lancePath(dir));
|
|
105
|
+
try {
|
|
106
|
+
return new ChunkStore(db, await db.openTable(TABLE));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
db.close();
|
|
110
|
+
throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag docs index`');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Nearest neighbours. Every row has a vector, so nothing has to be excluded. */
|
|
114
|
+
async nearest(vector, filter, limit) {
|
|
115
|
+
let query = this.#table.query().nearestTo(vector).limit(limit);
|
|
116
|
+
const predicate = where(filter);
|
|
117
|
+
if (predicate) {
|
|
118
|
+
query = query.where(predicate);
|
|
119
|
+
}
|
|
120
|
+
return hits(await query.toArray());
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The lexical leg. A missing full-text index, or a query the tokenizer
|
|
124
|
+
* makes nothing of, answers with nothing rather than throwing: a hybrid
|
|
125
|
+
* search that loses one leg is a worse search, not a failed one.
|
|
126
|
+
*/
|
|
127
|
+
async matching(text, filter, limit) {
|
|
128
|
+
try {
|
|
129
|
+
let query = this.#table
|
|
130
|
+
.query()
|
|
131
|
+
.fullTextSearch(text, { columns: ['text'] })
|
|
132
|
+
.limit(limit);
|
|
133
|
+
const predicate = where(filter);
|
|
134
|
+
if (predicate) {
|
|
135
|
+
query = query.where(predicate);
|
|
136
|
+
}
|
|
137
|
+
return hits(await query.toArray());
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
close() {
|
|
144
|
+
this.#db.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
function where(filter) {
|
|
149
|
+
return [clause('kind', filter.kinds), clause('path', filter.paths), prefixes(filter.prefixes)]
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.join(' AND ');
|
|
152
|
+
}
|
|
153
|
+
function clause(column, values) {
|
|
154
|
+
if (!values || values.length === 0) {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
if (column === 'kind') {
|
|
158
|
+
for (const value of values) {
|
|
159
|
+
if (!KINDS.has(value)) {
|
|
160
|
+
throw new Error(`kind cannot be ${JSON.stringify(value)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (!values.every((value) => SAFE.test(value))) {
|
|
165
|
+
// Left to the JavaScript filter, which is what makes it correct anyway.
|
|
166
|
+
return '';
|
|
167
|
+
}
|
|
168
|
+
return `${column} IN (${values.map((v) => `'${v}'`).join(', ')})`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One prefix covers a section and everything nested inside it. `LIKE` alone is
|
|
172
|
+
* too generous — `doc/sec:1` prefixes `doc/sec:10` as well — so this narrows
|
|
173
|
+
* the scan and the caller settles the boundary in JavaScript.
|
|
174
|
+
*/
|
|
175
|
+
function prefixes(values) {
|
|
176
|
+
if (!values || values.length === 0 || !values.every((value) => SAFE.test(value))) {
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
return `(${values.map((v) => `structurePath LIKE '${v}%'`).join(' OR ')})`;
|
|
180
|
+
}
|
|
181
|
+
function hits(rows) {
|
|
182
|
+
return rows.map((row, rank) => ({
|
|
183
|
+
record: strip(row),
|
|
184
|
+
rank,
|
|
185
|
+
relevance: score(row),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
/** A lexical query reports a score; a vector one reports a distance. */
|
|
189
|
+
function score(row) {
|
|
190
|
+
const relevance = row._relevance_score ?? row._score;
|
|
191
|
+
if (typeof relevance === 'number') {
|
|
192
|
+
return relevance;
|
|
193
|
+
}
|
|
194
|
+
const distance = row._distance;
|
|
195
|
+
return typeof distance === 'number' ? 1 / (1 + distance) : 0;
|
|
196
|
+
}
|
|
197
|
+
function strip(row) {
|
|
198
|
+
return {
|
|
199
|
+
id: row.id,
|
|
200
|
+
path: row.path,
|
|
201
|
+
ordinal: row.ordinal,
|
|
202
|
+
kind: row.kind,
|
|
203
|
+
text: row.text,
|
|
204
|
+
embedText: row.embedText,
|
|
205
|
+
lineSpec: row.lineSpec,
|
|
206
|
+
bodyStart: row.bodyStart,
|
|
207
|
+
bodyEnd: row.bodyEnd,
|
|
208
|
+
structureId: row.structureId,
|
|
209
|
+
structurePath: row.structurePath,
|
|
210
|
+
headings: row.headings,
|
|
211
|
+
tokens: row.tokens,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type AnyTool } from '@zenera/neo';
|
|
2
|
+
import { type DocsIndex } from './search.ts';
|
|
3
|
+
export interface DocsToolOptions {
|
|
4
|
+
/** passages per search when the model does not say */
|
|
5
|
+
limit?: number;
|
|
6
|
+
/** lines quoted per answer when the model does not say */
|
|
7
|
+
maxLines?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function docsTools<TCtx = unknown>(index: DocsIndex, options?: DocsToolOptions): AnyTool<TCtx>[];
|
|
10
|
+
//# sourceMappingURL=tools.d.ts.map
|