@zenera/rag 1.1.9 → 1.1.10

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.
Files changed (42) hide show
  1. package/README.md +154 -10
  2. package/dist/command.js +2 -1
  3. package/dist/docs/assemble.d.ts +52 -0
  4. package/dist/docs/assemble.js +127 -0
  5. package/dist/docs/build.d.ts +34 -0
  6. package/dist/docs/build.js +108 -0
  7. package/dist/docs/chunk.d.ts +73 -0
  8. package/dist/docs/chunk.js +586 -0
  9. package/dist/docs/command.d.ts +3 -0
  10. package/dist/docs/command.js +529 -0
  11. package/dist/docs/files.d.ts +94 -0
  12. package/dist/docs/files.js +80 -0
  13. package/dist/docs/index.d.ts +13 -0
  14. package/dist/docs/index.js +13 -0
  15. package/dist/docs/load.d.ts +28 -0
  16. package/dist/docs/load.js +212 -0
  17. package/dist/docs/lookup.d.ts +80 -0
  18. package/dist/docs/lookup.js +147 -0
  19. package/dist/docs/parse.d.ts +95 -0
  20. package/dist/docs/parse.js +372 -0
  21. package/dist/docs/readme.d.ts +6 -0
  22. package/dist/docs/readme.js +122 -0
  23. package/dist/docs/render.d.ts +13 -0
  24. package/dist/docs/render.js +46 -0
  25. package/dist/docs/repl.d.ts +7 -0
  26. package/dist/docs/repl.js +130 -0
  27. package/dist/docs/search.d.ts +92 -0
  28. package/dist/docs/search.js +251 -0
  29. package/dist/docs/store.d.ts +55 -0
  30. package/dist/docs/store.js +171 -0
  31. package/dist/docs/tools.d.ts +10 -0
  32. package/dist/docs/tools.js +300 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +3 -0
  35. package/dist/schema/command.js +3 -0
  36. package/dist/schema/query.js +1 -0
  37. package/dist/schema/search.d.ts +2 -0
  38. package/dist/schema/search.js +18 -2
  39. package/dist/schema/store.d.ts +4 -2
  40. package/dist/schema/store.js +16 -9
  41. package/dist/schema/tools.js +21 -2
  42. package/package.json +17 -4
