@operor/knowledge 0.1.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.
@@ -0,0 +1,379 @@
1
+ import { statSync } from 'node:fs';
2
+ import Database from 'better-sqlite3';
3
+ import * as sqliteVec from 'sqlite-vec';
4
+ import type { KBDocument, KBChunk, KBSearchResult, KBSearchOptions, KnowledgeStore, KBStats } from './types.js';
5
+
6
+ export class SQLiteKnowledgeStore implements KnowledgeStore {
7
+ private db: Database.Database;
8
+ private dbPath: string;
9
+ private dimensions: number;
10
+ private dimensionWarned = false;
11
+
12
+ constructor(dbPath: string = './knowledge.db', dimensions: number = 1536) {
13
+ this.db = new Database(dbPath);
14
+ this.dbPath = dbPath;
15
+ this.dimensions = dimensions;
16
+ this.db.pragma('journal_mode = WAL');
17
+ this.db.pragma('foreign_keys = ON');
18
+ sqliteVec.load(this.db);
19
+ }
20
+
21
+ getDimensions(): number {
22
+ return this.dimensions;
23
+ }
24
+
25
+ async initialize(): Promise<void> {
26
+ this.db.exec(`
27
+ CREATE TABLE IF NOT EXISTS kb_documents (
28
+ id TEXT PRIMARY KEY,
29
+ source_type TEXT NOT NULL,
30
+ source_url TEXT,
31
+ file_name TEXT,
32
+ title TEXT,
33
+ content TEXT NOT NULL,
34
+ metadata TEXT,
35
+ created_at INTEGER NOT NULL,
36
+ updated_at INTEGER NOT NULL,
37
+ priority INTEGER DEFAULT 2,
38
+ content_hash TEXT
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS kb_chunks (
42
+ id TEXT PRIMARY KEY,
43
+ document_id TEXT NOT NULL,
44
+ content TEXT NOT NULL,
45
+ chunk_index INTEGER NOT NULL,
46
+ metadata TEXT,
47
+ FOREIGN KEY (document_id) REFERENCES kb_documents(id) ON DELETE CASCADE
48
+ );
49
+ CREATE INDEX IF NOT EXISTS idx_chunks_document ON kb_chunks(document_id);
50
+ CREATE INDEX IF NOT EXISTS idx_documents_source_url ON kb_documents(source_url);
51
+ `);
52
+
53
+ // Migration: add columns for existing DBs
54
+ try { this.db.exec('ALTER TABLE kb_documents ADD COLUMN priority INTEGER DEFAULT 2'); } catch {}
55
+ try { this.db.exec('ALTER TABLE kb_documents ADD COLUMN content_hash TEXT'); } catch {}
56
+ try { this.db.exec('CREATE INDEX IF NOT EXISTS idx_documents_source_url ON kb_documents(source_url)'); } catch {}
57
+
58
+ this.db.exec(`
59
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
60
+ chunk_id TEXT PRIMARY KEY,
61
+ embedding float[${this.dimensions}]
62
+ );
63
+ `);
64
+
65
+ this.db.exec(`
66
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5(
67
+ chunk_id UNINDEXED,
68
+ content,
69
+ tokenize='porter unicode61'
70
+ );
71
+ `);
72
+ }
73
+
74
+ async close(): Promise<void> {
75
+ this.db.close();
76
+ }
77
+
78
+ async addDocument(doc: KBDocument): Promise<void> {
79
+ this.db.prepare(`
80
+ INSERT OR REPLACE INTO kb_documents (id, source_type, source_url, file_name, title, content, metadata, created_at, updated_at, priority, content_hash)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
+ `).run(
83
+ doc.id,
84
+ doc.sourceType,
85
+ doc.sourceUrl || null,
86
+ doc.fileName || null,
87
+ doc.title || null,
88
+ doc.content,
89
+ doc.metadata ? JSON.stringify(doc.metadata) : null,
90
+ doc.createdAt,
91
+ doc.updatedAt,
92
+ doc.priority ?? 2,
93
+ doc.contentHash || null,
94
+ );
95
+ }
96
+
97
+ async getDocument(id: string): Promise<KBDocument | null> {
98
+ const row = this.db.prepare('SELECT * FROM kb_documents WHERE id = ?').get(id) as any;
99
+ return row ? this.rowToDocument(row) : null;
100
+ }
101
+
102
+ async listDocuments(): Promise<KBDocument[]> {
103
+ const rows = this.db.prepare('SELECT * FROM kb_documents ORDER BY created_at DESC').all() as any[];
104
+ return rows.map((r) => this.rowToDocument(r));
105
+ }
106
+
107
+ async deleteDocument(id: string): Promise<void> {
108
+ const chunks = this.db.prepare('SELECT id FROM kb_chunks WHERE document_id = ?').all(id) as any[];
109
+ for (const chunk of chunks) {
110
+ this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?').run(chunk.id);
111
+ this.db.prepare('DELETE FROM fts_chunks WHERE chunk_id = ?').run(chunk.id);
112
+ }
113
+ this.db.prepare('DELETE FROM kb_chunks WHERE document_id = ?').run(id);
114
+ this.db.prepare('DELETE FROM kb_documents WHERE id = ?').run(id);
115
+ }
116
+
117
+ async addChunks(chunks: KBChunk[]): Promise<void> {
118
+ const insertChunk = this.db.prepare(`
119
+ INSERT OR REPLACE INTO kb_chunks (id, document_id, content, chunk_index, metadata)
120
+ VALUES (?, ?, ?, ?, ?)
121
+ `);
122
+ const insertVec = this.db.prepare(`
123
+ INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding)
124
+ VALUES (?, ?)
125
+ `);
126
+ const deleteFts = this.db.prepare(`
127
+ DELETE FROM fts_chunks WHERE chunk_id = ?
128
+ `);
129
+ const insertFts = this.db.prepare(`
130
+ INSERT INTO fts_chunks (chunk_id, content)
131
+ VALUES (?, ?)
132
+ `);
133
+
134
+ const transaction = this.db.transaction((items: KBChunk[]) => {
135
+ for (const chunk of items) {
136
+ insertChunk.run(
137
+ chunk.id,
138
+ chunk.documentId,
139
+ chunk.content,
140
+ chunk.chunkIndex,
141
+ chunk.metadata ? JSON.stringify(chunk.metadata) : null,
142
+ );
143
+ if (chunk.embedding) {
144
+ if (chunk.embedding.length !== this.dimensions && !this.dimensionWarned) {
145
+ this.dimensionWarned = true;
146
+ console.warn(
147
+ `[KB] Dimension mismatch: store expects ${this.dimensions}d vectors but received ${chunk.embedding.length}d. ` +
148
+ `This will cause search errors. Re-ingest your documents after switching embedding providers, ` +
149
+ `or set the correct dimensions when creating the store.`
150
+ );
151
+ }
152
+ insertVec.run(chunk.id, new Float32Array(chunk.embedding));
153
+ }
154
+ // FTS5: delete-then-insert for idempotent upsert (FTS5 has no OR REPLACE)
155
+ deleteFts.run(chunk.id);
156
+ insertFts.run(chunk.id, chunk.content);
157
+ }
158
+ });
159
+
160
+ transaction(chunks);
161
+ }
162
+
163
+ getChunkCount(documentId: string): number {
164
+ const row = this.db.prepare(
165
+ 'SELECT COUNT(*) as count FROM kb_chunks WHERE document_id = ?'
166
+ ).get(documentId) as any;
167
+ return row?.count || 0;
168
+ }
169
+
170
+ async search(query: string, embedding: number[], options?: KBSearchOptions): Promise<KBSearchResult[]> {
171
+ return this.searchByEmbedding(embedding, options);
172
+ }
173
+
174
+ async searchByEmbedding(embedding: number[], options?: KBSearchOptions): Promise<KBSearchResult[]> {
175
+ const limit = options?.limit || 5;
176
+ // When filtering by sourceTypes post-query, over-fetch to avoid missing
177
+ // results that would be filtered out (e.g., FAQ fast-path requesting limit=1
178
+ // but the closest vector is a non-FAQ document chunk).
179
+ const fetchLimit = options?.sourceTypes ? Math.min(limit * 10, 100) : limit;
180
+
181
+ const vecRows = this.db.prepare(`
182
+ SELECT chunk_id, distance
183
+ FROM vec_chunks
184
+ WHERE embedding MATCH ?
185
+ ORDER BY distance
186
+ LIMIT ?
187
+ `).all(new Float32Array(embedding), fetchLimit) as any[];
188
+
189
+ if (vecRows.length === 0) return [];
190
+
191
+ const results: KBSearchResult[] = [];
192
+ for (const vecRow of vecRows) {
193
+ const distance = vecRow.distance as number;
194
+ const score = 1 / (1 + distance);
195
+
196
+ if (options?.scoreThreshold && score < options.scoreThreshold) continue;
197
+
198
+ const chunk = this.db.prepare('SELECT * FROM kb_chunks WHERE id = ?').get(vecRow.chunk_id) as any;
199
+ if (!chunk) continue;
200
+
201
+ const doc = this.db.prepare('SELECT * FROM kb_documents WHERE id = ?').get(chunk.document_id) as any;
202
+ if (!doc) continue;
203
+
204
+ if (options?.sourceTypes && !options.sourceTypes.includes(doc.source_type)) continue;
205
+
206
+ results.push({
207
+ chunk: {
208
+ id: chunk.id,
209
+ documentId: chunk.document_id,
210
+ content: chunk.content,
211
+ chunkIndex: chunk.chunk_index,
212
+ metadata: chunk.metadata ? JSON.parse(chunk.metadata) : undefined,
213
+ },
214
+ document: this.rowToDocument(doc),
215
+ score,
216
+ distance,
217
+ });
218
+ }
219
+
220
+ return results.slice(0, limit);
221
+ }
222
+
223
+ async searchByKeyword(query: string, options?: KBSearchOptions): Promise<KBSearchResult[]> {
224
+ const limit = options?.limit || 5;
225
+
226
+ // FTS5 MATCH query — escape double quotes in user input
227
+ const safeQuery = query.replace(/"/g, '""');
228
+ let ftsRows: any[];
229
+ try {
230
+ ftsRows = this.db.prepare(`
231
+ SELECT chunk_id, rank
232
+ FROM fts_chunks
233
+ WHERE fts_chunks MATCH ?
234
+ ORDER BY rank
235
+ LIMIT ?
236
+ `).all(safeQuery, limit * 2) as any[]; // fetch extra to allow post-filtering
237
+ } catch {
238
+ // FTS5 can throw on malformed queries (e.g. special chars)
239
+ return [];
240
+ }
241
+
242
+ if (ftsRows.length === 0) return [];
243
+
244
+ const results: KBSearchResult[] = [];
245
+ for (const ftsRow of ftsRows) {
246
+ if (results.length >= limit) break;
247
+
248
+ const chunk = this.db.prepare('SELECT * FROM kb_chunks WHERE id = ?').get(ftsRow.chunk_id) as any;
249
+ if (!chunk) continue;
250
+
251
+ const doc = this.db.prepare('SELECT * FROM kb_documents WHERE id = ?').get(chunk.document_id) as any;
252
+ if (!doc) continue;
253
+
254
+ if (options?.sourceTypes && !options.sourceTypes.includes(doc.source_type)) continue;
255
+
256
+ // BM25 rank is negative (lower = better), convert to a positive score
257
+ const bm25Score = -ftsRow.rank;
258
+
259
+ if (options?.scoreThreshold && bm25Score < options.scoreThreshold) continue;
260
+
261
+ results.push({
262
+ chunk: {
263
+ id: chunk.id,
264
+ documentId: chunk.document_id,
265
+ content: chunk.content,
266
+ chunkIndex: chunk.chunk_index,
267
+ metadata: chunk.metadata ? JSON.parse(chunk.metadata) : undefined,
268
+ },
269
+ document: this.rowToDocument(doc),
270
+ score: bm25Score,
271
+ distance: 0,
272
+ });
273
+ }
274
+
275
+ return results;
276
+ }
277
+
278
+ /**
279
+ * Get all chunks from kb_chunks (text content only, no embeddings).
280
+ * Used by rebuild to re-embed all content.
281
+ */
282
+ getAllChunks(): { id: string; documentId: string; content: string; chunkIndex: number; metadata?: string }[] {
283
+ return this.db.prepare(
284
+ 'SELECT id, document_id AS documentId, content, chunk_index AS chunkIndex, metadata FROM kb_chunks ORDER BY document_id, chunk_index'
285
+ ).all() as any[];
286
+ }
287
+
288
+ /**
289
+ * Drop and recreate the vec_chunks virtual table with new dimensions.
290
+ * Preserves kb_chunks, kb_documents, and fts_chunks — only vector data is affected.
291
+ */
292
+ rebuildVecTable(newDimensions: number): void {
293
+ this.db.exec('DROP TABLE IF EXISTS vec_chunks');
294
+ this.db.exec(`
295
+ CREATE VIRTUAL TABLE vec_chunks USING vec0(
296
+ chunk_id TEXT PRIMARY KEY,
297
+ embedding float[${newDimensions}]
298
+ );
299
+ `);
300
+ this.dimensions = newDimensions;
301
+ this.dimensionWarned = false;
302
+ }
303
+
304
+ /**
305
+ * Batch-insert embeddings into vec_chunks.
306
+ * Expects an array of { chunkId, embedding } pairs.
307
+ */
308
+ batchInsertEmbeddings(items: { chunkId: string; embedding: number[] }[]): void {
309
+ const insert = this.db.prepare(
310
+ 'INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)'
311
+ );
312
+ const tx = this.db.transaction((batch: { chunkId: string; embedding: number[] }[]) => {
313
+ for (const item of batch) {
314
+ insert.run(item.chunkId, new Float32Array(item.embedding));
315
+ }
316
+ });
317
+ tx(items);
318
+ }
319
+
320
+ async getStats(): Promise<KBStats> {
321
+ const docCount = this.db.prepare('SELECT COUNT(*) as count FROM kb_documents').get() as any;
322
+ const chunkCount = this.db.prepare('SELECT COUNT(*) as count FROM kb_chunks').get() as any;
323
+ let dbSizeBytes = 0;
324
+ try {
325
+ dbSizeBytes = statSync(this.dbPath).size;
326
+ } catch {}
327
+ return {
328
+ documentCount: docCount.count,
329
+ chunkCount: chunkCount.count,
330
+ embeddingDimensions: this.dimensions,
331
+ dbSizeBytes,
332
+ };
333
+ }
334
+
335
+ async findBySourceUrl(url: string): Promise<KBDocument | null> {
336
+ const row = this.db.prepare('SELECT * FROM kb_documents WHERE source_url = ?').get(url) as any;
337
+ return row ? this.rowToDocument(row) : null;
338
+ }
339
+
340
+ async findByContentHash(hash: string): Promise<KBDocument | null> {
341
+ const row = this.db.prepare('SELECT * FROM kb_documents WHERE content_hash = ?').get(hash) as any;
342
+ return row ? this.rowToDocument(row) : null;
343
+ }
344
+
345
+ async updateDocument(id: string, updates: { content?: string; title?: string; contentHash?: string; priority?: number; metadata?: Record<string, any> }): Promise<void> {
346
+ const sets: string[] = [];
347
+ const values: any[] = [];
348
+ if (updates.content !== undefined) { sets.push('content = ?'); values.push(updates.content); }
349
+ if (updates.title !== undefined) { sets.push('title = ?'); values.push(updates.title); }
350
+ if (updates.contentHash !== undefined) { sets.push('content_hash = ?'); values.push(updates.contentHash); }
351
+ if (updates.priority !== undefined) { sets.push('priority = ?'); values.push(updates.priority); }
352
+ if (updates.metadata !== undefined) { sets.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
353
+ sets.push('updated_at = ?'); values.push(Date.now());
354
+ values.push(id);
355
+ this.db.prepare(`UPDATE kb_documents SET ${sets.join(', ')} WHERE id = ?`).run(...values);
356
+ }
357
+
358
+ async findSimilarFaq(embedding: number[], threshold: number): Promise<KBSearchResult | null> {
359
+ const results = await this.searchByEmbedding(embedding, { sourceTypes: ['faq'], limit: 1 });
360
+ if (results.length > 0 && results[0].score >= threshold) return results[0];
361
+ return null;
362
+ }
363
+
364
+ private rowToDocument(row: any): KBDocument {
365
+ return {
366
+ id: row.id,
367
+ sourceType: row.source_type,
368
+ sourceUrl: row.source_url || undefined,
369
+ fileName: row.file_name || undefined,
370
+ title: row.title || undefined,
371
+ content: row.content,
372
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
373
+ createdAt: row.created_at,
374
+ updatedAt: row.updated_at,
375
+ priority: row.priority ?? 2,
376
+ contentHash: row.content_hash || undefined,
377
+ };
378
+ }
379
+ }
@@ -0,0 +1,34 @@
1
+ import { RecursiveCharacterTextSplitter, MarkdownTextSplitter } from '@langchain/textsplitters';
2
+
3
+ export interface ChunkOptions {
4
+ chunkSize?: number;
5
+ chunkOverlap?: number;
6
+ }
7
+
8
+ export class TextChunker {
9
+ private defaultChunkSize: number;
10
+ private defaultChunkOverlap: number;
11
+
12
+ constructor(options?: ChunkOptions) {
13
+ this.defaultChunkSize = options?.chunkSize || 3200;
14
+ this.defaultChunkOverlap = options?.chunkOverlap || 200;
15
+ }
16
+
17
+ async chunk(text: string, options?: ChunkOptions): Promise<string[]> {
18
+ const splitter = new RecursiveCharacterTextSplitter({
19
+ chunkSize: options?.chunkSize || this.defaultChunkSize,
20
+ chunkOverlap: options?.chunkOverlap || this.defaultChunkOverlap,
21
+ });
22
+ const docs = await splitter.createDocuments([text]);
23
+ return docs.map((d) => d.pageContent);
24
+ }
25
+
26
+ async chunkMarkdown(markdown: string, options?: ChunkOptions): Promise<string[]> {
27
+ const splitter = new MarkdownTextSplitter({
28
+ chunkSize: options?.chunkSize || this.defaultChunkSize,
29
+ chunkOverlap: options?.chunkOverlap || this.defaultChunkOverlap,
30
+ });
31
+ const docs = await splitter.createDocuments([markdown]);
32
+ return docs.map((d) => d.pageContent);
33
+ }
34
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { unlinkSync } from 'node:fs';
3
+ import { EmbeddingService } from '../EmbeddingService.js';
4
+ import { SQLiteKnowledgeStore } from '../SQLiteKnowledgeStore.js';
5
+ import { TextChunker } from '../TextChunker.js';
6
+ import { IngestionPipeline } from '../IngestionPipeline.js';
7
+ import { UrlIngestor } from '../ingestors/UrlIngestor.js';
8
+ import { FileIngestor } from '../ingestors/FileIngestor.js';
9
+
10
+ // Mock AI SDK
11
+ vi.mock('ai', () => ({
12
+ embed: vi.fn(async ({ value }: { value: string }) => ({
13
+ embedding: mockEmbed(value),
14
+ })),
15
+ embedMany: vi.fn(async ({ values }: { values: string[] }) => ({
16
+ embeddings: values.map(mockEmbed),
17
+ })),
18
+ }));
19
+
20
+ vi.mock('@ai-sdk/openai', () => ({
21
+ createOpenAI: vi.fn(() => ({
22
+ embedding: vi.fn(() => ({})),
23
+ })),
24
+ }));
25
+
26
+ vi.mock('@ai-sdk/google', () => ({
27
+ createGoogleGenerativeAI: vi.fn(() => ({
28
+ textEmbeddingModel: vi.fn(() => ({})),
29
+ })),
30
+ }));
31
+
32
+ vi.mock('@ai-sdk/mistral', () => ({
33
+ mistral: {
34
+ embedding: vi.fn(() => ({})),
35
+ },
36
+ }));
37
+
38
+ vi.mock('@ai-sdk/cohere', () => ({
39
+ cohere: {
40
+ embedding: vi.fn(() => ({})),
41
+ },
42
+ }));
43
+
44
+ function mockEmbed(text: string): number[] {
45
+ const hash = text.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
46
+ return Array.from({ length: 1536 }, (_, i) => Math.sin(hash + i) * 0.1);
47
+ }
48
+
49
+ describe('CLI Integration — Bug Fixes', () => {
50
+ let store: SQLiteKnowledgeStore;
51
+ let embedder: EmbeddingService;
52
+ let chunker: TextChunker;
53
+ let pipeline: IngestionPipeline;
54
+ const dbPath = './test-cli-integration.db';
55
+
56
+ beforeEach(async () => {
57
+ store = new SQLiteKnowledgeStore(dbPath);
58
+ // BUG 1 FIX: Must call initialize() to create tables
59
+ await store.initialize();
60
+ embedder = new EmbeddingService({ provider: 'openai', apiKey: 'test' });
61
+ chunker = new TextChunker({ chunkSize: 100, chunkOverlap: 20 });
62
+ pipeline = new IngestionPipeline(store, embedder, chunker);
63
+ });
64
+
65
+ afterEach(async () => {
66
+ await store.close();
67
+ try {
68
+ unlinkSync(dbPath);
69
+ unlinkSync(`${dbPath}-shm`);
70
+ unlinkSync(`${dbPath}-wal`);
71
+ } catch {}
72
+ });
73
+
74
+ it('should list documents after initialization (Bug 1)', async () => {
75
+ // This would fail with "no such table: kb_documents" without initialize()
76
+ const docs = await store.listDocuments();
77
+ expect(docs).toEqual([]);
78
+ });
79
+
80
+ it('should ingest URL via UrlIngestor (Bug 2)', async () => {
81
+ // Mock fetch for URL ingestion
82
+ global.fetch = vi.fn(async () => ({
83
+ ok: true,
84
+ text: async () => '<html><head><title>Test</title></head><body>Test content</body></html>',
85
+ })) as any;
86
+
87
+ const urlIngestor = new UrlIngestor(pipeline);
88
+ const doc = await urlIngestor.ingestUrl('https://example.com');
89
+
90
+ expect(doc.id).toBeDefined();
91
+ expect(doc.sourceType).toBe('url');
92
+ expect(doc.sourceUrl).toBe('https://example.com');
93
+ });
94
+
95
+ it('should ingest FAQ via IngestionPipeline (Bug 2)', async () => {
96
+ const doc = await pipeline.ingestFaq('What is Operor?', 'Operor is a framework.');
97
+
98
+ expect(doc.id).toBeDefined();
99
+ expect(doc.sourceType).toBe('faq');
100
+ expect(doc.title).toBe('What is Operor?');
101
+ });
102
+
103
+ it('should search using embedding array (Bug 2)', async () => {
104
+ await pipeline.ingestFaq('What is the return policy?', 'You can return within 30 days.');
105
+
106
+ const queryEmbedding = await embedder.embed('return policy');
107
+ const results = await store.search('return policy', queryEmbedding, { limit: 5 });
108
+
109
+ expect(results.length).toBeGreaterThan(0);
110
+ });
111
+
112
+ it('should return stats via getStats()', async () => {
113
+ // Empty DB
114
+ const emptyStats = await store.getStats();
115
+ expect(emptyStats.documentCount).toBe(0);
116
+ expect(emptyStats.chunkCount).toBe(0);
117
+ expect(emptyStats.embeddingDimensions).toBe(1536);
118
+ expect(emptyStats.dbSizeBytes).toBeGreaterThan(0);
119
+
120
+ // After ingestion
121
+ await pipeline.ingestFaq('Q1?', 'A1');
122
+ await pipeline.ingestFaq('Q2?', 'A2');
123
+ const stats = await store.getStats();
124
+ expect(stats.documentCount).toBe(2);
125
+ expect(stats.chunkCount).toBe(2);
126
+ });
127
+
128
+ it('should delete document by string ID', async () => {
129
+ const doc = await pipeline.ingestFaq('Delete me?', 'OK');
130
+ await store.deleteDocument(doc.id);
131
+ const retrieved = await store.getDocument(doc.id);
132
+ expect(retrieved).toBeNull();
133
+ });
134
+ });