import { createHash, randomUUID } from 'node:crypto' import { DatabaseSync, type StatementSync } from 'node:sqlite' import { embeddingIndexJobSchema, type EmbeddingIndexJob } from '../../shared/embedding-contracts' import type { EmbeddingIndexDocument } from './embedding-index-coordinator' import type { Chunk, ChunkEmbeddingInput, CreateEvidenceInput, CreateGraphEntityInput, CreateGraphRelationInput, CreateKnowledgeBaseInput, Document, Evidence, EmbeddingIndexState, GraphEntity, GraphRelation, GraphStrategy, HybridSearchOptions, HybridSearchResult, JsonObject, KnowledgeBase, KnowledgeSource, KnowledgeSourceStatus, KnowledgeSourceType, ReplaceChunkInput, SearchOptions, SearchResult, StorageMode, UpdateEvidenceInput, UpdateGraphEntityInput, UpdateGraphRelationInput, UpdateKnowledgeBaseInput, UpsertDocumentInput, UpsertKnowledgeSourceInput, VectorSearchOptions } from './types' const DATABASE_VERSION = 4 const MAX_ID_LENGTH = 128 const MAX_NAME_LENGTH = 512 const MAX_LOCATION_LENGTH = 8192 const MAX_CONTENT_LENGTH = 2_000_000 const MAX_JSON_LENGTH = 131_072 const MAX_CHUNKS = 10_000 const MAX_CHUNK_BATCH_CONTENT = 32_000_000 const MAX_ALIASES = 100 const MAX_LIST_LIMIT = 500 const MAX_JSON_ARRAY_ITEMS = 1_000 const MAX_JSON_DEPTH = 20 const MAX_JSON_NODES = 10_000 const MAX_JSON_STRING_LENGTH = 32_768 const MAX_EMBEDDING_DIMENSIONS = 8_192 const MAX_EMBEDDING_BATCH = 256 const MAX_EMBEDDING_PROVIDER_LENGTH = 128 const MAX_EMBEDDING_MODEL_LENGTH = 512 const MAX_EMBEDDING_ERROR_LENGTH = 2_000 const MAX_GRAPH_DEPTH = 3 const MAX_VECTOR_CANDIDATES = 5_000 const RRF_CONSTANT = 60 type ScoredSearchResult = { result: SearchResult similarity?: number evidenceIds?: string[] } type Row = Record function requiredString( value: string, field: string, maximum: number, trim = true ): string { if (typeof value !== 'string') { throw new TypeError(`${field} must be a string`) } const normalized = trim ? value.trim() : value if (normalized.trim().length === 0) { throw new RangeError(`${field} must not be empty`) } if (normalized.length > maximum) { throw new RangeError(`${field} must be at most ${maximum} characters`) } return normalized } function optionalString( value: string | null | undefined, field: string, maximum: number, trim = true ): string | undefined { if (value === undefined || value === null || value === '') { return undefined } return requiredString(value, field, maximum, trim) } function boundedInteger( value: number, field: string, minimum: number, maximum: number ): number { if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new RangeError( `${field} must be an integer between ${minimum} and ${maximum}` ) } return value } function enumValue( value: T, field: string, values: readonly T[] ): T { if (!values.includes(value)) { throw new RangeError(`${field} has an unsupported value`) } return value } function validateJsonValue( value: unknown, field: string, seen: WeakSet, depth: number, state: { nodes: number } ): void { state.nodes += 1 if (state.nodes > MAX_JSON_NODES) { throw new RangeError(`${field} contains too many values`) } if (value === null || typeof value === 'boolean') { return } if (typeof value === 'number') { if (!Number.isFinite(value)) { throw new TypeError(`${field} must contain only finite numbers`) } return } if (typeof value === 'string') { if (value.length > MAX_JSON_STRING_LENGTH) { throw new RangeError( `${field} strings must be at most ${MAX_JSON_STRING_LENGTH} characters` ) } return } if (typeof value !== 'object') { throw new TypeError(`${field} contains an unsupported value`) } if (depth >= MAX_JSON_DEPTH) { throw new RangeError(`${field} must be at most ${MAX_JSON_DEPTH} levels deep`) } if (seen.has(value)) { throw new TypeError(`${field} must not contain circular references`) } seen.add(value) if (Array.isArray(value)) { if (value.length > MAX_JSON_ARRAY_ITEMS) { throw new RangeError( `${field} arrays must contain at most ${MAX_JSON_ARRAY_ITEMS} items` ) } for (const item of value) { validateJsonValue(item, field, seen, depth + 1, state) } seen.delete(value) return } if (Object.getPrototypeOf(value) !== Object.prototype) { throw new TypeError(`${field} must contain only plain objects`) } for (const [key, item] of Object.entries(value)) { requiredString(key, `${field} key`, 256, false) if (key.replaceAll(/[_\-\s]/g, '').toLowerCase() === 'apikey') { throw new Error(`${field} must not contain API keys`) } validateJsonValue(item, field, seen, depth + 1, state) } seen.delete(value) } function jsonObject(value: JsonObject | undefined, field: string): string { const object = value ?? {} if ( typeof object !== 'object' || object === null || Array.isArray(object) ) { throw new TypeError(`${field} must be an object`) } validateJsonValue(object, field, new WeakSet(), 0, { nodes: 0 }) const serialized = JSON.stringify(object) if (serialized.length > MAX_JSON_LENGTH) { throw new RangeError( `${field} must serialize to at most ${MAX_JSON_LENGTH} characters` ) } return serialized } function stringArray( value: string[] | undefined, field: string ): string[] { const items = value ?? [] if (!Array.isArray(items) || items.length > MAX_ALIASES) { throw new RangeError(`${field} must contain at most ${MAX_ALIASES} items`) } return [ ...new Set( items.map((item, index) => requiredString(item, `${field}[${index}]`, MAX_NAME_LENGTH) ) ) ] } function parseObject(value: string): JsonObject { return JSON.parse(value) as JsonObject } function parseStringArray(value: string): string[] { return JSON.parse(value) as string[] } function asString(row: Row, key: string): string { return row[key] as string } function asOptionalString(row: Row, key: string): string | undefined { const value = row[key] return value === null ? undefined : (value as string) } function asNumber(row: Row, key: string): number { return row[key] as number } function asBytes(row: Row, key: string): Uint8Array { return row[key] as Uint8Array } function contentChecksum(content: string): string { return createHash('sha256').update(content).digest('hex') } function normalizedChecksum(value: string, field: string): string { const checksum = requiredString(value, field, 64).toLowerCase() if (!/^[a-f0-9]{64}$/u.test(checksum)) { throw new RangeError(`${field} must be a SHA-256 checksum`) } return checksum } function normalizeVector( value: readonly number[], field: string ): { bytes: Buffer dimensions: number magnitude: number values: number[] } { if ( !Array.isArray(value) || value.length < 1 || value.length > MAX_EMBEDDING_DIMENSIONS ) { throw new RangeError( `${field} must contain between 1 and ${MAX_EMBEDDING_DIMENSIONS} dimensions` ) } const bytes = Buffer.allocUnsafe(value.length * Float32Array.BYTES_PER_ELEMENT) const values: number[] = [] let magnitudeSquared = 0 for (let index = 0; index < value.length; index += 1) { const component = value[index] if (typeof component !== 'number' || !Number.isFinite(component)) { throw new TypeError(`${field} must contain only finite numbers`) } const storedComponent = Math.fround(component) if (!Number.isFinite(storedComponent)) { throw new RangeError(`${field} components must fit in Float32`) } bytes.writeFloatLE( storedComponent, index * Float32Array.BYTES_PER_ELEMENT ) values.push(storedComponent) magnitudeSquared += storedComponent * storedComponent } const magnitude = Math.sqrt(magnitudeSquared) if (!Number.isFinite(magnitude) || magnitude <= 0) { throw new RangeError(`${field} must have a finite non-zero norm`) } return { bytes, dimensions: value.length, magnitude, values } } function cosineSimilarity( left: readonly number[], leftMagnitude: number, rightBytes: Uint8Array, dimensions: number, rightMagnitude: number ): number | undefined { if ( left.length !== dimensions || rightBytes.byteLength !== dimensions * Float32Array.BYTES_PER_ELEMENT || !Number.isFinite(rightMagnitude) || rightMagnitude <= 0 ) { return undefined } const buffer = Buffer.from( rightBytes.buffer, rightBytes.byteOffset, rightBytes.byteLength ) let dot = 0 for (let index = 0; index < dimensions; index += 1) { const component = buffer.readFloatLE( index * Float32Array.BYTES_PER_ELEMENT ) if (!Number.isFinite(component)) { return undefined } dot += (left[index] ?? 0) * component } const similarity = dot / (leftMagnitude * rightMagnitude) return Number.isFinite(similarity) ? Math.max(-1, Math.min(1, similarity)) : undefined } function mapKnowledgeBase(row: Row): KnowledgeBase { return { id: asString(row, 'id'), name: asString(row, 'name'), description: asOptionalString(row, 'description'), storageMode: asString(row, 'storage_mode') as StorageMode, graphEnabled: asNumber(row, 'graph_enabled') === 1, graphStrategy: asString(row, 'graph_strategy') as GraphStrategy, createdAt: asString(row, 'created_at'), updatedAt: asString(row, 'updated_at') } } function mapSource(row: Row): KnowledgeSource { return { id: asString(row, 'id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), type: asString(row, 'type') as KnowledgeSourceType, location: asString(row, 'location'), displayName: asString(row, 'display_name'), status: asString(row, 'status') as KnowledgeSourceStatus, lastError: asOptionalString(row, 'last_error'), metadata: parseObject(asString(row, 'metadata')), createdAt: asString(row, 'created_at'), updatedAt: asString(row, 'updated_at') } } function mapDocument(row: Row): Document { return { id: asString(row, 'id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), sourceId: asString(row, 'source_id'), externalId: asString(row, 'external_id'), title: asString(row, 'title'), mimeType: asOptionalString(row, 'mime_type'), sourceLocation: asOptionalString(row, 'source_location'), checksum: asOptionalString(row, 'checksum'), metadata: parseObject(asString(row, 'metadata')), createdAt: asString(row, 'created_at'), updatedAt: asString(row, 'updated_at') } } function mapChunk(row: Row): Chunk { return { id: asString(row, 'id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), documentId: asString(row, 'document_id'), ordinal: asNumber(row, 'ordinal'), content: asString(row, 'content'), tokenCount: row.token_count === null ? undefined : asNumber(row, 'token_count'), heading: asOptionalString(row, 'heading'), location: asOptionalString(row, 'location'), metadata: parseObject(asString(row, 'metadata')), createdAt: asString(row, 'created_at') } } function mapEntity(row: Row): GraphEntity { return { id: asString(row, 'id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), name: asString(row, 'name'), type: asString(row, 'type'), aliases: parseStringArray(asString(row, 'aliases')), description: asOptionalString(row, 'description'), properties: parseObject(asString(row, 'properties')), locked: asNumber(row, 'locked') === 1, createdAt: asString(row, 'created_at'), updatedAt: asString(row, 'updated_at') } } function mapRelation(row: Row): GraphRelation { return { id: asString(row, 'id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), sourceEntityId: asString(row, 'source_entity_id'), targetEntityId: asString(row, 'target_entity_id'), type: asString(row, 'type'), label: asOptionalString(row, 'label'), properties: parseObject(asString(row, 'properties')), locked: asNumber(row, 'locked') === 1, createdAt: asString(row, 'created_at'), updatedAt: asString(row, 'updated_at') } } function mapEvidence(row: Row): Evidence { return { id: asString(row, 'id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), entityId: asOptionalString(row, 'entity_id'), relationId: asOptionalString(row, 'relation_id'), documentId: asString(row, 'document_id'), chunkId: asOptionalString(row, 'chunk_id'), quote: asOptionalString(row, 'quote'), location: asOptionalString(row, 'location'), createdAt: asString(row, 'created_at') } } function mapEmbeddingIndexState(row: Row): EmbeddingIndexState { return { documentId: asString(row, 'document_id'), knowledgeBaseId: asString(row, 'knowledge_base_id'), provider: asString(row, 'provider'), model: asString(row, 'model'), dimensions: row.dimensions === null ? undefined : asNumber(row, 'dimensions'), contentChecksum: asString(row, 'content_checksum'), status: asString(row, 'status') as EmbeddingIndexState['status'], lastError: asOptionalString(row, 'last_error'), updatedAt: asString(row, 'updated_at') } } export class KnowledgeDatabase { private database?: DatabaseSync constructor(private readonly databasePath: string) { requiredString(databasePath, 'databasePath', MAX_LOCATION_LENGTH, false) } initialize(): void { if (this.database) { return } const database = new DatabaseSync(this.databasePath, { enableForeignKeyConstraints: true, timeout: 5_000 }) try { database.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; `) this.assertFts5(database) this.migrate(database) database .prepare('DELETE FROM embedding_rebuild_staging') .run() this.database = database } catch (error) { database.close() throw error } } close(): void { if (!this.database) { return } this.database.close() this.database = undefined } createKnowledgeBase(input: CreateKnowledgeBaseInput): KnowledgeBase { const database = this.requireDatabase() const id = optionalString(input.id, 'id', MAX_ID_LENGTH) ?? randomUUID() const name = requiredString(input.name, 'name', MAX_NAME_LENGTH) const description = optionalString( input.description, 'description', MAX_CONTENT_LENGTH ) const storageMode = enumValue(input.storageMode, 'storageMode', [ 'reference', 'managed' ]) const graphStrategy = enumValue( input.graphStrategy ?? 'hybrid', 'graphStrategy', ['rules', 'model', 'hybrid', 'ask'] ) const now = new Date().toISOString() database .prepare( `INSERT INTO knowledge_bases (id, name, description, storage_mode, graph_enabled, graph_strategy, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, name, description ?? null, storageMode, input.graphEnabled === false ? 0 : 1, graphStrategy, now, now ) return this.getKnowledgeBase(id) as KnowledgeBase } listKnowledgeBases(limit = MAX_LIST_LIMIT): KnowledgeBase[] { boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) return this.requireDatabase() .prepare( `SELECT * FROM knowledge_bases ORDER BY created_at ASC, id ASC LIMIT ?` ) .all(limit) .map(mapKnowledgeBase) } getKnowledgeBase(id: string): KnowledgeBase | undefined { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const row = this.requireDatabase() .prepare('SELECT * FROM knowledge_bases WHERE id = ?') .get(normalizedId) return row ? mapKnowledgeBase(row) : undefined } updateKnowledgeBase( id: string, input: UpdateKnowledgeBaseInput ): KnowledgeBase { const current = this.requiredKnowledgeBase(id) const name = input.name === undefined ? current.name : requiredString(input.name, 'name', MAX_NAME_LENGTH) const description = input.description === undefined ? current.description : optionalString(input.description, 'description', MAX_CONTENT_LENGTH) const storageMode = input.storageMode === undefined ? current.storageMode : enumValue(input.storageMode, 'storageMode', ['reference', 'managed']) const graphStrategy = input.graphStrategy === undefined ? current.graphStrategy : enumValue(input.graphStrategy, 'graphStrategy', [ 'rules', 'model', 'hybrid', 'ask' ]) const graphEnabled = input.graphEnabled ?? current.graphEnabled this.requireDatabase() .prepare( `UPDATE knowledge_bases SET name = ?, description = ?, storage_mode = ?, graph_enabled = ?, graph_strategy = ?, updated_at = ? WHERE id = ?` ) .run( name, description ?? null, storageMode, graphEnabled ? 1 : 0, graphStrategy, new Date().toISOString(), current.id ) return this.requiredKnowledgeBase(current.id) } deleteKnowledgeBase(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) return ( this.requireDatabase() .prepare('DELETE FROM knowledge_bases WHERE id = ?') .run(normalizedId).changes > 0 ) } upsertSource(input: UpsertKnowledgeSourceInput): KnowledgeSource { const database = this.requireDatabase() const knowledgeBaseId = requiredString( input.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const type = enumValue(input.type, 'type', ['file', 'directory', 'url']) const location = requiredString( input.location, 'location', MAX_LOCATION_LENGTH, false ) const requestedId = optionalString(input.id, 'id', MAX_ID_LENGTH) const naturalMatch = requestedId ? undefined : database .prepare( `SELECT * FROM knowledge_sources WHERE knowledge_base_id = ? AND type = ? AND location = ?` ) .get(knowledgeBaseId, type, location) const id = requestedId ?? (naturalMatch ? asString(naturalMatch, 'id') : randomUUID()) const existing = database .prepare('SELECT * FROM knowledge_sources WHERE id = ?') .get(id) if ( existing && asString(existing, 'knowledge_base_id') !== knowledgeBaseId ) { throw new Error('A knowledge source cannot move between knowledge bases') } const displayName = requiredString( input.displayName, 'displayName', MAX_NAME_LENGTH ) const status = enumValue(input.status ?? 'pending', 'status', [ 'pending', 'indexing', 'ready', 'paused', 'error' ]) const lastError = optionalString( input.lastError, 'lastError', MAX_CONTENT_LENGTH ) const metadata = jsonObject(input.metadata, 'metadata') const now = new Date().toISOString() database .prepare( `INSERT INTO knowledge_sources (id, knowledge_base_id, type, location, display_name, status, last_error, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, location = excluded.location, display_name = excluded.display_name, status = excluded.status, last_error = excluded.last_error, metadata = excluded.metadata, updated_at = excluded.updated_at` ) .run( id, knowledgeBaseId, type, location, displayName, status, lastError ?? null, metadata, now, now ) return this.requiredSource(id) } listSources( knowledgeBaseId: string, limit = MAX_LIST_LIMIT ): KnowledgeSource[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) return this.requireDatabase() .prepare( `SELECT * FROM knowledge_sources WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC LIMIT ?` ) .all(normalizedId, limit) .map(mapSource) } removeSource(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) return ( this.requireDatabase() .prepare('DELETE FROM knowledge_sources WHERE id = ?') .run(normalizedId).changes > 0 ) } upsertDocument( input: UpsertDocumentInput, chunks: ReplaceChunkInput[] ): Document { if (!Array.isArray(chunks) || chunks.length > MAX_CHUNKS) { throw new RangeError(`chunks must contain at most ${MAX_CHUNKS} items`) } const database = this.requireDatabase() const normalizedChunks = this.normalizeChunks(chunks) const knowledgeBaseId = requiredString( input.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const sourceId = requiredString( input.sourceId, 'sourceId', MAX_ID_LENGTH ) const externalId = requiredString( input.externalId, 'externalId', MAX_LOCATION_LENGTH, false ) const source = database .prepare( 'SELECT knowledge_base_id FROM knowledge_sources WHERE id = ?' ) .get(sourceId) if (!source || asString(source, 'knowledge_base_id') !== knowledgeBaseId) { throw new Error( 'Document source must belong to the document knowledge base' ) } const requestedId = optionalString(input.id, 'id', MAX_ID_LENGTH) const naturalMatch = requestedId ? undefined : database .prepare( 'SELECT id FROM documents WHERE source_id = ? AND external_id = ?' ) .get(sourceId, externalId) const id = requestedId ?? (naturalMatch ? asString(naturalMatch, 'id') : randomUUID()) const existing = database .prepare('SELECT knowledge_base_id FROM documents WHERE id = ?') .get(id) if ( existing && asString(existing, 'knowledge_base_id') !== knowledgeBaseId ) { throw new Error('A document cannot move between knowledge bases') } const title = requiredString(input.title, 'title', MAX_NAME_LENGTH) const mimeType = optionalString(input.mimeType, 'mimeType', 256) const sourceLocation = optionalString( input.sourceLocation, 'sourceLocation', MAX_LOCATION_LENGTH, false ) const checksum = optionalString(input.checksum, 'checksum', 512) const metadata = jsonObject(input.metadata, 'metadata') const now = new Date().toISOString() this.transaction(database, () => { database .prepare( `INSERT INTO documents (id, knowledge_base_id, source_id, external_id, title, mime_type, source_location, checksum, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET source_id = excluded.source_id, external_id = excluded.external_id, title = excluded.title, mime_type = excluded.mime_type, source_location = excluded.source_location, checksum = excluded.checksum, metadata = excluded.metadata, updated_at = excluded.updated_at` ) .run( id, knowledgeBaseId, sourceId, externalId, title, mimeType ?? null, sourceLocation ?? null, checksum ?? null, metadata, now, now ) database .prepare('DELETE FROM embedding_index_state WHERE document_id = ?') .run(id) database.prepare('DELETE FROM chunks WHERE document_id = ?').run(id) const insertChunk = database.prepare( `INSERT INTO chunks (id, knowledge_base_id, document_id, ordinal, content, token_count, heading, location, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) for (const chunk of normalizedChunks) { insertChunk.run( chunk.id, knowledgeBaseId, id, chunk.ordinal, chunk.content, chunk.tokenCount ?? null, chunk.heading ?? null, chunk.location ?? null, chunk.metadata, now ) } }) return this.requiredDocument(id) } getDocument(id: string): Document | undefined { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const row = this.requireDatabase() .prepare('SELECT * FROM documents WHERE id = ?') .get(normalizedId) return row ? mapDocument(row) : undefined } listDocuments( knowledgeBaseId: string, limit = MAX_LIST_LIMIT ): Document[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) return this.requireDatabase() .prepare( `SELECT * FROM documents WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC LIMIT ?` ) .all(normalizedId, limit) .map(mapDocument) } removeDocument(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) return ( this.requireDatabase() .prepare('DELETE FROM documents WHERE id = ?') .run(normalizedId).changes > 0 ) } removeEvidenceForDocument(documentId: string): number { const normalizedId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) return Number( this.requireDatabase() .prepare('DELETE FROM graph_evidence WHERE document_id = ?') .run(normalizedId).changes ) } pruneUnreferencedGeneratedGraph(knowledgeBaseId: string): { entities: number relations: number } { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const database = this.requireDatabase() let entities = 0 let relations = 0 this.transaction(database, () => { relations = Number( database .prepare( `DELETE FROM graph_relations WHERE knowledge_base_id = ? AND locked = 0 AND NOT EXISTS ( SELECT 1 FROM graph_evidence WHERE relation_id = graph_relations.id )` ) .run(normalizedId).changes ) entities = Number( database .prepare( `DELETE FROM graph_entities WHERE knowledge_base_id = ? AND locked = 0 AND NOT EXISTS ( SELECT 1 FROM graph_evidence WHERE entity_id = graph_entities.id ) AND NOT EXISTS ( SELECT 1 FROM graph_relations WHERE source_entity_id = graph_entities.id OR target_entity_id = graph_entities.id )` ) .run(normalizedId).changes ) }) return { entities, relations } } listChunks(documentId: string, limit = MAX_LIST_LIMIT): Chunk[] { const normalizedId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) boundedInteger(limit, 'limit', 1, MAX_CHUNKS) return this.requireDatabase() .prepare( `SELECT * FROM chunks WHERE document_id = ? ORDER BY ordinal ASC LIMIT ?` ) .all(normalizedId, limit) .map(mapChunk) } replaceDocumentEmbeddings( documentId: string, provider: string, model: string, embeddings: readonly ChunkEmbeddingInput[] ): EmbeddingIndexState { const normalizedDocumentId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const normalizedProvider = requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) const normalizedModel = requiredString( model, 'model', MAX_EMBEDDING_MODEL_LENGTH ) if (!Array.isArray(embeddings) || embeddings.length > MAX_CHUNKS) { throw new RangeError(`embeddings must contain at most ${MAX_CHUNKS} items`) } const database = this.requireDatabase() const document = database .prepare('SELECT id, knowledge_base_id FROM documents WHERE id = ?') .get(normalizedDocumentId) if (!document) { throw new Error(`Document not found: ${normalizedDocumentId}`) } const chunks = database .prepare( `SELECT id, content FROM chunks WHERE document_id = ? ORDER BY ordinal ASC, id ASC` ) .all(normalizedDocumentId) if (embeddings.length !== chunks.length) { throw new Error('Embeddings must cover every current document chunk') } const chunksById = new Map( chunks.map((row) => [asString(row, 'id'), asString(row, 'content')]) ) const seen = new Set() let dimensions: number | undefined const normalized = embeddings.map((embedding, index) => { const chunkId = requiredString( embedding.chunkId, `embeddings[${index}].chunkId`, MAX_ID_LENGTH ) const content = chunksById.get(chunkId) if (content === undefined || seen.has(chunkId)) { throw new Error('Embeddings must reference unique chunks in the document') } seen.add(chunkId) const checksum = normalizedChecksum( embedding.contentChecksum, `embeddings[${index}].contentChecksum` ) if (checksum !== contentChecksum(content)) { throw new Error('Embedding content checksum does not match the chunk') } const vector = normalizeVector( embedding.vector, `embeddings[${index}].vector` ) if (dimensions === undefined) { dimensions = vector.dimensions } else if (dimensions !== vector.dimensions) { throw new Error('Document embeddings must have consistent dimensions') } return { chunkId, checksum, ...vector } }) const indexChecksum = createHash('sha256') .update( normalized .map((item) => `${item.chunkId}\0${item.checksum}`) .sort() .join('\n') ) .digest('hex') const now = new Date().toISOString() this.transaction(database, () => { database .prepare( `DELETE FROM chunk_embeddings WHERE provider = ? AND model = ? AND chunk_id IN (SELECT id FROM chunks WHERE document_id = ?)` ) .run(normalizedProvider, normalizedModel, normalizedDocumentId) const insert = database.prepare( `INSERT INTO chunk_embeddings (chunk_id, knowledge_base_id, provider, model, dimensions, content_checksum, vector, magnitude, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) for (const item of normalized) { insert.run( item.chunkId, asString(document, 'knowledge_base_id'), normalizedProvider, normalizedModel, item.dimensions, item.checksum, item.bytes, item.magnitude, now, now ) } database .prepare( `INSERT INTO embedding_index_state (document_id, knowledge_base_id, provider, model, dimensions, content_checksum, status, last_error, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'ready', NULL, ?) ON CONFLICT(document_id, provider, model) DO UPDATE SET knowledge_base_id = excluded.knowledge_base_id, dimensions = excluded.dimensions, content_checksum = excluded.content_checksum, status = 'ready', last_error = NULL, updated_at = excluded.updated_at` ) .run( normalizedDocumentId, asString(document, 'knowledge_base_id'), normalizedProvider, normalizedModel, dimensions ?? null, indexChecksum, now ) }) return this.requiredEmbeddingIndexState( normalizedDocumentId, normalizedProvider, normalizedModel ) } beginDocumentEmbeddingReplacement( documentId: string, provider: string, model: string ): string { const normalizedDocumentId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const normalizedProvider = requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) const normalizedModel = requiredString( model, 'model', MAX_EMBEDDING_MODEL_LENGTH ) const database = this.requireDatabase() if ( !database .prepare('SELECT 1 FROM documents WHERE id = ?') .get(normalizedDocumentId) ) { throw new Error(`Document not found: ${normalizedDocumentId}`) } database .prepare( `DELETE FROM embedding_rebuild_staging WHERE document_id = ? AND provider = ? AND model = ?` ) .run( normalizedDocumentId, normalizedProvider, normalizedModel ) return randomUUID() } appendDocumentEmbeddingBatch( replacementId: string, documentId: string, provider: string, model: string, embeddings: readonly ChunkEmbeddingInput[] ): void { const normalizedReplacementId = requiredString( replacementId, 'replacementId', MAX_ID_LENGTH ) const normalizedDocumentId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const normalizedProvider = requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) const normalizedModel = requiredString( model, 'model', MAX_EMBEDDING_MODEL_LENGTH ) if ( !Array.isArray(embeddings) || embeddings.length < 1 || embeddings.length > MAX_EMBEDDING_BATCH ) { throw new RangeError( `embeddings must contain between 1 and ${MAX_EMBEDDING_BATCH} items` ) } const database = this.requireDatabase() const findChunk = database.prepare( 'SELECT content FROM chunks WHERE id = ? AND document_id = ?' ) const existingDimensions = database .prepare( `SELECT dimensions FROM embedding_rebuild_staging WHERE replacement_id = ? LIMIT 1` ) .get(normalizedReplacementId) let dimensions = existingDimensions ? asNumber(existingDimensions, 'dimensions') : undefined const normalized = embeddings.map((embedding, index) => { const chunkId = requiredString( embedding.chunkId, `embeddings[${index}].chunkId`, MAX_ID_LENGTH ) const chunk = findChunk.get(chunkId, normalizedDocumentId) if (!chunk) { throw new Error( 'Embeddings must reference chunks in the document' ) } const checksum = normalizedChecksum( embedding.contentChecksum, `embeddings[${index}].contentChecksum` ) if (checksum !== contentChecksum(asString(chunk, 'content'))) { throw new Error( 'Embedding content checksum does not match the chunk' ) } const vector = normalizeVector( embedding.vector, `embeddings[${index}].vector` ) if (dimensions === undefined) { dimensions = vector.dimensions } else if (dimensions !== vector.dimensions) { throw new Error( 'Document embeddings must have consistent dimensions' ) } return { chunkId, checksum, ...vector } }) const insert = database.prepare( `INSERT INTO embedding_rebuild_staging (replacement_id, document_id, provider, model, chunk_id, dimensions, content_checksum, vector, magnitude) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ) this.transaction(database, () => { for (const item of normalized) { insert.run( normalizedReplacementId, normalizedDocumentId, normalizedProvider, normalizedModel, item.chunkId, item.dimensions, item.checksum, item.bytes, item.magnitude ) } }) } finishDocumentEmbeddingReplacement( replacementId: string, documentId: string, provider: string, model: string ): EmbeddingIndexState { const normalizedReplacementId = requiredString( replacementId, 'replacementId', MAX_ID_LENGTH ) const normalizedDocumentId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const normalizedProvider = requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) const normalizedModel = requiredString( model, 'model', MAX_EMBEDDING_MODEL_LENGTH ) const database = this.requireDatabase() const document = database .prepare('SELECT knowledge_base_id FROM documents WHERE id = ?') .get(normalizedDocumentId) if (!document) { throw new Error(`Document not found: ${normalizedDocumentId}`) } const counts = database .prepare( `SELECT (SELECT COUNT(*) FROM chunks WHERE document_id = ?) AS chunks, (SELECT COUNT(*) FROM embedding_rebuild_staging WHERE replacement_id = ? AND document_id = ? AND provider = ? AND model = ?) AS embeddings` ) .get( normalizedDocumentId, normalizedReplacementId, normalizedDocumentId, normalizedProvider, normalizedModel ) if ( !counts || asNumber(counts, 'chunks') !== asNumber(counts, 'embeddings') ) { throw new Error('Embeddings must cover every current document chunk') } const indexHash = createHash('sha256') let dimensions: number | undefined let firstChecksum = true for (const row of database .prepare( `SELECT chunk_id, content_checksum, dimensions FROM embedding_rebuild_staging WHERE replacement_id = ? ORDER BY chunk_id` ) .iterate(normalizedReplacementId)) { const chunkId = asString(row, 'chunk_id') const checksum = asString(row, 'content_checksum') if (!firstChecksum) { indexHash.update('\n') } indexHash.update(`${chunkId}\0${checksum}`) firstChecksum = false const rowDimensions = asNumber(row, 'dimensions') if (dimensions === undefined) { dimensions = rowDimensions } else if (dimensions !== rowDimensions) { throw new Error( 'Document embeddings must have consistent dimensions' ) } } const now = new Date().toISOString() this.transaction(database, () => { database .prepare( `DELETE FROM chunk_embeddings WHERE provider = ? AND model = ? AND chunk_id IN (SELECT id FROM chunks WHERE document_id = ?)` ) .run(normalizedProvider, normalizedModel, normalizedDocumentId) database .prepare( `INSERT INTO chunk_embeddings (chunk_id, knowledge_base_id, provider, model, dimensions, content_checksum, vector, magnitude, created_at, updated_at) SELECT chunk_id, ?, provider, model, dimensions, content_checksum, vector, magnitude, ?, ? FROM embedding_rebuild_staging WHERE replacement_id = ?` ) .run( asString(document, 'knowledge_base_id'), now, now, normalizedReplacementId ) database .prepare( `INSERT INTO embedding_index_state (document_id, knowledge_base_id, provider, model, dimensions, content_checksum, status, last_error, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'ready', NULL, ?) ON CONFLICT(document_id, provider, model) DO UPDATE SET knowledge_base_id = excluded.knowledge_base_id, dimensions = excluded.dimensions, content_checksum = excluded.content_checksum, status = 'ready', last_error = NULL, updated_at = excluded.updated_at` ) .run( normalizedDocumentId, asString(document, 'knowledge_base_id'), normalizedProvider, normalizedModel, dimensions ?? null, indexHash.digest('hex'), now ) database .prepare( `DELETE FROM embedding_rebuild_staging WHERE replacement_id = ?` ) .run(normalizedReplacementId) }) return this.requiredEmbeddingIndexState( normalizedDocumentId, normalizedProvider, normalizedModel ) } discardDocumentEmbeddingReplacement(replacementId: string): void { this.requireDatabase() .prepare( `DELETE FROM embedding_rebuild_staging WHERE replacement_id = ?` ) .run( requiredString( replacementId, 'replacementId', MAX_ID_LENGTH ) ) } recordEmbeddingIndexError( documentId: string, provider: string, model: string, error: string ): EmbeddingIndexState { const normalizedDocumentId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const normalizedProvider = requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) const normalizedModel = requiredString( model, 'model', MAX_EMBEDDING_MODEL_LENGTH ) const normalizedError = requiredString( error, 'error', MAX_EMBEDDING_ERROR_LENGTH, false ) const database = this.requireDatabase() const document = database .prepare('SELECT knowledge_base_id FROM documents WHERE id = ?') .get(normalizedDocumentId) if (!document) { throw new Error(`Document not found: ${normalizedDocumentId}`) } database .prepare( `INSERT INTO embedding_index_state (document_id, knowledge_base_id, provider, model, dimensions, content_checksum, status, last_error, updated_at) VALUES (?, ?, ?, ?, NULL, '', 'error', ?, ?) ON CONFLICT(document_id, provider, model) DO UPDATE SET status = 'error', last_error = excluded.last_error, updated_at = excluded.updated_at` ) .run( normalizedDocumentId, asString(document, 'knowledge_base_id'), normalizedProvider, normalizedModel, normalizedError, new Date().toISOString() ) return this.requiredEmbeddingIndexState( normalizedDocumentId, normalizedProvider, normalizedModel ) } getEmbeddingIndexState( documentId: string, provider: string, model: string ): EmbeddingIndexState | undefined { const row = this.requireDatabase() .prepare( `SELECT * FROM embedding_index_state WHERE document_id = ? AND provider = ? AND model = ?` ) .get( requiredString(documentId, 'documentId', MAX_ID_LENGTH), requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ), requiredString(model, 'model', MAX_EMBEDDING_MODEL_LENGTH) ) return row ? mapEmbeddingIndexState(row) : undefined } getLastEmbeddingIndexJob(): EmbeddingIndexJob | null { const row = this.requireDatabase() .prepare( 'SELECT status_json FROM embedding_index_job WHERE singleton = 1' ) .get() if (!row) { return null } try { return embeddingIndexJobSchema.parse( JSON.parse(asString(row, 'status_json')) ) } catch { return null } } saveEmbeddingIndexJob(job: EmbeddingIndexJob | null): void { const database = this.requireDatabase() if (!job) { database .prepare('DELETE FROM embedding_index_job WHERE singleton = 1') .run() return } const normalized = embeddingIndexJobSchema.parse(job) database .prepare( `INSERT INTO embedding_index_job (singleton, status_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(singleton) DO UPDATE SET status_json = excluded.status_json, updated_at = excluded.updated_at` ) .run(JSON.stringify(normalized), new Date().toISOString()) } listEmbeddingIndexDocumentIds(): string[] { return this.requireDatabase() .prepare( `SELECT d.id FROM documents d WHERE json_extract(d.metadata, '$.status') IS NULL OR json_extract(d.metadata, '$.status') = 'ready' ORDER BY d.knowledge_base_id, d.id` ) .all() .map((document) => asString(document, 'id')) } getEmbeddingIndexDocument( documentId: string ): EmbeddingIndexDocument | undefined { const database = this.requireDatabase() const normalizedDocumentId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const document = database .prepare( `SELECT d.id FROM documents d WHERE d.id = ? AND (json_extract(d.metadata, '$.status') IS NULL OR json_extract(d.metadata, '$.status') = 'ready')` ) .get(normalizedDocumentId) if (!document) { return undefined } const chunks = database.prepare( `SELECT id, content FROM chunks WHERE document_id = ? ORDER BY ordinal ASC, id ASC` ) return { id: normalizedDocumentId, items: chunks.all(normalizedDocumentId).map((row) => { const content = asString(row, 'content') return { id: asString(row, 'id'), content, contentChecksum: contentChecksum(content) } }) } } vectorSearch(options: VectorSearchOptions): SearchResult[] { return this.vectorSearchScored(options).map((item) => item.result) } graphSearch( knowledgeBaseId: string, query: string, limit = 20, maximumDepth = 1 ): HybridSearchResult[] { boundedInteger(limit, 'limit', 1, 100) boundedInteger(maximumDepth, 'maximumDepth', 0, MAX_GRAPH_DEPTH) return this.graphSearchScored( requiredString(knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH), requiredString(query, 'query', 512), limit, maximumDepth ).map((item, index) => ({ ...item.result, retrieval: { score: 1 / (RRF_CONSTANT + index + 1), channels: ['graph'], graphRank: index + 1, evidenceIds: item.evidenceIds ?? [] } })) } hybridSearch(options: HybridSearchOptions): HybridSearchResult[] { const knowledgeBaseId = requiredString( options.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const query = requiredString(options.query, 'query', 512) const limit = options.limit ?? 20 boundedInteger(limit, 'limit', 1, 100) const lexical = this.search({ knowledgeBaseId, query, limit: Math.min(100, Math.max(limit * 4, limit)) }) const vector = options.vector && options.provider && options.model ? this.vectorSearchScored({ knowledgeBaseId, provider: options.provider, model: options.model, vector: options.vector, limit: options.vectorLimit ?? Math.min(100, limit * 4) }) : [] const graph = options.graphEnabled === false ? [] : this.graphSearchScored( knowledgeBaseId, query, Math.min(100, Math.max(limit * 4, limit)), boundedInteger( options.graphDepth ?? 1, 'graphDepth', 0, MAX_GRAPH_DEPTH ) ) const fused = new Map< string, { result: SearchResult score: number channels: Set<'fts' | 'vector' | 'graph'> lexicalRank?: number vectorRank?: number graphRank?: number similarity?: number evidenceIds: Set } >() const add = ( channel: 'fts' | 'vector' | 'graph', candidates: readonly ScoredSearchResult[], weight: number ): void => { candidates.forEach((candidate, index) => { const current = fused.get(candidate.result.chunk.id) ?? { result: candidate.result, score: 0, channels: new Set<'fts' | 'vector' | 'graph'>(), evidenceIds: new Set() } current.score += weight / (RRF_CONSTANT + index + 1) current.channels.add(channel) if (channel === 'fts') { current.lexicalRank = index + 1 } else if (channel === 'vector') { current.vectorRank = index + 1 current.similarity = candidate.similarity } else { current.graphRank = index + 1 for (const evidenceId of candidate.evidenceIds ?? []) { current.evidenceIds.add(evidenceId) } } fused.set(candidate.result.chunk.id, current) }) } add( 'fts', lexical.map((result) => ({ result })), 1 ) add('vector', vector, 1) add('graph', graph, 0.8) return [...fused.values()] .sort( (left, right) => right.score - left.score || left.result.chunk.id.localeCompare(right.result.chunk.id) ) .slice(0, limit) .map((item) => ({ ...item.result, rank: -item.score, retrieval: { score: item.score, channels: [...item.channels], lexicalRank: item.lexicalRank, vectorRank: item.vectorRank, graphRank: item.graphRank, similarity: item.similarity, evidenceIds: [...item.evidenceIds] } })) } search(options: SearchOptions): SearchResult[] { const knowledgeBaseId = requiredString( options.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const query = requiredString(options.query, 'query', 512) const limit = options.limit ?? 20 boundedInteger(limit, 'limit', 1, 100) const literalQuery = query .split(/\s+/u) .map((term) => `"${term.replaceAll('"', '""')}"`) .join(' ') let rows = this.requireDatabase() .prepare( `SELECT c.*, snippet(chunks_fts, 0, '', '', ' … ', 24) AS snippet, bm25(chunks_fts) AS rank, d.id AS d_id, d.knowledge_base_id AS d_knowledge_base_id, d.source_id AS d_source_id, d.external_id AS d_external_id, d.title AS d_title, d.mime_type AS d_mime_type, d.source_location AS d_source_location, d.checksum AS d_checksum, d.metadata AS d_metadata, d.created_at AS d_created_at, d.updated_at AS d_updated_at, s.id AS s_id, s.knowledge_base_id AS s_knowledge_base_id, s.type AS s_type, s.location AS s_location, s.display_name AS s_display_name, s.status AS s_status, s.last_error AS s_last_error, s.metadata AS s_metadata, s.created_at AS s_created_at, s.updated_at AS s_updated_at FROM chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid JOIN documents d ON d.id = c.document_id JOIN knowledge_sources s ON s.id = d.source_id WHERE chunks_fts MATCH ? AND c.knowledge_base_id = ? ORDER BY rank ASC LIMIT ?` ) .all(literalQuery, knowledgeBaseId, limit) if (rows.length === 0 && /\p{Script=Han}/u.test(query)) { const terms = [ ...new Set( [...query].filter((character) => /\p{Script=Han}/u.test(character)) ) ].slice(0, 24) const conditions = terms.map(() => 'c.content LIKE ?').join(' AND ') rows = this.requireDatabase() .prepare( `SELECT c.*, substr(c.content, 1, 600) AS snippet, 100 AS rank, d.id AS d_id, d.knowledge_base_id AS d_knowledge_base_id, d.source_id AS d_source_id, d.external_id AS d_external_id, d.title AS d_title, d.mime_type AS d_mime_type, d.source_location AS d_source_location, d.checksum AS d_checksum, d.metadata AS d_metadata, d.created_at AS d_created_at, d.updated_at AS d_updated_at, s.id AS s_id, s.knowledge_base_id AS s_knowledge_base_id, s.type AS s_type, s.location AS s_location, s.display_name AS s_display_name, s.status AS s_status, s.last_error AS s_last_error, s.metadata AS s_metadata, s.created_at AS s_created_at, s.updated_at AS s_updated_at FROM chunks c JOIN documents d ON d.id = c.document_id JOIN knowledge_sources s ON s.id = d.source_id WHERE c.knowledge_base_id = ? AND ${conditions} ORDER BY d.updated_at DESC, c.ordinal ASC LIMIT ?` ) .all( knowledgeBaseId, ...terms.map((term) => `%${term}%`), limit ) } return rows.map((row) => ({ chunk: mapChunk(row), document: mapDocument(this.prefixedRow(row, 'd_')), source: mapSource(this.prefixedRow(row, 's_')), snippet: asString(row, 'snippet'), rank: asNumber(row, 'rank') })) } createEntity(input: CreateGraphEntityInput): GraphEntity { const database = this.requireDatabase() const id = optionalString(input.id, 'id', MAX_ID_LENGTH) ?? randomUUID() const knowledgeBaseId = requiredString( input.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const name = requiredString(input.name, 'name', MAX_NAME_LENGTH) const type = requiredString(input.type, 'type', MAX_NAME_LENGTH) const aliases = JSON.stringify(stringArray(input.aliases, 'aliases')) const description = optionalString( input.description, 'description', MAX_CONTENT_LENGTH ) const properties = jsonObject(input.properties, 'properties') const now = new Date().toISOString() database .prepare( `INSERT INTO graph_entities (id, knowledge_base_id, name, type, aliases, description, properties, locked, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, knowledgeBaseId, name, type, aliases, description ?? null, properties, input.locked ? 1 : 0, now, now ) return this.requiredEntity(id) } getEntity(id: string): GraphEntity | undefined { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const row = this.requireDatabase() .prepare('SELECT * FROM graph_entities WHERE id = ?') .get(normalizedId) return row ? mapEntity(row) : undefined } listEntities( knowledgeBaseId: string, limit = MAX_LIST_LIMIT ): GraphEntity[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) return this.requireDatabase() .prepare( `SELECT * FROM graph_entities WHERE knowledge_base_id = ? ORDER BY name COLLATE NOCASE ASC, id ASC LIMIT ?` ) .all(normalizedId, limit) .map(mapEntity) } updateEntity(id: string, input: UpdateGraphEntityInput): GraphEntity { const current = this.requiredEntity(id) this.requireDatabase() .prepare( `UPDATE graph_entities SET name = ?, type = ?, aliases = ?, description = ?, properties = ?, locked = ?, updated_at = ? WHERE id = ?` ) .run( input.name === undefined ? current.name : requiredString(input.name, 'name', MAX_NAME_LENGTH), input.type === undefined ? current.type : requiredString(input.type, 'type', MAX_NAME_LENGTH), JSON.stringify( input.aliases === undefined ? current.aliases : stringArray(input.aliases, 'aliases') ), input.description === undefined ? (current.description ?? null) : (optionalString( input.description, 'description', MAX_CONTENT_LENGTH ) ?? null), input.properties === undefined ? JSON.stringify(current.properties) : jsonObject(input.properties, 'properties'), (input.locked ?? current.locked) ? 1 : 0, new Date().toISOString(), current.id ) return this.requiredEntity(current.id) } deleteEntity(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) return ( this.requireDatabase() .prepare('DELETE FROM graph_entities WHERE id = ?') .run(normalizedId).changes > 0 ) } createRelation(input: CreateGraphRelationInput): GraphRelation { const database = this.requireDatabase() const id = optionalString(input.id, 'id', MAX_ID_LENGTH) ?? randomUUID() const knowledgeBaseId = requiredString( input.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const sourceEntityId = requiredString( input.sourceEntityId, 'sourceEntityId', MAX_ID_LENGTH ) const targetEntityId = requiredString( input.targetEntityId, 'targetEntityId', MAX_ID_LENGTH ) const type = requiredString(input.type, 'type', MAX_NAME_LENGTH) const label = optionalString(input.label, 'label', MAX_NAME_LENGTH) const properties = jsonObject(input.properties, 'properties') const now = new Date().toISOString() this.assertRelationEntities( database, knowledgeBaseId, sourceEntityId, targetEntityId ) database .prepare( `INSERT INTO graph_relations (id, knowledge_base_id, source_entity_id, target_entity_id, type, label, properties, locked, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, knowledgeBaseId, sourceEntityId, targetEntityId, type, label ?? null, properties, input.locked ? 1 : 0, now, now ) return this.requiredRelation(id) } getRelation(id: string): GraphRelation | undefined { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const row = this.requireDatabase() .prepare('SELECT * FROM graph_relations WHERE id = ?') .get(normalizedId) return row ? mapRelation(row) : undefined } listRelations( knowledgeBaseId: string, limit = MAX_LIST_LIMIT ): GraphRelation[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) return this.requireDatabase() .prepare( `SELECT * FROM graph_relations WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC LIMIT ?` ) .all(normalizedId, limit) .map(mapRelation) } updateRelation( id: string, input: UpdateGraphRelationInput ): GraphRelation { const current = this.requiredRelation(id) const sourceEntityId = input.sourceEntityId === undefined ? current.sourceEntityId : requiredString( input.sourceEntityId, 'sourceEntityId', MAX_ID_LENGTH ) const targetEntityId = input.targetEntityId === undefined ? current.targetEntityId : requiredString( input.targetEntityId, 'targetEntityId', MAX_ID_LENGTH ) const database = this.requireDatabase() this.assertRelationEntities( database, current.knowledgeBaseId, sourceEntityId, targetEntityId ) database .prepare( `UPDATE graph_relations SET source_entity_id = ?, target_entity_id = ?, type = ?, label = ?, properties = ?, locked = ?, updated_at = ? WHERE id = ?` ) .run( sourceEntityId, targetEntityId, input.type === undefined ? current.type : requiredString(input.type, 'type', MAX_NAME_LENGTH), input.label === undefined ? (current.label ?? null) : (optionalString(input.label, 'label', MAX_NAME_LENGTH) ?? null), input.properties === undefined ? JSON.stringify(current.properties) : jsonObject(input.properties, 'properties'), (input.locked ?? current.locked) ? 1 : 0, new Date().toISOString(), current.id ) return this.requiredRelation(current.id) } deleteRelation(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) return ( this.requireDatabase() .prepare('DELETE FROM graph_relations WHERE id = ?') .run(normalizedId).changes > 0 ) } createEvidence(input: CreateEvidenceInput): Evidence { const database = this.requireDatabase() const id = optionalString(input.id, 'id', MAX_ID_LENGTH) ?? randomUUID() const knowledgeBaseId = requiredString( input.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const entityId = optionalString( input.entityId, 'entityId', MAX_ID_LENGTH ) const relationId = optionalString( input.relationId, 'relationId', MAX_ID_LENGTH ) const documentId = requiredString( input.documentId, 'documentId', MAX_ID_LENGTH ) const chunkId = optionalString(input.chunkId, 'chunkId', MAX_ID_LENGTH) const quote = optionalString( input.quote, 'quote', 32_768, false ) const location = optionalString( input.location, 'location', MAX_LOCATION_LENGTH, false ) this.assertEvidenceTargets(database, { knowledgeBaseId, entityId, relationId, documentId, chunkId }) database .prepare( `INSERT INTO graph_evidence (id, knowledge_base_id, entity_id, relation_id, document_id, chunk_id, quote, location, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, knowledgeBaseId, entityId ?? null, relationId ?? null, documentId, chunkId ?? null, quote ?? null, location ?? null, new Date().toISOString() ) return this.requiredEvidence(id) } listEvidence( knowledgeBaseId: string, limit = MAX_LIST_LIMIT ): Evidence[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) return this.requireDatabase() .prepare( `SELECT * FROM graph_evidence WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC LIMIT ?` ) .all(normalizedId, limit) .map(mapEvidence) } updateEvidence(id: string, input: UpdateEvidenceInput): Evidence { const current = this.requiredEvidence(id) const next = { knowledgeBaseId: current.knowledgeBaseId, entityId: input.entityId === undefined ? current.entityId : optionalString(input.entityId, 'entityId', MAX_ID_LENGTH), relationId: input.relationId === undefined ? current.relationId : optionalString(input.relationId, 'relationId', MAX_ID_LENGTH), documentId: input.documentId === undefined ? current.documentId : requiredString(input.documentId, 'documentId', MAX_ID_LENGTH), chunkId: input.chunkId === undefined ? current.chunkId : optionalString(input.chunkId, 'chunkId', MAX_ID_LENGTH) } const database = this.requireDatabase() this.assertEvidenceTargets(database, next) database .prepare( `UPDATE graph_evidence SET entity_id = ?, relation_id = ?, document_id = ?, chunk_id = ?, quote = ?, location = ? WHERE id = ?` ) .run( next.entityId ?? null, next.relationId ?? null, next.documentId, next.chunkId ?? null, input.quote === undefined ? (current.quote ?? null) : (optionalString(input.quote, 'quote', 32_768, false) ?? null), input.location === undefined ? (current.location ?? null) : (optionalString( input.location, 'location', MAX_LOCATION_LENGTH, false ) ?? null), current.id ) return this.requiredEvidence(current.id) } deleteEvidence(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) return ( this.requireDatabase() .prepare('DELETE FROM graph_evidence WHERE id = ?') .run(normalizedId).changes > 0 ) } mergeEntities(targetEntityId: string, sourceEntityId: string): GraphEntity { const target = this.requiredEntity(targetEntityId) const source = this.requiredEntity(sourceEntityId) if (target.id === source.id) { throw new Error('Cannot merge an entity into itself') } if (target.knowledgeBaseId !== source.knowledgeBaseId) { throw new Error('Entities must belong to the same knowledge base') } const aliases = stringArray( [ ...target.aliases, source.name, ...source.aliases.filter((alias) => alias !== target.name) ], 'merged aliases' ) const properties = { ...source.properties, ...target.properties } const database = this.requireDatabase() this.transaction(database, () => { database .prepare( `UPDATE graph_relations SET source_entity_id = ? WHERE source_entity_id = ?` ) .run(target.id, source.id) database .prepare( `UPDATE graph_relations SET target_entity_id = ? WHERE target_entity_id = ?` ) .run(target.id, source.id) database .prepare( `UPDATE graph_evidence SET entity_id = ? WHERE entity_id = ?` ) .run(target.id, source.id) database .prepare( `UPDATE graph_entities SET aliases = ?, description = ?, properties = ?, locked = ?, updated_at = ? WHERE id = ?` ) .run( JSON.stringify(aliases), target.description ?? source.description ?? null, jsonObject(properties, 'merged properties'), target.locked || source.locked ? 1 : 0, new Date().toISOString(), target.id ) database .prepare('DELETE FROM graph_entities WHERE id = ?') .run(source.id) }) return this.requiredEntity(target.id) } private vectorSearchScored( options: VectorSearchOptions ): ScoredSearchResult[] { const knowledgeBaseId = requiredString( options.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const provider = requiredString( options.provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) const model = requiredString( options.model, 'model', MAX_EMBEDDING_MODEL_LENGTH ) const queryVector = normalizeVector(options.vector, 'vector') const limit = options.limit ?? 20 boundedInteger(limit, 'limit', 1, 100) const minimumSimilarity = options.minimumSimilarity ?? -1 if ( typeof minimumSimilarity !== 'number' || !Number.isFinite(minimumSimilarity) || minimumSimilarity < -1 || minimumSimilarity > 1 ) { throw new RangeError('minimumSimilarity must be between -1 and 1') } const rows = this.requireDatabase() .prepare( `SELECT ce.chunk_id, ce.vector AS embedding_vector, ce.dimensions AS embedding_dimensions, ce.magnitude AS embedding_magnitude FROM chunk_embeddings ce JOIN embedding_index_state eis ON eis.knowledge_base_id = ce.knowledge_base_id AND eis.provider = ce.provider AND eis.model = ce.model AND eis.dimensions = ce.dimensions AND eis.status = 'ready' JOIN chunks c ON c.id = ce.chunk_id AND c.document_id = eis.document_id AND c.knowledge_base_id = ce.knowledge_base_id WHERE ce.knowledge_base_id = ? AND ce.provider = ? AND ce.model = ? AND ce.dimensions = ? AND length(ce.vector) = ce.dimensions * 4 AND ce.content_checksum <> '' ORDER BY ce.chunk_id ASC LIMIT ?` ) .all( knowledgeBaseId, provider, model, queryVector.dimensions, MAX_VECTOR_CANDIDATES + 1 ) if (rows.length > MAX_VECTOR_CANDIDATES) { return [] } const winners = rows .map((row): { chunkId: string; similarity: number } | undefined => { const similarity = cosineSimilarity( queryVector.values, queryVector.magnitude, asBytes(row, 'embedding_vector'), asNumber(row, 'embedding_dimensions'), asNumber(row, 'embedding_magnitude') ) if (similarity === undefined || similarity < minimumSimilarity) { return undefined } return { chunkId: asString(row, 'chunk_id'), similarity } }) .filter( (item): item is { chunkId: string; similarity: number } => item !== undefined ) .sort( (left, right) => right.similarity - left.similarity || left.chunkId.localeCompare(right.chunkId) ) .slice(0, limit) if (winners.length === 0) { return [] } const placeholders = winners.map(() => '?').join(', ') const hydratedRows = this.requireDatabase() .prepare( `SELECT c.*, substr(c.content, 1, 600) AS snippet, d.id AS d_id, d.knowledge_base_id AS d_knowledge_base_id, d.source_id AS d_source_id, d.external_id AS d_external_id, d.title AS d_title, d.mime_type AS d_mime_type, d.source_location AS d_source_location, d.checksum AS d_checksum, d.metadata AS d_metadata, d.created_at AS d_created_at, d.updated_at AS d_updated_at, s.id AS s_id, s.knowledge_base_id AS s_knowledge_base_id, s.type AS s_type, s.location AS s_location, s.display_name AS s_display_name, s.status AS s_status, s.last_error AS s_last_error, s.metadata AS s_metadata, s.created_at AS s_created_at, s.updated_at AS s_updated_at FROM chunks c JOIN documents d ON d.id = c.document_id JOIN knowledge_sources s ON s.id = d.source_id WHERE c.id IN (${placeholders}) AND c.knowledge_base_id = ? AND d.knowledge_base_id = ? AND s.knowledge_base_id = ?` ) .all( ...winners.map((winner) => winner.chunkId), knowledgeBaseId, knowledgeBaseId, knowledgeBaseId ) as Row[] const rowsByChunkId = new Map( hydratedRows.map((row) => [asString(row, 'id'), row]) ) return winners.flatMap((winner) => { const row = rowsByChunkId.get(winner.chunkId) return row ? [ { similarity: winner.similarity, result: { chunk: mapChunk(row), document: mapDocument(this.prefixedRow(row, 'd_')), source: mapSource(this.prefixedRow(row, 's_')), snippet: asString(row, 'snippet'), rank: -winner.similarity } } ] : [] }) } private graphSearchScored( knowledgeBaseId: string, query: string, limit: number, maximumDepth: number ): ScoredSearchResult[] { const terms = [ ...new Set( [query, ...query.split(/[^\p{L}\p{N}_.$/@-]+/u)] .map((term) => term.normalize('NFKC').trim().toLowerCase()) .filter((term) => term.length > 1) ) ] .sort((left, right) => right.length - left.length) .slice(0, 8) if (terms.length === 0) { return [] } const conditions = terms .map( () => `(lower(ge.name) LIKE ? ESCAPE '\\' OR lower(ge.aliases) LIKE ? ESCAPE '\\' OR lower(ge.type) LIKE ? ESCAPE '\\')` ) .join(' OR ') const patterns = terms.flatMap((term) => { const escaped = term.replaceAll('\\', '\\\\').replaceAll('%', '\\%') .replaceAll('_', '\\_') return [`%${escaped}%`, `%${escaped}%`, `%${escaped}%`] }) const rows = this.requireDatabase() .prepare( `WITH RECURSIVE seed(id, depth) AS ( SELECT ge.id, 0 FROM graph_entities ge WHERE ge.knowledge_base_id = ? AND (${conditions}) AND EXISTS ( SELECT 1 FROM graph_evidence ev JOIN chunks ec ON ec.id = ev.chunk_id WHERE ev.entity_id = ge.id AND ev.knowledge_base_id = ge.knowledge_base_id AND ec.knowledge_base_id = ge.knowledge_base_id AND ec.document_id = ev.document_id ) ORDER BY ge.name COLLATE NOCASE ASC, ge.id ASC LIMIT 24 ), reachable(id, depth) AS ( SELECT id, depth FROM seed UNION SELECT CASE WHEN gr.source_entity_id = reachable.id THEN gr.target_entity_id ELSE gr.source_entity_id END, reachable.depth + 1 FROM reachable JOIN graph_relations gr ON gr.knowledge_base_id = ? AND (gr.source_entity_id = reachable.id OR gr.target_entity_id = reachable.id) WHERE reachable.depth < ? AND EXISTS ( SELECT 1 FROM graph_evidence rev JOIN chunks rc ON rc.id = rev.chunk_id WHERE rev.relation_id = gr.id AND rev.knowledge_base_id = gr.knowledge_base_id AND rc.knowledge_base_id = gr.knowledge_base_id AND rc.document_id = rev.document_id ) AND EXISTS ( SELECT 1 FROM graph_evidence nev JOIN chunks nc ON nc.id = nev.chunk_id WHERE nev.entity_id = CASE WHEN gr.source_entity_id = reachable.id THEN gr.target_entity_id ELSE gr.source_entity_id END AND nev.knowledge_base_id = gr.knowledge_base_id AND nc.knowledge_base_id = gr.knowledge_base_id AND nc.document_id = nev.document_id ) ), reached(id, depth) AS ( SELECT id, MIN(depth) FROM reachable GROUP BY id ), backed_evidence AS ( SELECT ev.*, reached.depth AS graph_depth FROM graph_evidence ev JOIN reached ON reached.id = ev.entity_id WHERE ev.knowledge_base_id = ? AND ev.chunk_id IS NOT NULL UNION ALL SELECT ev.*, MAX(source.depth, target.depth) AS graph_depth FROM graph_evidence ev JOIN graph_relations gr ON gr.id = ev.relation_id JOIN reached source ON source.id = gr.source_entity_id JOIN reached target ON target.id = gr.target_entity_id WHERE ev.knowledge_base_id = ? AND gr.knowledge_base_id = ? AND ev.chunk_id IS NOT NULL ) SELECT c.*, substr(c.content, 1, 600) AS snippet, 200 + MIN(backed_evidence.graph_depth) AS rank, group_concat(DISTINCT backed_evidence.id) AS evidence_ids, d.id AS d_id, d.knowledge_base_id AS d_knowledge_base_id, d.source_id AS d_source_id, d.external_id AS d_external_id, d.title AS d_title, d.mime_type AS d_mime_type, d.source_location AS d_source_location, d.checksum AS d_checksum, d.metadata AS d_metadata, d.created_at AS d_created_at, d.updated_at AS d_updated_at, s.id AS s_id, s.knowledge_base_id AS s_knowledge_base_id, s.type AS s_type, s.location AS s_location, s.display_name AS s_display_name, s.status AS s_status, s.last_error AS s_last_error, s.metadata AS s_metadata, s.created_at AS s_created_at, s.updated_at AS s_updated_at FROM backed_evidence JOIN chunks c ON c.id = backed_evidence.chunk_id AND c.document_id = backed_evidence.document_id JOIN documents d ON d.id = c.document_id JOIN knowledge_sources s ON s.id = d.source_id WHERE c.knowledge_base_id = ? AND d.knowledge_base_id = ? AND s.knowledge_base_id = ? GROUP BY c.id ORDER BY MIN(backed_evidence.graph_depth) ASC, c.id ASC LIMIT ?` ) .all( knowledgeBaseId, ...patterns, knowledgeBaseId, maximumDepth, knowledgeBaseId, knowledgeBaseId, knowledgeBaseId, knowledgeBaseId, knowledgeBaseId, knowledgeBaseId, limit ) return rows.map((row) => ({ evidenceIds: asString(row, 'evidence_ids').split(','), result: { chunk: mapChunk(row), document: mapDocument(this.prefixedRow(row, 'd_')), source: mapSource(this.prefixedRow(row, 's_')), snippet: asString(row, 'snippet'), rank: asNumber(row, 'rank') } })) } private assertFts5(database: DatabaseSync): void { try { database.exec(` CREATE VIRTUAL TABLE temp.goodbuddy_fts5_probe USING fts5(value); DROP TABLE temp.goodbuddy_fts5_probe; `) } catch (error) { throw new Error( 'GoodBuddy knowledge database requires SQLite with FTS5 support', { cause: error } ) } } private migrate(database: DatabaseSync): void { database.exec('BEGIN IMMEDIATE') try { database.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL ); `) const row = database .prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations') .get() const currentVersion = row ? asNumber(row, 'version') : 0 if (currentVersion > DATABASE_VERSION) { throw new Error( `Knowledge database version ${currentVersion} is newer than supported version ${DATABASE_VERSION}` ) } if (currentVersion < 1) { this.migrateToVersion1(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(1, new Date().toISOString()) } if (currentVersion < 2) { this.migrateToVersion2(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(2, new Date().toISOString()) } if (currentVersion < 3) { this.migrateToVersion3(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(3, new Date().toISOString()) } if (currentVersion < 4) { this.migrateToVersion4(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(4, new Date().toISOString()) } database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } private migrateToVersion1(database: DatabaseSync): void { database.exec(` CREATE TABLE knowledge_bases ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, storage_mode TEXT NOT NULL CHECK (storage_mode IN ('reference', 'managed')), graph_enabled INTEGER NOT NULL CHECK (graph_enabled IN (0, 1)), graph_strategy TEXT NOT NULL CHECK (graph_strategy IN ('rules', 'model', 'hybrid', 'ask')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE knowledge_sources ( id TEXT PRIMARY KEY, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('file', 'directory', 'url')), location TEXT NOT NULL, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'indexing', 'ready', 'paused', 'error')), last_error TEXT, metadata TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (knowledge_base_id, type, location) ); CREATE INDEX knowledge_sources_base_idx ON knowledge_sources(knowledge_base_id); CREATE TABLE documents ( id TEXT PRIMARY KEY, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, source_id TEXT NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE, external_id TEXT NOT NULL, title TEXT NOT NULL, mime_type TEXT, source_location TEXT, checksum TEXT, metadata TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (source_id, external_id) ); CREATE INDEX documents_base_idx ON documents(knowledge_base_id); CREATE TABLE chunks ( id TEXT PRIMARY KEY, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, ordinal INTEGER NOT NULL CHECK (ordinal >= 0), content TEXT NOT NULL, token_count INTEGER CHECK (token_count IS NULL OR token_count >= 0), heading TEXT, location TEXT, metadata TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (document_id, ordinal) ); CREATE INDEX chunks_base_idx ON chunks(knowledge_base_id); CREATE VIRTUAL TABLE chunks_fts USING fts5( content, content='chunks', content_rowid='rowid', tokenize='unicode61' ); CREATE TRIGGER chunks_after_insert AFTER INSERT ON chunks BEGIN INSERT INTO chunks_fts(rowid, content) VALUES (new.rowid, new.content); END; CREATE TRIGGER chunks_after_delete AFTER DELETE ON chunks BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.rowid, old.content); END; CREATE TRIGGER chunks_after_update AFTER UPDATE ON chunks BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.rowid, old.content); INSERT INTO chunks_fts(rowid, content) VALUES (new.rowid, new.content); END; CREATE TABLE graph_entities ( id TEXT PRIMARY KEY, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL, aliases TEXT NOT NULL, description TEXT, properties TEXT NOT NULL, locked INTEGER NOT NULL CHECK (locked IN (0, 1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX graph_entities_base_idx ON graph_entities(knowledge_base_id); CREATE TABLE graph_relations ( id TEXT PRIMARY KEY, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, source_entity_id TEXT NOT NULL REFERENCES graph_entities(id) ON DELETE CASCADE, target_entity_id TEXT NOT NULL REFERENCES graph_entities(id) ON DELETE CASCADE, type TEXT NOT NULL, label TEXT, properties TEXT NOT NULL, locked INTEGER NOT NULL CHECK (locked IN (0, 1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX graph_relations_base_idx ON graph_relations(knowledge_base_id); CREATE TABLE graph_evidence ( id TEXT PRIMARY KEY, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, entity_id TEXT REFERENCES graph_entities(id) ON DELETE CASCADE, relation_id TEXT REFERENCES graph_relations(id) ON DELETE CASCADE, document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, chunk_id TEXT REFERENCES chunks(id) ON DELETE SET NULL, quote TEXT, location TEXT, created_at TEXT NOT NULL, CHECK (entity_id IS NOT NULL OR relation_id IS NOT NULL) ); CREATE INDEX graph_evidence_base_idx ON graph_evidence(knowledge_base_id); `) } private migrateToVersion2(database: DatabaseSync): void { database.exec(` CREATE TABLE chunk_embeddings ( chunk_id TEXT NOT NULL REFERENCES chunks(id) ON DELETE CASCADE, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, provider TEXT NOT NULL, model TEXT NOT NULL, dimensions INTEGER NOT NULL CHECK (dimensions >= 1 AND dimensions <= 8192), content_checksum TEXT NOT NULL CHECK (length(content_checksum) = 64), vector BLOB NOT NULL CHECK (length(vector) = dimensions * 4), magnitude REAL NOT NULL CHECK (magnitude > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (chunk_id, provider, model) ); CREATE INDEX chunk_embeddings_lookup_idx ON chunk_embeddings( knowledge_base_id, provider, model, dimensions, chunk_id ); CREATE TABLE embedding_index_state ( document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, knowledge_base_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, provider TEXT NOT NULL, model TEXT NOT NULL, dimensions INTEGER CHECK (dimensions IS NULL OR (dimensions >= 1 AND dimensions <= 8192)), content_checksum TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('ready', 'error')), last_error TEXT, updated_at TEXT NOT NULL, PRIMARY KEY (document_id, provider, model) ); CREATE INDEX embedding_index_state_lookup_idx ON embedding_index_state( knowledge_base_id, provider, model, status, document_id ); `) } private migrateToVersion3(database: DatabaseSync): void { database.exec(` CREATE TABLE embedding_index_job ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), status_json TEXT NOT NULL CHECK (length(status_json) <= 32768), updated_at TEXT NOT NULL ); `) } private migrateToVersion4(database: DatabaseSync): void { database.exec(` CREATE TABLE embedding_rebuild_staging ( replacement_id TEXT NOT NULL, document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, provider TEXT NOT NULL, model TEXT NOT NULL, chunk_id TEXT NOT NULL REFERENCES chunks(id) ON DELETE CASCADE, dimensions INTEGER NOT NULL CHECK (dimensions >= 1 AND dimensions <= 8192), content_checksum TEXT NOT NULL CHECK (length(content_checksum) = 64), vector BLOB NOT NULL, magnitude REAL NOT NULL CHECK (magnitude > 0), PRIMARY KEY (replacement_id, chunk_id) ); CREATE INDEX embedding_rebuild_staging_document_idx ON embedding_rebuild_staging( document_id, provider, model, replacement_id ); `) } private normalizeChunks(chunks: ReplaceChunkInput[]): Array<{ id: string ordinal: number content: string tokenCount?: number heading?: string location?: string metadata: string }> { let totalContent = 0 const ordinals = new Set() const ids = new Set() return chunks.map((chunk, index) => { const id = optionalString(chunk.id, `chunks[${index}].id`, MAX_ID_LENGTH) ?? randomUUID() if (ids.has(id)) { throw new Error('Chunk IDs must be unique') } ids.add(id) const ordinal = boundedInteger( chunk.ordinal, `chunks[${index}].ordinal`, 0, MAX_CHUNKS - 1 ) if (ordinals.has(ordinal)) { throw new Error('Chunk ordinals must be unique') } ordinals.add(ordinal) const content = requiredString( chunk.content, `chunks[${index}].content`, MAX_CONTENT_LENGTH, false ) totalContent += content.length if (totalContent > MAX_CHUNK_BATCH_CONTENT) { throw new RangeError( `chunk content must total at most ${MAX_CHUNK_BATCH_CONTENT} characters` ) } return { id, ordinal, content, tokenCount: chunk.tokenCount === undefined ? undefined : boundedInteger( chunk.tokenCount, `chunks[${index}].tokenCount`, 0, 100_000_000 ), heading: optionalString( chunk.heading, `chunks[${index}].heading`, MAX_NAME_LENGTH ), location: optionalString( chunk.location, `chunks[${index}].location`, MAX_LOCATION_LENGTH, false ), metadata: jsonObject(chunk.metadata, `chunks[${index}].metadata`) } }) } private assertRelationEntities( database: DatabaseSync, knowledgeBaseId: string, sourceEntityId: string, targetEntityId: string ): void { const count = database .prepare( `SELECT COUNT(*) AS count FROM graph_entities WHERE knowledge_base_id = ? AND id IN (?, ?)` ) .get(knowledgeBaseId, sourceEntityId, targetEntityId) const expected = sourceEntityId === targetEntityId ? 1 : 2 if (!count || asNumber(count, 'count') !== expected) { throw new Error('Relation entities must belong to the relation knowledge base') } } private assertEvidenceTargets( database: DatabaseSync, value: { knowledgeBaseId: string entityId?: string relationId?: string documentId: string chunkId?: string } ): void { if (!value.entityId && !value.relationId) { throw new Error('Evidence must reference an entity or relation') } const matches = ( statement: StatementSync, id: string | undefined ): boolean => id === undefined || asNumber( statement.get(id, value.knowledgeBaseId) as Row, 'count' ) === 1 const chunkMatches = value.chunkId === undefined || asNumber( database .prepare( `SELECT COUNT(*) AS count FROM chunks WHERE id = ? AND knowledge_base_id = ? AND document_id = ?` ) .get(value.chunkId, value.knowledgeBaseId, value.documentId) as Row, 'count' ) === 1 if ( !matches( database.prepare( 'SELECT COUNT(*) AS count FROM graph_entities WHERE id = ? AND knowledge_base_id = ?' ), value.entityId ) || !matches( database.prepare( 'SELECT COUNT(*) AS count FROM graph_relations WHERE id = ? AND knowledge_base_id = ?' ), value.relationId ) || !matches( database.prepare( 'SELECT COUNT(*) AS count FROM documents WHERE id = ? AND knowledge_base_id = ?' ), value.documentId ) || !chunkMatches ) { throw new Error('Evidence targets must belong to the evidence knowledge base') } } private prefixedRow(row: Row, prefix: string): Row { const result: Row = {} for (const [key, value] of Object.entries(row)) { if (key.startsWith(prefix)) { result[key.slice(prefix.length)] = value } } return result } private transaction(database: DatabaseSync, operation: () => void): void { database.exec('BEGIN IMMEDIATE') try { operation() database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } } private requireDatabase(): DatabaseSync { if (!this.database) { throw new Error('Knowledge database is not initialized') } return this.database } private requiredKnowledgeBase(id: string): KnowledgeBase { const value = this.getKnowledgeBase(id) if (!value) { throw new Error(`Knowledge base not found: ${id}`) } return value } private requiredSource(id: string): KnowledgeSource { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const row = this.requireDatabase() .prepare('SELECT * FROM knowledge_sources WHERE id = ?') .get(normalizedId) if (!row) { throw new Error(`Knowledge source not found: ${normalizedId}`) } return mapSource(row) } private requiredDocument(id: string): Document { const value = this.getDocument(id) if (!value) { throw new Error(`Document not found: ${id}`) } return value } private requiredEntity(id: string): GraphEntity { const value = this.getEntity(id) if (!value) { throw new Error(`Graph entity not found: ${id}`) } return value } private requiredRelation(id: string): GraphRelation { const value = this.getRelation(id) if (!value) { throw new Error(`Graph relation not found: ${id}`) } return value } private requiredEvidence(id: string): Evidence { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const row = this.requireDatabase() .prepare('SELECT * FROM graph_evidence WHERE id = ?') .get(normalizedId) if (!row) { throw new Error(`Graph evidence not found: ${normalizedId}`) } return mapEvidence(row) } private requiredEmbeddingIndexState( documentId: string, provider: string, model: string ): EmbeddingIndexState { const value = this.getEmbeddingIndexState(documentId, provider, model) if (!value) { throw new Error(`Embedding index state not found: ${documentId}`) } return value } }