@@ -0,0 +1,171 @@
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
+ const KINDS = new Set(CHUNK_KINDS);
31
+ /** What may go into a string literal in a predicate, and nothing else. */
32
+ const SAFE = /^[\w.:/ -]+$/;
33
+ export async function writeChunks(dir, rows, vectors) {
34
+ if (rows.length === 0) {
35
+ throw new CliError('the documents hold nothing to index', EXIT.invalid, 'they are empty, or every one of them is blank');
36
+ }
37
+ const db = await connect(lancePath(dir));
38
+ // Every column is always populated — never null — so the Arrow schema is
39
+ // inferred from the first row without a declaration to keep in step.
40
+ const table = await db.createTable(TABLE, rows.map((row, i) => ({ ...row, vector: vectors[i] })), { mode: 'overwrite' });
41
+ await table.createIndex('text', { config: Index.fts() });
42
+ await table.createIndex('kind', { config: Index.bitmap() });
43
+ for (const column of ['path', 'structurePath']) {
44
+ await table.createIndex(column, { config: Index.btree() });
45
+ }
46
+ const vector = rows.length >= VECTOR_INDEX_MIN_ROWS;
47
+ if (vector) {
48
+ await table.createIndex('vector');
49
+ }
50
+ db.close();
51
+ return { rows: rows.length, fts: true, vector };
52
+ }
53
+ export class ChunkStore {
54
+ #db;
55
+ #table;
56
+ constructor(db, table) {
57
+ this.#db = db;
58
+ this.#table = table;
59
+ }
60
+ static async open(dir) {
61
+ const db = await connect(lancePath(dir));
62
+ try {
63
+ return new ChunkStore(db, await db.openTable(TABLE));
64
+ }
65
+ catch {
66
+ db.close();
67
+ throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag docs index`');
68
+ }
69
+ }
70
+ /** Nearest neighbours. Every row has a vector, so nothing has to be excluded. */
71
+ async nearest(vector, filter, limit) {
72
+ let query = this.#table.query().nearestTo(vector).limit(limit);
73
+ const predicate = where(filter);
74
+ if (predicate) {
75
+ query = query.where(predicate);
76
+ }
77
+ return hits(await query.toArray());
78
+ }
79
+ /**
80
+ * The lexical leg. A missing full-text index, or a query the tokenizer
81
+ * makes nothing of, answers with nothing rather than throwing: a hybrid
82
+ * search that loses one leg is a worse search, not a failed one.
83
+ */
84
+ async matching(text, filter, limit) {
85
+ try {
86
+ let query = this.#table
87
+ .query()
88
+ .fullTextSearch(text, { columns: ['text'] })
89
+ .limit(limit);
90
+ const predicate = where(filter);
91
+ if (predicate) {
92
+ query = query.where(predicate);
93
+ }
94
+ return hits(await query.toArray());
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
100
+ close() {
101
+ this.#db.close();
102
+ }
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ function where(filter) {
106
+ return [clause('kind', filter.kinds), clause('path', filter.paths), prefixes(filter.prefixes)]
107
+ .filter(Boolean)
108
+ .join(' AND ');
109
+ }
110
+ function clause(column, values) {
111
+ if (!values || values.length === 0) {
112
+ return '';
113
+ }
114
+ if (column === 'kind') {
115
+ for (const value of values) {
116
+ if (!KINDS.has(value)) {
117
+ throw new Error(`kind cannot be ${JSON.stringify(value)}`);
118
+ }
119
+ }
120
+ }
121
+ else if (!values.every((value) => SAFE.test(value))) {
122
+ // Left to the JavaScript filter, which is what makes it correct anyway.
123
+ return '';
124
+ }
125
+ return `${column} IN (${values.map((v) => `'${v}'`).join(', ')})`;
126
+ }
127
+ /**
128
+ * One prefix covers a section and everything nested inside it. `LIKE` alone is
129
+ * too generous — `doc/sec:1` prefixes `doc/sec:10` as well — so this narrows
130
+ * the scan and the caller settles the boundary in JavaScript.
131
+ */
132
+ function prefixes(values) {
133
+ if (!values || values.length === 0 || !values.every((value) => SAFE.test(value))) {
134
+ return '';
135
+ }
136
+ return `(${values.map((v) => `structurePath LIKE '${v}%'`).join(' OR ')})`;
137
+ }
138
+ function hits(rows) {
139
+ return rows.map((row, rank) => ({
140
+ record: strip(row),
141
+ rank,
142
+ relevance: score(row),
143
+ }));
144
+ }
145
+ /** A lexical query reports a score; a vector one reports a distance. */
146
+ function score(row) {
147
+ const relevance = row._relevance_score ?? row._score;
148
+ if (typeof relevance === 'number') {
149
+ return relevance;
150
+ }
151
+ const distance = row._distance;
152
+ return typeof distance === 'number' ? 1 / (1 + distance) : 0;
153
+ }
154
+ function strip(row) {
155
+ return {
156
+ id: row.id,
157
+ path: row.path,
158
+ ordinal: row.ordinal,
159
+ kind: row.kind,
160
+ text: row.text,
161
+ embedText: row.embedText,
162
+ lineSpec: row.lineSpec,
163
+ bodyStart: row.bodyStart,
164
+ bodyEnd: row.bodyEnd,
165
+ structureId: row.structureId,
166
+ structurePath: row.structurePath,
167
+ headings: row.headings,
168
+ tokens: row.tokens,
169
+ };
170
+ }
171
+ //# 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
@@ -0,0 +1,300 @@
1
+ import { tool } from '@zenera/neo';
2
+ import { PatternError } from "../common/match.js";
3
+ import { assemble } from "./assemble.js";
4
+ import { CHUNK_KINDS } from "./chunk.js";
5
+ import { grepLines, listFiles, listSections, listTables, readRange, readSection, } from "./lookup.js";
6
+ import { renderAssembly } from "./render.js";
7
+ import { SEARCH_MODES } from "./search.js";
8
+ // ---------------------------------------------------------------------------
9
+ // The same index, given to an agent
10
+ //
11
+ // Four tools over one engine, and only one of them ranks anything. `search_docs`
12
+ // is the way in when the question is vague; the other three are exact, because
13
+ // a model told "no results" by a vector search has learned nothing — a ranking
14
+ // returns the top of a list, so an empty answer and an absent thing look
15
+ // identical.
16
+ //
17
+ // `search_docs` is shaped for the second call rather than the first. The first
18
+ // is always a sentence and always returns too much of the wrong tree; the
19
+ // second is the same sentence with `files: ["nsx_4.2*/api/**"]`, or
20
+ // `section: "Rate limits"`, or `kind: ["table"]` because the answer is a table
21
+ // and not the prose around it. Those are parameters and not separate tools, so
22
+ // narrowing costs one call and not three.
23
+ //
24
+ // Every answer carries line numbers, and `read_docs` takes them. That is the
25
+ // loop the whole thing exists for: find the passage, read around it, then edit
26
+ // the file the passage came from — and a passage that cannot be pointed at is a
27
+ // passage nothing can be done with.
28
+ // ---------------------------------------------------------------------------
29
+ const GROUP = 'docs';
30
+ /** Kept small on purpose: a tool result is prompt, and the model asked for one thing. */
31
+ const DEFAULT_LIMIT = 5;
32
+ const MAX_LIMIT = 25;
33
+ /** A listing is lines rather than passages, so it can afford more of them. */
34
+ const DEFAULT_ROWS = 50;
35
+ const MAX_ROWS = 200;
36
+ /** A ceiling on what one answer may quote, so one long section cannot eat it. */
37
+ const DEFAULT_MAX_LINES = 200;
38
+ const MAX_LINES = 800;
39
+ export function docsTools(index, options = {}) {
40
+ const limitOf = (asked) => clamp(asked ?? options.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT);
41
+ const linesOf = (asked) => clamp(asked ?? options.maxLines ?? DEFAULT_MAX_LINES, 20, MAX_LINES);
42
+ const searchDocs = tool({
43
+ name: 'search_docs',
44
+ group: GROUP,
45
+ description: 'Searches the documents and answers with the passages that matched, quoted ' +
46
+ 'verbatim with their line numbers and with a marker wherever something between ' +
47
+ 'two passages was left out. When the first answer is from the wrong part of the ' +
48
+ 'corpus, ask again with the same query and a narrowing: `files` for a path ' +
49
+ 'pattern, `section` for a heading, `kind` for tables or code only. Pass the ids ' +
50
+ 'from an earlier answer in exclude_ids to be shown something new instead.',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: {
54
+ query: {
55
+ type: 'string',
56
+ description: 'What is wanted, as a sentence rather than keywords.',
57
+ },
58
+ files: strings('Only documents whose name matches. A glob if it has * or ?, e.g. ' +
59
+ '"guides/**" or "nsx_4.2*/api/**"; otherwise a substring.'),
60
+ exclude_files: strings('Documents to leave out, matched the same way.'),
61
+ section: strings('Only under these headings, and whatever nests inside them. A heading ' +
62
+ 'title, or a structure path from an earlier answer.'),
63
+ kind: {
64
+ type: 'array',
65
+ items: { type: 'string', enum: [...CHUNK_KINDS] },
66
+ description: 'Only these kinds of block. Use ["table"] or ["table_row"] when the ' +
67
+ 'answer is tabular, ["code"] for examples.',
68
+ },
69
+ mode: {
70
+ type: 'string',
71
+ enum: [...SEARCH_MODES],
72
+ description: 'hybrid blends meaning and wording; text is exact wording only, for ' +
73
+ 'an error string or an identifier.',
74
+ },
75
+ exclude_ids: strings('Passage ids already seen, as printed in an earlier answer.'),
76
+ limit: {
77
+ type: 'integer',
78
+ description: `Passages kept. Default ${DEFAULT_LIMIT}, at most ${MAX_LIMIT}.`,
79
+ },
80
+ before: { type: 'integer', description: 'Extra lines quoted before each passage.' },
81
+ after: { type: 'integer', description: 'Extra lines quoted after each passage.' },
82
+ max_lines: {
83
+ type: 'integer',
84
+ description: `A ceiling on the whole answer. Default ${DEFAULT_MAX_LINES}.`,
85
+ },
86
+ },
87
+ required: ['query'],
88
+ additionalProperties: false,
89
+ },
90
+ execute: async (args) => {
91
+ const query = {
92
+ query: args.query,
93
+ files: args.files,
94
+ exclude_files: args.exclude_files,
95
+ section: args.section,
96
+ kinds: args.kind,
97
+ mode: args.mode,
98
+ exclude_ids: args.exclude_ids,
99
+ limit: limitOf(args.limit),
100
+ };
101
+ const result = await guard(() => index.search(query));
102
+ if ('error' in result) {
103
+ return result;
104
+ }
105
+ if (result.files.length === 0) {
106
+ return {
107
+ found: 0,
108
+ hint: 'no document matched `files` — call list_docs to see their names',
109
+ };
110
+ }
111
+ if (result.matches.length === 0) {
112
+ return {
113
+ found: 0,
114
+ scope: { documents: result.files.length, sections: result.sections.length },
115
+ hint: args.section?.length
116
+ ? 'nothing under that section — drop `section` and search the whole document'
117
+ : 'try fewer words, or mode "text" if it is an exact string',
118
+ };
119
+ }
120
+ const excerpt = await assemble(index, result.matches, {
121
+ before: args.before,
122
+ after: args.after,
123
+ maxLines: linesOf(args.max_lines),
124
+ });
125
+ return {
126
+ found: result.matches.length,
127
+ ids: result.matches.map((m) => m.id),
128
+ documents: excerpt.files.map((f) => f.path),
129
+ truncated: excerpt.truncated,
130
+ passages: renderAssembly(excerpt, { colour: false }),
131
+ };
132
+ },
133
+ });
134
+ const listDocs = tool({
135
+ name: 'list_docs',
136
+ group: GROUP,
137
+ description: 'Lists what is in the index — the documents, their headings, or their tables — ' +
138
+ 'without searching or ranking anything. Call it first to learn the document ' +
139
+ "names that `files` patterns are matched against, or to see a document's " +
140
+ 'structure before asking about one part of it.',
141
+ parameters: {
142
+ type: 'object',
143
+ properties: {
144
+ what: {
145
+ type: 'string',
146
+ enum: ['files', 'sections', 'tables'],
147
+ description: 'Default files.',
148
+ },
149
+ files: strings('Only these documents, matched by glob or substring.'),
150
+ section: strings('Only under these headings.'),
151
+ depth: {
152
+ type: 'integer',
153
+ description: 'sections only: the deepest heading level to report.',
154
+ },
155
+ limit: { type: 'integer', description: `Rows kept. Default ${DEFAULT_ROWS}.` },
156
+ },
157
+ additionalProperties: false,
158
+ },
159
+ execute: async (args) => {
160
+ const options = {
161
+ files: args.files,
162
+ section: args.section,
163
+ depth: args.depth,
164
+ limit: clamp(args.limit ?? DEFAULT_ROWS, 1, MAX_ROWS),
165
+ };
166
+ const what = args.what ?? 'files';
167
+ const result = await guard(() => what === 'sections'
168
+ ? listSections(index, options)
169
+ : what === 'tables'
170
+ ? listTables(index, options)
171
+ : listFiles(index, options));
172
+ if ('error' in result) {
173
+ return result;
174
+ }
175
+ return {
176
+ found: result.found,
177
+ truncated: result.truncated,
178
+ [what]: result.rows,
179
+ ...(result.found === 0
180
+ ? { hint: 'nothing matched — widen `files`, or call it with no arguments' }
181
+ : {}),
182
+ };
183
+ },
184
+ });
185
+ const grepDocs = tool({
186
+ name: 'grep_docs',
187
+ group: GROUP,
188
+ description: 'Every line matching a pattern, with the document and line number, and the ' +
189
+ 'section it sits in. Exact, not ranked, and it reports the true total even when ' +
190
+ 'the rows are cut — so unlike search_docs it can answer "does this string appear ' +
191
+ 'anywhere". Reach for it with an identifier, an error message, or a flag name.',
192
+ parameters: {
193
+ type: 'object',
194
+ properties: {
195
+ pattern: {
196
+ type: 'string',
197
+ description: 'A substring, a glob if it has * or ?, or a regex with regex.',
198
+ },
199
+ files: strings('Only these documents.'),
200
+ section: strings('Only under these headings.'),
201
+ regex: { type: 'boolean', description: 'Read the pattern as a regex.' },
202
+ case_sensitive: { type: 'boolean', description: 'Match the capitals too.' },
203
+ limit: { type: 'integer', description: `Lines kept. Default ${DEFAULT_ROWS}.` },
204
+ },
205
+ required: ['pattern'],
206
+ additionalProperties: false,
207
+ },
208
+ execute: async (args) => {
209
+ const result = await guard(() => grepLines(index, args.pattern, {
210
+ files: args.files,
211
+ section: args.section,
212
+ regex: args.regex,
213
+ caseSensitive: args.case_sensitive,
214
+ limit: clamp(args.limit ?? DEFAULT_ROWS, 1, MAX_ROWS),
215
+ }));
216
+ if ('error' in result) {
217
+ return result;
218
+ }
219
+ return {
220
+ found: result.found,
221
+ truncated: result.truncated,
222
+ lines: result.rows,
223
+ ...(result.found === 0 ? { hint: 'nothing matched anywhere in scope' } : {}),
224
+ };
225
+ },
226
+ });
227
+ const readDocs = tool({
228
+ name: 'read_docs',
229
+ group: GROUP,
230
+ description: 'Reads a document verbatim: a whole named section, or a line range as printed by ' +
231
+ 'search_docs or grep_docs. Use it when a passage was found and the lines around ' +
232
+ 'it are needed in full, with nothing omitted and nothing summarised.',
233
+ parameters: {
234
+ type: 'object',
235
+ properties: {
236
+ file: {
237
+ type: 'string',
238
+ description: 'A document name, exactly as list_docs prints it.',
239
+ },
240
+ section: {
241
+ type: 'string',
242
+ description: 'A heading title or structure path. Overrides from/to.',
243
+ },
244
+ from: { type: 'integer', description: 'First line, 1-based. Default 1.' },
245
+ to: { type: 'integer', description: 'Last line. Default the end of the document.' },
246
+ },
247
+ required: ['file'],
248
+ additionalProperties: false,
249
+ },
250
+ execute: async (args) => {
251
+ const file = index.resolveFiles([args.file])[0];
252
+ if (!file) {
253
+ return {
254
+ error: `no document called ${args.file}`,
255
+ hint: 'call list_docs for the names',
256
+ };
257
+ }
258
+ const result = await guard(async () => args.section
259
+ ? await readSection(index, file, args.section)
260
+ : await readRange(index, file, args.from ?? 1, args.to ?? Infinity));
261
+ if ('error' in result) {
262
+ return result;
263
+ }
264
+ // A whole large document is prompt spent on lines nobody asked for.
265
+ const kept = result.lines.slice(0, MAX_LINES);
266
+ return {
267
+ file: result.file,
268
+ start: result.start,
269
+ end: result.start + kept.length - 1,
270
+ total: result.total,
271
+ truncated: kept.length < result.lines.length,
272
+ text: kept.map((line, at) => `${result.start + at} | ${line}`).join('\n'),
273
+ };
274
+ },
275
+ });
276
+ return [searchDocs, listDocs, grepDocs, readDocs];
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ const strings = (description) => ({
280
+ type: 'array',
281
+ items: { type: 'string' },
282
+ description,
283
+ });
284
+ const clamp = (value, low, high) => Math.min(Math.max(Math.trunc(value), low), high);
285
+ /**
286
+ * A bad pattern is a bad argument, not a failure. A model handed a thrown
287
+ * exception retries the same call; one handed a sentence fixes it.
288
+ */
289
+ async function guard(run) {
290
+ try {
291
+ return await run();
292
+ }
293
+ catch (err) {
294
+ if (err instanceof PatternError) {
295
+ return { error: err.message };
296
+ }
297
+ throw err;
298
+ }
299
+ }
300
+ //# sourceMappingURL=tools.js.map
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './common/locate.ts';
2
2
  export * from './common/manifest.ts';
3
3
  export * from './common/match.ts';
4
+ export * as docs from './docs/index.ts';
4
5
  export * from './schema/build.ts';
5
6
  export * from './schema/entities.ts';
6
7
  export * from './schema/files.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  export * from "./common/locate.js";
2
2
  export * from "./common/manifest.js";
3
3
  export * from "./common/match.js";
4
+ // A namespace rather than a re-export: both subjects have a `Manifest`, a
5
+ // `writeIndex` and a `search`, and flattening them would collide on every one.
6
+ export * as docs from "./docs/index.js";
4
7
  export * from "./schema/build.js";
5
8
  export * from "./schema/entities.js";
6
9
  export * from "./schema/files.js";
@@ -99,6 +99,7 @@ export const command = {
99
99
  [' --exclude-method <name>', dim('Drop an operation by name.')],
100
100
  [' --exclude-type <name>', dim('Drop a schema by name.')],
101
101
  [' --exclude-property <name>', dim('Drop a field by name.')],
102
+ [' --source <name>', dim('Only this document, as `stats` names it. Repeatable.')],
102
103
  [' --limit <n>', dim('Seeds kept per term. Default 5.')],
103
104
  [' --max-hops <n>', dim('How far apart two hits may be. Default 3.')],
104
105
  [' --max-nodes <n>', dim('Nodes per result. Default 200.')],
@@ -282,6 +283,7 @@ const SEARCH_OPTIONS = {
282
283
  'exclude-method': MANY,
283
284
  'exclude-type': MANY,
284
285
  'exclude-property': MANY,
286
+ source: MANY,
285
287
  limit: { type: 'string' },
286
288
  'max-hops': { type: 'string' },
287
289
  'max-nodes': { type: 'string' },
@@ -362,6 +364,7 @@ function fromFlags(values, positionals = []) {
362
364
  put('exclude_methods', values['exclude-method']);
363
365
  put('exclude_types', values['exclude-type']);
364
366
  put('exclude_properties', values['exclude-property']);
367
+ put('sources', values.source);
365
368
  put('direction', values.direction);
366
369
  put('method_type', values['method-type']);
367
370
  put('limit', values.limit && count(values.limit, '--limit'));
@@ -19,6 +19,7 @@ const LISTS = [
19
19
  'exclude_methods',
20
20
  'exclude_types',
21
21
  'exclude_properties',
22
+ 'sources',
22
23
  ];
23
24
  const NUMBERS = ['limit', 'max_hops', 'max_nodes'];
24
25
  const DIRECTIONS = ['input', 'output', 'any'];
@@ -24,6 +24,8 @@ export interface SchemaQuery {
24
24
  exclude_methods?: readonly string[];
25
25
  exclude_types?: readonly string[];
26
26
  exclude_properties?: readonly string[];
27
+ /** document names, as `stats` prints them; any one of them is enough */
28
+ sources?: readonly string[];
27
29
  /** seeds kept per query string */
28
30
  limit?: number;
29
31
  max_hops?: number;
@@ -1,3 +1,4 @@
1
+ import { CliError, EXIT } from '@zenera/cli/lib';
1
2
  import { assertSameEmbedding } from "../common/manifest.js";
2
3
  import { openIndex } from "./files.js";
3
4
  import { EntityStore } from "./store.js";
@@ -21,7 +22,8 @@ export class SchemaIndex {
21
22
  static async open(dir, embedder) {
22
23
  const index = await openIndex(dir);
23
24
  assertSameEmbedding(index.manifest, embedder.id);
24
- return new SchemaIndex(index, await EntityStore.open(dir), embedder);
25
+ const names = index.manifest.sources.map((s) => s.name);
26
+ return new SchemaIndex(index, await EntityStore.open(dir, names), embedder);
25
27
  }
26
28
  schemas() {
27
29
  return this.#index.schemas();
@@ -33,6 +35,7 @@ export class SchemaIndex {
33
35
  this.#store.close();
34
36
  }
35
37
  async search(query, signal) {
38
+ this.#assertSources(query.sources);
36
39
  const terms = termsOf(query);
37
40
  if (terms.length === 0) {
38
41
  return { seeds: [], subgraphs: [], empty: [] };
@@ -70,11 +73,24 @@ export class SchemaIndex {
70
73
  });
71
74
  return { seeds, subgraphs, empty };
72
75
  }
76
+ /** Settled before the embedder is called, so a typo costs no credential. */
77
+ #assertSources(wanted) {
78
+ if (!wanted || wanted.length === 0) {
79
+ return;
80
+ }
81
+ const known = this.manifest.sources.map((s) => s.name);
82
+ const missing = wanted.filter((name) => !known.includes(name));
83
+ if (missing.length > 0) {
84
+ throw new CliError(`this index holds no document called ${missing.join(', ')}`, EXIT.failed, `it has: ${known.join(', ')}`);
85
+ }
86
+ }
73
87
  }
74
88
  // ---------------------------------------------------------------------------
75
89
  function termsOf(query) {
76
90
  const method = methodTypes(query.method_type);
77
91
  const loose = query.direction ?? 'any';
92
+ // A document is a constraint on the whole question, not on one field of it.
93
+ const sources = query.sources?.length ? query.sources : undefined;
78
94
  return [
79
95
  ...group(query.all, 'all', { methodTypes: method.mixed }),
80
96
  ...group(query.methods, 'methods', { kinds: ['method'], methodTypes: method.only }),
@@ -96,7 +112,7 @@ function termsOf(query) {
96
112
  kinds: ['property'],
97
113
  directions: sides('output'),
98
114
  }),
99
- ];
115
+ ].map((term) => (sources ? { ...term, filter: { ...term.filter, sources } } : term));
100
116
  }
101
117
  function group(texts, field, filter) {
102
118
  return (texts ?? [])
@@ -4,6 +4,7 @@ export interface StoreFilter {
4
4
  kinds?: readonly string[];
5
5
  directions?: readonly string[];
6
6
  methodTypes?: readonly string[];
7
+ sources?: readonly string[];
7
8
  }
8
9
  export interface Hit {
9
10
  record: EntityRecord;
@@ -20,8 +21,9 @@ export interface WriteResult {
20
21
  export declare function writeStore(dir: string, rows: readonly EntityRecord[], vectors: readonly Float32Array[]): Promise<WriteResult>;
21
22
  export declare class EntityStore {
22
23
  #private;
23
- constructor(db: Connection, table: Table);
24
- static open(dir: string): Promise<EntityStore>;
24
+ constructor(db: Connection, table: Table, sources?: readonly string[]);
25
+ /** `sources` is the document vocabulary a `sources` filter is checked against. */
26
+ static open(dir: string, sources?: readonly string[]): Promise<EntityStore>;
25
27
  /**
26
28
  * One hybrid query: the same string goes to the full-text side and, as a
27
29
  * vector, to the nearest-neighbour side, and LanceDB fuses the two.