import { createHash, randomUUID } from 'node:crypto' import { DatabaseSync, type StatementSync } from 'node:sqlite' import { embeddingIndexJobSchema, type EmbeddingIndexJob } from '../../shared/embedding-contracts' import { defaultKnowledgeChunkingSettings, defaultKnowledgeRetrievalSettings, knowledgeChunkingSettingsSchema, knowledgeChunkRoleSchema, knowledgeRetrievalSettingsSchema, type KnowledgeChunkRole, type KnowledgeChunksListInput, type KnowledgeSettingsUpdateInput } from '../../shared/knowledge-contracts' import { defaultKnowledgeOntologySettings, isRelationEndpointAllowed, knowledgeOntologySettingsSchema, normalizeEntityTypeAlias, normalizeOntologyAlias, normalizeRelationTypeAlias } from '../../shared/knowledge-ontology' import type { KnowledgeTaskError, KnowledgeTaskItem, KnowledgeTaskKind, KnowledgeTaskScope, KnowledgeTaskStage, KnowledgeTaskStatus } from '../../shared/knowledge-task-contracts' import { knowledgeTaskKindSchema, knowledgeTaskScopeSchema, knowledgeTaskStageSchema, knowledgeTaskStatusSchema } from '../../shared/knowledge-task-contracts' import type { EmbeddingIndexDocument } from './embedding-index-coordinator' import { containsHanText, contextualIndexText, createCjkSearchText, knowledgeRetrievalTerms } from './retrieval-text' import { normalizeEntityAlias } from './graph-extractor' import type { Chunk, ChunkEmbeddingInput, CreateEvidenceInput, CreateGraphEntityInput, CreateGraphRelationInput, CreateKnowledgeBaseInput, Document, EmbeddingIndexCoverage, 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 = 11 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 maximumFilesPerSourceDatabaseLimit = 2_000 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_TASK_TEXT_LENGTH = 1_000 const MAX_TASK_DEDUPE_KEY_LENGTH = 512 const MAX_TASK_JOB_ID_LENGTH = 256 const MAX_TERMINAL_TASKS_PER_LIBRARY = 500 const MAX_GRAPH_DEPTH = 3 const RRF_CONSTANT = 60 type ScoredSearchResult = { result: SearchResult similarity?: number evidenceIds?: string[] } export type HybridSearchResultPage = { results: HybridSearchResult[] vectorScannedCount: number } export type PreparedEmbeddingReplacement = { replacementId: string provider: string model: string } export type DocumentPublicationOptions = { embeddingReplacement?: PreparedEmbeddingReplacement embeddingError?: { provider: string model: string message: string } afterChunksInserted?: (document: Document) => void } type Row = Record const taskScopes = knowledgeTaskScopeSchema.options const taskKinds = knowledgeTaskKindSchema.options const taskStages = knowledgeTaskStageSchema.options const taskStatuses = knowledgeTaskStatusSchema.options const activeTaskStatuses: readonly KnowledgeTaskStatus[] = [ 'queued', 'running' ] const terminalTaskStatuses: readonly KnowledgeTaskStatus[] = [ 'succeeded', 'failed', 'cancelled', 'skipped', 'interrupted' ] export type CreateKnowledgeTaskInput = { id?: string libraryId: string parentTaskId?: string retryOfTaskId?: string sourceId?: string documentId?: string documentName: string scope: KnowledgeTaskScope kind: KnowledgeTaskKind stage?: KnowledgeTaskStage status?: KnowledgeTaskStatus progress?: number completedItems?: number totalItems?: number message?: string error?: KnowledgeTaskError attempt?: number dedupeKey?: string embeddingJobId?: string } export type UpdateKnowledgeTaskInput = { sourceId?: string | null documentId?: string | null documentName?: string stage?: KnowledgeTaskStage status?: KnowledgeTaskStatus progress?: number completedItems?: number | null totalItems?: number | null message?: string | null error?: KnowledgeTaskError | null embeddingJobId?: string | null } 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 truncatedOptionalString( value: string | null | undefined, maximum: number ): string | undefined { if (value === undefined || value === null || value === '') { return undefined } const normalized = String(value).trim() return normalized ? normalized.slice(0, maximum) : undefined } 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 parseSettings( value: string | null | number | bigint | Uint8Array | undefined, schema: { parse(input: unknown): T }, fallback: T ): T { if (typeof value !== 'string') { return fallback } try { return schema.parse(JSON.parse(value)) } catch { return fallback } } 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, retrievalSettings: parseSettings( row.retrieval_settings, knowledgeRetrievalSettingsSchema, defaultKnowledgeRetrievalSettings ), chunkingSettings: parseSettings( row.chunking_settings, knowledgeChunkingSettingsSchema, defaultKnowledgeChunkingSettings ), chunkingRebuildRequired: row.chunking_rebuild_required === undefined ? false : asNumber(row, 'chunking_rebuild_required') === 1, ontologySettings: parseSettings( row.ontology_settings, knowledgeOntologySettingsSchema, defaultKnowledgeOntologySettings ), ontologyRebuildRequired: row.ontology_rebuild_required === undefined ? false : asNumber(row, 'ontology_rebuild_required') === 1, 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'), enabled: row.enabled === undefined ? true : asNumber(row, 'enabled') === 1, role: row.role === undefined ? 'standalone' : knowledgeChunkRoleSchema.parse(asString(row, 'role')), parentChunkId: row.parent_chunk_id === undefined ? undefined : asOptionalString(row, 'parent_chunk_id'), manuallyEdited: row.manually_edited === undefined ? false : asNumber(row, 'manually_edited') === 1, updatedAt: row.updated_at === undefined ? undefined : asOptionalString(row, 'updated_at') } } function chunkIndexContent( content: string, metadata: JsonObject ): string { return contextualIndexText(content, metadata.contextPrefix) } 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'), start: row.start_offset === undefined || row.start_offset === null ? undefined : asNumber(row, 'start_offset'), end: row.end_offset === undefined || row.end_offset === null ? undefined : asNumber(row, 'end_offset'), confidence: row.confidence === undefined || row.confidence === null ? undefined : asNumber(row, 'confidence'), source: row.source === undefined ? 'legacy' : (asString(row, 'source') as Evidence['source']), provenance: row.provenance === undefined ? {} : parseObject(asString(row, 'provenance')), 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') } } function mapKnowledgeTask(row: Row): KnowledgeTaskItem { const status = asString(row, 'status') as KnowledgeTaskStatus const kind = asString(row, 'kind') as KnowledgeTaskKind const topLevel = row.parent_task_id === null const sourceId = asOptionalString(row, 'source_id') const documentId = asOptionalString(row, 'document_id') const message = asOptionalString(row, 'message') const errorMessage = asOptionalString(row, 'error_message') return { id: asString(row, 'id'), libraryId: asString(row, 'library_id'), parentTaskId: asOptionalString(row, 'parent_task_id'), retryOfTaskId: asOptionalString(row, 'retry_of_task_id'), sourceId, documentId, documentName: asString(row, 'document_name'), scope: asString(row, 'scope') as KnowledgeTaskScope, kind, stage: asString(row, 'stage') as KnowledgeTaskStage, status, progress: asNumber(row, 'progress'), completedItems: row.completed_items === null ? undefined : asNumber(row, 'completed_items'), totalItems: row.total_items === null ? undefined : asNumber(row, 'total_items'), message, error: errorMessage ? { message: errorMessage, remedy: asOptionalString(row, 'error_remedy') } : undefined, attempt: asNumber(row, 'attempt'), canCancel: topLevel && activeTaskStatuses.includes(status) && [ 'source-sync', 'document-process', 'document-rebuild', 'library-rebuild', 'embedding-rebuild', 'graph-rebuild' ].includes(kind), canRetry: topLevel && ['failed', 'cancelled', 'interrupted'].includes(status) && [ 'source-sync', 'document-rebuild', 'library-rebuild', 'embedding-rebuild', 'graph-rebuild' ].includes(kind) && (kind !== 'source-sync' || sourceId !== undefined) && (kind !== 'document-rebuild' || documentId !== undefined), embeddingJobId: asOptionalString(row, 'embedding_job_id'), createdAt: asString(row, 'created_at'), startedAt: asOptionalString(row, 'started_at'), completedAt: asOptionalString(row, 'completed_at'), updatedAt: asString(row, 'updated_at') } } export class KnowledgeDatabase { private database?: DatabaseSync private readonly taskPrunedLibraries = new Set() private readonly taskPrunePending = new Set() 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) this.database = database this.interruptActiveKnowledgeTasks() database .prepare('DELETE FROM embedding_rebuild_staging') .run() } catch (error) { this.database = undefined database.close() throw error } } close(): void { if (!this.database) { return } this.database.close() this.database = undefined this.taskPrunedLibraries.clear() this.taskPrunePending.clear() } 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, retrieval_settings, chunking_settings, chunking_rebuild_required, ontology_settings, ontology_rebuild_required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)` ) .run( id, name, description ?? null, storageMode, input.graphEnabled === true ? 1 : 0, graphStrategy, JSON.stringify(defaultKnowledgeRetrievalSettings), JSON.stringify(defaultKnowledgeChunkingSettings), JSON.stringify(defaultKnowledgeOntologySettings), 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) } getKnowledgeBaseCounts(): Map< string, { sourceCount: number documentCount: number indexedDocumentCount: number } > { const rows = this.requireDatabase() .prepare( `SELECT kb.id, COUNT(DISTINCT s.id) AS source_count, COUNT(DISTINCT d.id) AS document_count, COUNT(DISTINCT CASE WHEN COALESCE(json_extract(d.metadata, '$.status'), 'ready') <> 'failed' THEN d.id END) AS indexed_document_count FROM knowledge_bases kb LEFT JOIN knowledge_sources s ON s.knowledge_base_id = kb.id LEFT JOIN documents d ON d.knowledge_base_id = kb.id GROUP BY kb.id` ) .all() return new Map( rows.map((row) => [ asString(row, 'id'), { sourceCount: asNumber(row, 'source_count'), documentCount: asNumber(row, 'document_count'), indexedDocumentCount: asNumber( row, 'indexed_document_count' ) } ]) ) } 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 const enablingGraph = !current.graphEnabled && graphEnabled const graphConfigurationChanged = graphEnabled && ( enablingGraph || graphStrategy !== current.graphStrategy ) this.requireDatabase() .prepare( `UPDATE knowledge_bases SET name = ?, description = ?, storage_mode = ?, graph_enabled = ?, graph_strategy = ?, ontology_rebuild_required = CASE WHEN ? = 1 THEN 1 ELSE ontology_rebuild_required END, updated_at = ? WHERE id = ?` ) .run( name, description ?? null, storageMode, graphEnabled ? 1 : 0, graphStrategy, graphConfigurationChanged ? 1 : 0, new Date().toISOString(), current.id ) return this.requiredKnowledgeBase(current.id) } updateKnowledgeSettings( input: KnowledgeSettingsUpdateInput ): KnowledgeBase { const current = this.requiredKnowledgeBase(input.knowledgeBaseId) const retrieval = input.retrieval ? knowledgeRetrievalSettingsSchema.parse(input.retrieval) : current.retrievalSettings const chunking = input.chunking ? knowledgeChunkingSettingsSchema.parse(input.chunking) : current.chunkingSettings const ontology = input.ontology ? knowledgeOntologySettingsSchema.parse(input.ontology) : current.ontologySettings const chunkingChanged = input.chunking !== undefined && JSON.stringify(chunking) !== JSON.stringify(current.chunkingSettings) const ontologyChanged = input.ontology !== undefined && JSON.stringify(ontology) !== JSON.stringify(current.ontologySettings) this.requireDatabase() .prepare( `UPDATE knowledge_bases SET retrieval_settings = ?, chunking_settings = ?, chunking_rebuild_required = CASE WHEN NOT EXISTS ( SELECT 1 FROM documents WHERE documents.knowledge_base_id = knowledge_bases.id ) THEN 0 WHEN ? = 1 THEN 1 ELSE chunking_rebuild_required END, ontology_settings = ?, ontology_rebuild_required = CASE WHEN ? = 1 THEN 1 ELSE ontology_rebuild_required END, updated_at = ? WHERE id = ?` ) .run( JSON.stringify(retrieval), JSON.stringify(chunking), chunkingChanged ? 1 : 0, JSON.stringify(ontology), ontologyChanged ? 1 : 0, new Date().toISOString(), current.id ) return this.requiredKnowledgeBase(current.id) } markKnowledgeChunkingRebuilt(knowledgeBaseId: string): void { this.requireDatabase() .prepare( `UPDATE knowledge_bases SET chunking_rebuild_required = 0, updated_at = ? WHERE id = ?` ) .run( new Date().toISOString(), requiredString(knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH) ) } markKnowledgeOntologyRebuilt(knowledgeBaseId: string): void { this.requireDatabase() .prepare( `UPDATE knowledge_bases SET ontology_rebuild_required = 0, updated_at = ? WHERE id = ?` ) .run( new Date().toISOString(), requiredString(knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH) ) } deleteKnowledgeBase(id: string): boolean { const normalizedId = requiredString(id, 'id', MAX_ID_LENGTH) const deleted = this.requireDatabase() .prepare('DELETE FROM knowledge_bases WHERE id = ?') .run(normalizedId).changes > 0 if (deleted) { this.taskPrunedLibraries.delete(normalizedId) this.taskPrunePending.delete(normalizedId) } return deleted } createKnowledgeTask(input: CreateKnowledgeTaskInput): KnowledgeTaskItem { const database = this.requireDatabase() const id = optionalString(input.id, 'id', MAX_ID_LENGTH) ?? randomUUID() const libraryId = requiredString( input.libraryId, 'libraryId', MAX_ID_LENGTH ) const parentTaskId = optionalString( input.parentTaskId, 'parentTaskId', MAX_ID_LENGTH ) const retryOfTaskId = optionalString( input.retryOfTaskId, 'retryOfTaskId', MAX_ID_LENGTH ) const sourceId = optionalString(input.sourceId, 'sourceId', MAX_ID_LENGTH) const documentId = optionalString( input.documentId, 'documentId', MAX_ID_LENGTH ) const documentName = requiredString( input.documentName, 'documentName', MAX_NAME_LENGTH ) const scope = enumValue(input.scope, 'scope', taskScopes) const kind = enumValue(input.kind, 'kind', taskKinds) const status = enumValue(input.status ?? 'queued', 'status', taskStatuses) const stage = enumValue(input.stage ?? 'queued', 'stage', taskStages) const progress = boundedInteger( input.progress ?? (status === 'succeeded' || status === 'skipped' ? 100 : 0), 'progress', 0, 100 ) const completedItems = input.completedItems === undefined ? undefined : boundedInteger( input.completedItems, 'completedItems', 0, Number.MAX_SAFE_INTEGER ) const totalItems = input.totalItems === undefined ? undefined : boundedInteger( input.totalItems, 'totalItems', 0, Number.MAX_SAFE_INTEGER ) if ( completedItems !== undefined && totalItems !== undefined && completedItems > totalItems ) { throw new RangeError('completedItems must not exceed totalItems') } const message = optionalString( input.message, 'message', MAX_TASK_TEXT_LENGTH ) const errorMessage = optionalString( input.error?.message, 'error.message', MAX_TASK_TEXT_LENGTH ) const errorRemedy = optionalString( input.error?.remedy, 'error.remedy', MAX_TASK_TEXT_LENGTH ) if (status === 'failed' && !errorMessage) { throw new Error('Failed tasks must include an error') } if (status === 'succeeded' && progress !== 100) { throw new Error('Succeeded tasks must report 100 percent') } const attempt = boundedInteger( input.attempt ?? 1, 'attempt', 1, Number.MAX_SAFE_INTEGER ) const dedupeKey = optionalString( input.dedupeKey, 'dedupeKey', MAX_TASK_DEDUPE_KEY_LENGTH ) const embeddingJobId = optionalString( input.embeddingJobId, 'embeddingJobId', MAX_TASK_JOB_ID_LENGTH ) const now = new Date().toISOString() const terminal = terminalTaskStatuses.includes(status) try { database .prepare( `INSERT INTO knowledge_tasks (id, library_id, parent_task_id, retry_of_task_id, source_id, document_id, document_name, scope, kind, stage, status, progress, completed_items, total_items, message, error_message, error_remedy, attempt, dedupe_key, embedding_job_id, created_at, started_at, completed_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, libraryId, parentTaskId ?? null, retryOfTaskId ?? null, sourceId ?? null, documentId ?? null, documentName, scope, kind, stage, status, progress, completedItems ?? null, totalItems ?? null, message ?? null, errorMessage ?? null, errorRemedy ?? null, attempt, dedupeKey ?? null, embeddingJobId ?? null, now, status === 'running' ? now : null, terminal ? now : null, now ) } catch (error) { if ( dedupeKey && error instanceof Error && error.message.includes('UNIQUE constraint failed') ) { const existing = this.getActiveKnowledgeTaskByDedupeKey( libraryId, dedupeKey ) if (existing) { return existing } } throw error } if (terminal) { this.taskPrunePending.add(libraryId) } return this.requiredKnowledgeTask(id) } getKnowledgeTask(id: string): KnowledgeTaskItem | undefined { const row = this.requireDatabase() .prepare('SELECT * FROM knowledge_tasks WHERE id = ?') .get(requiredString(id, 'id', MAX_ID_LENGTH)) return row ? mapKnowledgeTask(row) : undefined } getActiveKnowledgeTaskByDedupeKey( libraryId: string, dedupeKey: string ): KnowledgeTaskItem | undefined { const row = this.requireDatabase() .prepare( `SELECT * FROM knowledge_tasks WHERE library_id = ? AND dedupe_key = ? AND status IN ('queued', 'running') LIMIT 1` ) .get( requiredString(libraryId, 'libraryId', MAX_ID_LENGTH), requiredString( dedupeKey, 'dedupeKey', MAX_TASK_DEDUPE_KEY_LENGTH ) ) return row ? mapKnowledgeTask(row) : undefined } getKnowledgeTaskByEmbeddingJobId( libraryId: string, embeddingJobId: string ): KnowledgeTaskItem | undefined { const row = this.requireDatabase() .prepare( `SELECT * FROM knowledge_tasks WHERE library_id = ? AND embedding_job_id = ? ORDER BY created_at DESC, id DESC LIMIT 1` ) .get( requiredString(libraryId, 'libraryId', MAX_ID_LENGTH), requiredString( embeddingJobId, 'embeddingJobId', MAX_TASK_JOB_ID_LENGTH ) ) return row ? mapKnowledgeTask(row) : undefined } listKnowledgeTasks( libraryId: string, limit = MAX_LIST_LIMIT ): KnowledgeTaskItem[] { boundedInteger(limit, 'limit', 1, MAX_LIST_LIMIT) const normalizedLibraryId = requiredString( libraryId, 'libraryId', MAX_ID_LENGTH ) if ( !this.taskPrunedLibraries.has(normalizedLibraryId) || this.taskPrunePending.has(normalizedLibraryId) ) { this.pruneKnowledgeTasks(normalizedLibraryId) } return this.requireDatabase() .prepare( `WITH RECURSIVE retained(id) AS ( SELECT id FROM ( SELECT id FROM knowledge_tasks WHERE library_id = ? AND status NOT IN ('queued', 'running') ORDER BY created_at DESC, id DESC LIMIT ? ) UNION SELECT task.parent_task_id FROM knowledge_tasks task JOIN retained ON task.id = retained.id WHERE task.parent_task_id IS NOT NULL UNION SELECT task.retry_of_task_id FROM knowledge_tasks task JOIN retained ON task.id = retained.id WHERE task.retry_of_task_id IS NOT NULL ) SELECT * FROM knowledge_tasks WHERE library_id = ? AND status IN ('queued', 'running') UNION ALL SELECT task.* FROM knowledge_tasks task JOIN retained ON task.id = retained.id WHERE task.status NOT IN ('queued', 'running') ORDER BY created_at DESC, id DESC` ) .all(normalizedLibraryId, limit, normalizedLibraryId) .map(mapKnowledgeTask) } listActiveKnowledgeTasks(libraryId?: string): KnowledgeTaskItem[] { const database = this.requireDatabase() const rows = libraryId ? database .prepare( `SELECT * FROM knowledge_tasks WHERE library_id = ? AND status IN ('queued', 'running') ORDER BY created_at ASC, id ASC` ) .all(requiredString(libraryId, 'libraryId', MAX_ID_LENGTH)) : database .prepare( `SELECT * FROM knowledge_tasks WHERE status IN ('queued', 'running') ORDER BY created_at ASC, id ASC` ) .all() return rows.map(mapKnowledgeTask) } updateKnowledgeTask( id: string, update: UpdateKnowledgeTaskInput ): KnowledgeTaskItem | undefined { const current = this.getKnowledgeTask(id) if (!current || terminalTaskStatuses.includes(current.status)) { return current } const status = enumValue( update.status ?? current.status, 'status', taskStatuses ) const stage = enumValue( update.stage ?? current.stage, 'stage', taskStages ) const progress = boundedInteger( status === 'succeeded' || status === 'skipped' ? 100 : update.progress ?? current.progress, 'progress', 0, 100 ) const completedItems = update.completedItems === null ? undefined : update.completedItems === undefined ? current.completedItems : boundedInteger( update.completedItems, 'completedItems', 0, Number.MAX_SAFE_INTEGER ) const totalItems = update.totalItems === null ? undefined : update.totalItems === undefined ? current.totalItems : boundedInteger( update.totalItems, 'totalItems', 0, Number.MAX_SAFE_INTEGER ) if ( completedItems !== undefined && totalItems !== undefined && completedItems > totalItems ) { throw new RangeError('completedItems must not exceed totalItems') } const message = update.message === null ? undefined : update.message === undefined ? current.message : truncatedOptionalString(update.message, MAX_TASK_TEXT_LENGTH) const error = update.error === null ? undefined : update.error === undefined ? current.error : { message: truncatedOptionalString( update.error.message, MAX_TASK_TEXT_LENGTH ) ?? '任务失败', remedy: truncatedOptionalString( update.error.remedy, MAX_TASK_TEXT_LENGTH ) } if (status === 'failed' && !error) { throw new Error('Failed tasks must include an error') } const sourceId = update.sourceId === null ? undefined : update.sourceId === undefined ? current.sourceId : requiredString(update.sourceId, 'sourceId', MAX_ID_LENGTH) const documentId = update.documentId === null ? undefined : update.documentId === undefined ? current.documentId : requiredString(update.documentId, 'documentId', MAX_ID_LENGTH) const documentName = update.documentName === undefined ? current.documentName : requiredString( update.documentName, 'documentName', MAX_NAME_LENGTH ) const embeddingJobId = update.embeddingJobId === null ? undefined : update.embeddingJobId === undefined ? current.embeddingJobId : requiredString( update.embeddingJobId, 'embeddingJobId', MAX_TASK_JOB_ID_LENGTH ) const now = new Date().toISOString() const terminal = terminalTaskStatuses.includes(status) const changes = this.requireDatabase() .prepare( `UPDATE knowledge_tasks SET source_id = ?, document_id = ?, document_name = ?, stage = ?, status = ?, progress = ?, completed_items = ?, total_items = ?, message = ?, error_message = ?, error_remedy = ?, embedding_job_id = ?, started_at = CASE WHEN ? = 'running' THEN COALESCE(started_at, ?) ELSE started_at END, completed_at = CASE WHEN ? = 1 THEN COALESCE(completed_at, ?) ELSE completed_at END, updated_at = ? WHERE id = ? AND status IN ('queued', 'running')` ) .run( sourceId ?? null, documentId ?? null, documentName, stage, status, progress, completedItems ?? null, totalItems ?? null, message ?? null, error?.message ?? null, error?.remedy ?? null, embeddingJobId ?? null, status, now, terminal ? 1 : 0, now, now, current.id ).changes if (changes > 0 && terminal) { this.taskPrunePending.add(current.libraryId) } return this.getKnowledgeTask(current.id) } cancelKnowledgeTask( id: string, message = '任务已取消' ): boolean { const current = this.getKnowledgeTask(id) if (!current || !activeTaskStatuses.includes(current.status)) { return false } return ( this.updateKnowledgeTask(current.id, { status: 'cancelled', message, error: null })?.status === 'cancelled' ) } interruptActiveKnowledgeTasks( message = '应用重启,任务已中断' ): number { if (!this.database) { return 0 } const normalizedMessage = requiredString( message, 'message', MAX_TASK_TEXT_LENGTH ) const now = new Date().toISOString() return Number( this.database .prepare( `UPDATE knowledge_tasks SET status = 'interrupted', message = ?, error_message = ?, error_remedy = '请重试该任务。', completed_at = ?, updated_at = ? WHERE status IN ('queued', 'running')` ) .run(normalizedMessage, normalizedMessage, now, now).changes ) } pruneKnowledgeTasks(libraryId: string): number { const normalizedLibraryId = requiredString( libraryId, 'libraryId', MAX_ID_LENGTH ) const changes = Number( this.requireDatabase() .prepare( `WITH RECURSIVE retained(id) AS ( SELECT id FROM ( SELECT id FROM knowledge_tasks WHERE library_id = ? AND status NOT IN ('queued', 'running') ORDER BY created_at DESC, id DESC LIMIT ? ) UNION SELECT task.parent_task_id FROM knowledge_tasks task JOIN retained ON task.id = retained.id WHERE task.parent_task_id IS NOT NULL UNION SELECT task.retry_of_task_id FROM knowledge_tasks task JOIN retained ON task.id = retained.id WHERE task.retry_of_task_id IS NOT NULL ) DELETE FROM knowledge_tasks WHERE library_id = ? AND status NOT IN ('queued', 'running') AND id NOT IN (SELECT id FROM retained)` ) .run( normalizedLibraryId, MAX_TERMINAL_TASKS_PER_LIBRARY, normalizedLibraryId ).changes ) this.taskPrunedLibraries.add(normalizedLibraryId) this.taskPrunePending.delete(normalizedLibraryId) return changes } 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) } listSourcesForSnapshot(knowledgeBaseId: string): KnowledgeSource[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) return this.requireDatabase() .prepare( `SELECT * FROM knowledge_sources WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC` ) .all(normalizedId) .map(mapSource) } getSource(id: string): KnowledgeSource | undefined { const row = this.requireDatabase() .prepare('SELECT * FROM knowledge_sources WHERE id = ?') .get(requiredString(id, 'id', MAX_ID_LENGTH)) return row ? mapSource(row) : undefined } 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 { return this.publishDocument(input, chunks) } publishDocument( input: UpsertDocumentInput, chunks: ReplaceChunkInput[], options: DocumentPublicationOptions = {} ): 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 embeddingReplacement = options.embeddingReplacement ? { replacementId: requiredString( options.embeddingReplacement.replacementId, 'embeddingReplacement.replacementId', MAX_ID_LENGTH ), provider: requiredString( options.embeddingReplacement.provider, 'embeddingReplacement.provider', MAX_EMBEDDING_PROVIDER_LENGTH ), model: requiredString( options.embeddingReplacement.model, 'embeddingReplacement.model', MAX_EMBEDDING_MODEL_LENGTH ) } : undefined const embeddingError = options.embeddingError ? { provider: requiredString( options.embeddingError.provider, 'embeddingError.provider', MAX_EMBEDDING_PROVIDER_LENGTH ), model: requiredString( options.embeddingError.model, 'embeddingError.model', MAX_EMBEDDING_MODEL_LENGTH ), message: requiredString( options.embeddingError.message, 'embeddingError.message', MAX_EMBEDDING_ERROR_LENGTH, false ) } : undefined if (embeddingReplacement && embeddingError) { throw new Error( 'Document publication cannot include embeddings and an embedding error' ) } if (embeddingReplacement) { this.validatePreparedDocumentEmbeddings( database, embeddingReplacement, id, normalizedChunks ) } 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 graph_evidence 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, enabled, role, parent_chunk_id, manually_edited, updated_at, cjk_search, index_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) const chunksInForeignKeyOrder = [...normalizedChunks].sort( (left, right) => Number(left.role === 'child') - Number(right.role === 'child') || left.ordinal - right.ordinal ) for (const chunk of chunksInForeignKeyOrder) { const chunkMetadata = parseObject(chunk.metadata) const indexContent = chunkIndexContent( chunk.content, chunkMetadata ) insertChunk.run( chunk.id, knowledgeBaseId, id, chunk.ordinal, chunk.content, chunk.tokenCount ?? null, chunk.heading ?? null, chunk.location ?? null, chunk.metadata, now, chunk.enabled ? 1 : 0, chunk.role, chunk.parentChunkId ?? null, chunk.manuallyEdited ? 1 : 0, now, createCjkSearchText( `${chunk.heading ?? ''}\n${indexContent}` ), indexContent ) } options.afterChunksInserted?.(this.requiredDocument(id)) database .prepare( 'DELETE FROM embedding_index_state WHERE document_id = ?' ) .run(id) if (embeddingReplacement) { this.publishPreparedDocumentEmbeddings( database, embeddingReplacement, id, knowledgeBaseId, now ) } else if (embeddingError) { this.recordEmbeddingIndexError( id, embeddingError.provider, embeddingError.model, embeddingError.message ) } this.pruneUnreferencedGeneratedGraph(knowledgeBaseId) }) 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) } listDocumentsForSource(sourceId: string): Document[] { const normalizedId = requiredString(sourceId, 'sourceId', MAX_ID_LENGTH) return this.requireDatabase() .prepare( `SELECT * FROM documents WHERE source_id = ? ORDER BY created_at ASC, id ASC LIMIT ?` ) .all(normalizedId, maximumFilesPerSourceDatabaseLimit) .map(mapDocument) } listDocumentsForLibraryRebuild(knowledgeBaseId: string): Document[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) return this.requireDatabase() .prepare( `SELECT * FROM documents WHERE knowledge_base_id = ? ORDER BY source_id ASC, created_at ASC, id ASC` ) .all(normalizedId) .map(mapDocument) } listDocumentsForSnapshot(knowledgeBaseId: string): Document[] { return this.listDocumentsForLibraryRebuild(knowledgeBaseId) } getDocumentChunkCounts(knowledgeBaseId: string): Map { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) return new Map( this.requireDatabase() .prepare( `SELECT document_id, COUNT(*) AS chunk_count FROM chunks WHERE knowledge_base_id = ? GROUP BY document_id` ) .all(normalizedId) .map((row) => [ asString(row, 'document_id'), asNumber(row, 'chunk_count') ]) ) } 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 ) } replaceEvidenceForDocument( documentId: string, operation: () => void ): void { const normalizedId = requiredString( documentId, 'documentId', MAX_ID_LENGTH ) const database = this.requireDatabase() this.transaction(database, () => { database .prepare('DELETE FROM graph_evidence WHERE document_id = ?') .run(normalizedId) operation() }) } 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) } listChunksPage(input: KnowledgeChunksListInput): { items: Chunk[] total: number page: number pageSize: number } { const document = this.getDocument(input.documentId) if (!document || document.knowledgeBaseId !== input.knowledgeBaseId) { throw new Error('Document must belong to the requested knowledge base') } const page = boundedInteger(input.page, 'page', 1, 1_000_000) const pageSize = boundedInteger(input.pageSize, 'pageSize', 1, 200) const search = optionalString(input.search, 'search', 1_000) const pattern = search ? `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%') .replaceAll('_', '\\_')}%` : undefined const database = this.requireDatabase() const where = pattern ? `document_id = ? AND (content LIKE ? ESCAPE '\\' OR heading LIKE ? ESCAPE '\\' OR location LIKE ? ESCAPE '\\')` : 'document_id = ?' const parameters = pattern ? [document.id, pattern, pattern, pattern] : [document.id] const count = database .prepare(`SELECT COUNT(*) AS count FROM chunks WHERE ${where}`) .get(...parameters) const items = database .prepare( `SELECT * FROM chunks WHERE ${where} ORDER BY ordinal ASC, id ASC LIMIT ? OFFSET ?` ) .all( ...parameters, pageSize, (page - 1) * pageSize ) .map(mapChunk) return { items, total: count ? asNumber(count, 'count') : 0, page, pageSize } } getChunkForReference( knowledgeBaseId: string, documentId: string, chunkId: string ): { chunk: Chunk; document: Document; source: KnowledgeSource } | undefined { const row = this.requireDatabase() .prepare( `SELECT c.*, 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 = ? AND c.document_id = ? AND c.knowledge_base_id = ? AND d.knowledge_base_id = ? AND s.knowledge_base_id = ?` ) .get( requiredString(chunkId, 'chunkId', MAX_ID_LENGTH), requiredString(documentId, 'documentId', MAX_ID_LENGTH), requiredString(knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH), knowledgeBaseId, knowledgeBaseId ) return row ? { chunk: mapChunk(row), document: mapDocument(this.prefixedRow(row, 'd_')), source: mapSource(this.prefixedRow(row, 's_')) } : undefined } updateChunk(input: { knowledgeBaseId: string documentId: string chunkId: string content?: string enabled?: boolean }): Chunk { const current = this.getChunkForReference( input.knowledgeBaseId, input.documentId, input.chunkId ) if (!current) { throw new Error('Chunk must belong to the requested document and library') } const content = input.content === undefined ? current.chunk.content : requiredString(input.content, 'content', MAX_CONTENT_LENGTH, false) const enabled = input.enabled ?? current.chunk.enabled const indexContent = chunkIndexContent( content, current.chunk.metadata ) const changed = content !== current.chunk.content || enabled !== current.chunk.enabled if (!changed) { return current.chunk } const database = this.requireDatabase() const now = new Date().toISOString() this.transaction(database, () => { if (content !== current.chunk.content) { database .prepare('DELETE FROM graph_evidence WHERE chunk_id = ?') .run(current.chunk.id) } database .prepare( `UPDATE chunks SET content = ?, enabled = ?, manually_edited = 1, updated_at = ?, cjk_search = ?, index_content = ? WHERE id = ?` ) .run( content, enabled ? 1 : 0, now, createCjkSearchText( `${current.chunk.heading ?? ''}\n${indexContent}` ), indexContent, current.chunk.id ) database .prepare('DELETE FROM chunk_embeddings WHERE chunk_id = ?') .run(current.chunk.id) database .prepare('DELETE FROM embedding_index_state WHERE document_id = ?') .run(current.document.id) }) return this.getChunkForReference( input.knowledgeBaseId, input.documentId, input.chunkId )!.chunk } deleteChunk(input: { knowledgeBaseId: string documentId: string chunkId: string }): boolean { const current = this.getChunkForReference( input.knowledgeBaseId, input.documentId, input.chunkId ) if (!current) { return false } const database = this.requireDatabase() let deleted = false this.transaction(database, () => { database .prepare('DELETE FROM graph_evidence WHERE chunk_id = ?') .run(current.chunk.id) database .prepare('DELETE FROM embedding_index_state WHERE document_id = ?') .run(current.document.id) deleted = database .prepare('DELETE FROM chunks WHERE id = ?') .run(current.chunk.id).changes > 0 }) return deleted } listContextChunks(chunk: Chunk, adjacentCount: number): Chunk[] { boundedInteger(adjacentCount, 'adjacentCount', 0, 2) const database = this.requireDatabase() if (chunk.parentChunkId) { const parent = database .prepare( `SELECT * FROM chunks WHERE id = ? AND document_id = ? AND enabled = 1 AND role = 'parent'` ) .get(chunk.parentChunkId, chunk.documentId) if (parent) { return [mapChunk(parent)] } } return database .prepare( `SELECT * FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent' AND ordinal BETWEEN ? AND ? ORDER BY ordinal ASC, id ASC` ) .all( chunk.documentId, Math.max(0, chunk.ordinal - adjacentCount), chunk.ordinal + adjacentCount ) .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, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent' 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, 'index_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 ) requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ) 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}`) } return randomUUID() } beginPreparedDocumentEmbeddingReplacement( documentId: string, provider: string, model: string ): string { requiredString(documentId, 'documentId', MAX_ID_LENGTH) requiredString(provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH) requiredString(model, 'model', MAX_EMBEDDING_MODEL_LENGTH) 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 chunkIds = embeddings.map((embedding, index) => requiredString( embedding.chunkId, `embeddings[${index}].chunkId`, MAX_ID_LENGTH ) ) if (new Set(chunkIds).size !== chunkIds.length) { throw new Error('Embedding chunk IDs must be unique within a batch') } const placeholders = chunkIds.map(() => '?').join(', ') const chunksById = new Map( database .prepare( `SELECT id, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent' AND id IN (${placeholders})` ) .all(normalizedDocumentId, ...chunkIds) .map((row) => [ asString(row, 'id'), asString(row, 'index_content') ]) ) if (chunksById.size !== chunkIds.length) { throw new Error( 'Embeddings must reference chunks in the document' ) } 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 = chunkIds[index]! const chunkContent = chunksById.get(chunkId)! const checksum = normalizedChecksum( embedding.contentChecksum, `embeddings[${index}].contentChecksum` ) if (checksum !== contentChecksum(chunkContent)) { 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 } }) this.insertEmbeddingReplacementBatch(database, { replacementId: normalizedReplacementId, documentId: normalizedDocumentId, provider: normalizedProvider, model: normalizedModel, embeddings: normalized }) } appendPreparedDocumentEmbeddingBatch( 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 existingDimensions = database .prepare( `SELECT dimensions FROM embedding_rebuild_staging WHERE replacement_id = ? LIMIT 1` ) .get(normalizedReplacementId) let dimensions = existingDimensions ? asNumber(existingDimensions, 'dimensions') : undefined const seen = new Set() const normalized = embeddings.map((embedding, index) => { const chunkId = requiredString( embedding.chunkId, `embeddings[${index}].chunkId`, MAX_ID_LENGTH ) if (seen.has(chunkId)) { throw new Error('Embedding chunk IDs must be unique within a batch') } seen.add(chunkId) const checksum = normalizedChecksum( embedding.contentChecksum, `embeddings[${index}].contentChecksum` ) 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 } }) this.insertEmbeddingReplacementBatch(database, { replacementId: normalizedReplacementId, documentId: normalizedDocumentId, provider: normalizedProvider, model: normalizedModel, embeddings: normalized }) } 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 = ? AND enabled = 1 AND role <> 'parent') 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 stagedRows = database .prepare( `SELECT staged.content_checksum AS staged_checksum, current.index_content AS current_content FROM embedding_rebuild_staging staged LEFT JOIN chunks current ON current.id = staged.chunk_id AND current.document_id = staged.document_id AND current.enabled = 1 AND current.role <> 'parent' WHERE staged.replacement_id = ? AND staged.document_id = ? AND staged.provider = ? AND staged.model = ?` ) .all( normalizedReplacementId, normalizedDocumentId, normalizedProvider, normalizedModel ) if ( stagedRows.some((row) => { const currentContent = asOptionalString(row, 'current_content') return ( currentContent === undefined || asString(row, 'staged_checksum') !== contentChecksum(currentContent) ) }) ) { throw new Error('Embedding content changed during replacement') } 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 = CASE WHEN embedding_index_state.status = 'ready' THEN 'ready' ELSE 'error' END, 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( knowledgeBaseId: string ): EmbeddingIndexJob | null { const row = this.requireDatabase() .prepare( `SELECT status_json FROM embedding_index_job WHERE knowledge_base_id = ?` ) .get( requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) ) if (!row) { return null } try { return embeddingIndexJobSchema.parse( JSON.parse(asString(row, 'status_json')) ) } catch { return null } } saveEmbeddingIndexJob( knowledgeBaseId: string, job: EmbeddingIndexJob | null ): void { const database = this.requireDatabase() const normalizedKnowledgeBaseId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) if (!job) { database .prepare( 'DELETE FROM embedding_index_job WHERE knowledge_base_id = ?' ) .run(normalizedKnowledgeBaseId) return } const normalized = embeddingIndexJobSchema.parse(job) database .prepare( `INSERT INTO embedding_index_job (knowledge_base_id, status_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(knowledge_base_id) DO UPDATE SET status_json = excluded.status_json, updated_at = excluded.updated_at` ) .run( normalizedKnowledgeBaseId, JSON.stringify(normalized), new Date().toISOString() ) } listEmbeddingIndexDocumentIds( knowledgeBaseId: string ): string[] { return this.requireDatabase() .prepare( `SELECT d.id FROM documents d WHERE d.knowledge_base_id = ? AND (json_extract(d.metadata, '$.status') IS NULL OR json_extract(d.metadata, '$.status') = 'ready') ORDER BY d.id` ) .all( requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) ) .map((document) => asString(document, 'id')) } countEmbeddingIndexDocuments(knowledgeBaseId: string): number { const row = this.requireDatabase() .prepare( `SELECT COUNT(*) AS count FROM documents d WHERE d.knowledge_base_id = ? AND (json_extract(d.metadata, '$.status') IS NULL OR json_extract(d.metadata, '$.status') = 'ready')` ) .get( requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) ) return row ? asNumber(row, 'count') : 0 } getEmbeddingIndexCoverage( knowledgeBaseId: string, provider: string, model: string ): EmbeddingIndexCoverage { const row = this.requireDatabase() .prepare( `SELECT COUNT(*) AS total, COALESCE(SUM( CASE WHEN eis.status = 'ready' THEN 1 ELSE 0 END ), 0) AS indexed, COALESCE(SUM( CASE WHEN eis.status = 'error' THEN 1 ELSE 0 END ), 0) AS error FROM documents d LEFT JOIN embedding_index_state eis ON eis.document_id = d.id AND eis.provider = ? AND eis.model = ? WHERE d.knowledge_base_id = ? AND (json_extract(d.metadata, '$.status') IS NULL OR json_extract(d.metadata, '$.status') = 'ready')` ) .get( requiredString( provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH ), requiredString(model, 'model', MAX_EMBEDDING_MODEL_LENGTH), requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) ) const total = row ? asNumber(row, 'total') : 0 const indexed = row ? asNumber(row, 'indexed') : 0 const error = row ? asNumber(row, 'error') : 0 return { total, indexed, error, missing: total - indexed - error } } 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, metadata, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent' ORDER BY ordinal ASC, id ASC` ) return { id: normalizedDocumentId, items: chunks.all(normalizedDocumentId).map((row) => { const content = row.index_content === undefined ? chunkIndexContent( asString(row, 'content'), parseObject(asString(row, 'metadata')) ) : asString(row, 'index_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[] { return this.hybridSearchWithDiagnostics(options).results } hybridSearchWithDiagnostics( options: HybridSearchOptions ): HybridSearchResultPage { const knowledgeBaseId = requiredString( options.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const query = requiredString(options.query, 'query', 4_000) const limit = options.limit ?? 20 boundedInteger(limit, 'limit', 1, 100) const candidateMultiplier = boundedInteger( options.candidateMultiplier ?? 4, 'candidateMultiplier', 1, 10 ) const candidateLimit = Math.min(100, limit * candidateMultiplier) const ftsWeight = this.validateWeight(options.ftsWeight ?? 1, 'ftsWeight') const vectorWeight = this.validateWeight( options.vectorWeight ?? 1, 'vectorWeight' ) const graphWeight = this.validateWeight( options.graphWeight ?? 0.8, 'graphWeight' ) const lexical = ftsWeight === 0 ? [] : this.search({ knowledgeBaseId, query, limit: candidateLimit }) const vectorPage = vectorWeight > 0 && options.vector && options.provider && options.model ? this.vectorSearchScoredWithCount({ knowledgeBaseId, provider: options.provider, model: options.model, vector: options.vector, limit: options.vectorLimit ?? candidateLimit, minimumSimilarity: options.minimumVectorSimilarity, signal: options.signal }) : { results: [], scannedCount: 0 } const vector = vectorPage.results const graph = options.graphEnabled === false || graphWeight === 0 ? [] : this.graphSearchScored( knowledgeBaseId, query, candidateLimit, 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 })), ftsWeight ) add('vector', vector, vectorWeight) add('graph', graph, graphWeight) return { results: [...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] } })), vectorScannedCount: vectorPage.scannedCount } } search(options: SearchOptions): SearchResult[] { const knowledgeBaseId = requiredString( options.knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const query = requiredString(options.query, 'query', 4_000) const limit = options.limit ?? 20 boundedInteger(limit, 'limit', 1, 100) const literalQuery = query .split(/\s+/u) .filter(Boolean) .map((term) => `"${term.replaceAll('"', '""')}"`) .join(' OR ') let rows = this.requireDatabase() .prepare( `SELECT c.*, substr(c.content, 1, 600) 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 = ? AND c.enabled = 1 AND c.role <> 'parent' ORDER BY rank ASC LIMIT ?` ) .all(literalQuery, knowledgeBaseId, limit) as Row[] if (containsHanText(query)) { const terms = knowledgeRetrievalTerms(query, 64) const cjkQuery = terms .map((term) => `"${term.replaceAll('"', '""')}"`) .join(' OR ') const cjkRows = cjkQuery ? this.requireDatabase() .prepare( `SELECT c.*, substr(c.content, 1, 600) AS snippet, bm25(chunks_cjk) 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_cjk JOIN chunks c ON c.rowid = chunks_cjk.rowid JOIN documents d ON d.id = c.document_id JOIN knowledge_sources s ON s.id = d.source_id WHERE chunks_cjk MATCH ? AND c.knowledge_base_id = ? AND c.enabled = 1 AND c.role <> 'parent' ORDER BY rank ASC, c.id ASC LIMIT ?` ) .all(cjkQuery, knowledgeBaseId, limit) : [] const byChunkId = new Map() for (const row of [...rows, ...cjkRows]) { const id = asString(row, 'id') const current = byChunkId.get(id) if (!current || asNumber(row, 'rank') < asNumber(current, 'rank')) { byChunkId.set(id, row) } } rows = [...byChunkId.values()] .sort( (left, right) => asNumber(left, 'rank') - asNumber(right, 'rank') || asString(left, 'id').localeCompare(asString(right, 'id')) ) .slice(0, 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 ontology = this.requiredKnowledgeBase(knowledgeBaseId).ontologySettings const requestedType = requiredString(input.type, 'type', MAX_NAME_LENGTH) const type = normalizeEntityTypeAlias(requestedType, ontology) if ( input.locked && type === 'CONCEPT' && !ontology.entityTypes.some( (definition) => [definition.id, ...definition.aliases].some( (value) => normalizeOntologyAlias(value) === normalizeOntologyAlias(requestedType) ) ) ) { throw new Error('Entity type is not defined by the library ontology') } 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) } findEntityByCanonicalName( knowledgeBaseId: string, type: string, name: string ): GraphEntity | undefined { requiredString(knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH) const normalizedType = requiredString(type, 'type', MAX_NAME_LENGTH) const normalizedName = normalizeEntityAlias(name) if (!normalizedName) { return undefined } return this.listEntitiesForIdentity(knowledgeBaseId) .find( (candidate) => candidate.type === normalizedType && ( normalizeEntityAlias(candidate.name) === normalizedName || candidate.aliases.some( (alias) => normalizeEntityAlias(alias) === normalizedName ) ) ) } listEntitiesForIdentity(knowledgeBaseId: string): GraphEntity[] { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) return this.requireDatabase() .prepare( `SELECT * FROM graph_entities WHERE knowledge_base_id = ? ORDER BY name COLLATE NOCASE ASC, id ASC` ) .all(normalizedId) .map(mapEntity) } updateEntity(id: string, input: UpdateGraphEntityInput): GraphEntity { const current = this.requiredEntity(id) const ontology = this.requiredKnowledgeBase(current.knowledgeBaseId).ontologySettings const requestedType = input.type === undefined ? undefined : requiredString(input.type, 'type', MAX_NAME_LENGTH) const nextType = requestedType === undefined ? current.type : normalizeEntityTypeAlias(requestedType, ontology) if ( input.locked === true && requestedType !== undefined && nextType === 'CONCEPT' && !ontology.entityTypes.some((definition) => [definition.id, ...definition.aliases].some( (value) => normalizeOntologyAlias(value) === normalizeOntologyAlias(requestedType) ) ) ) { throw new Error('Entity type is not defined by the library ontology') } 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), nextType, 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 ontology = this.requiredKnowledgeBase(knowledgeBaseId).ontologySettings const type = normalizeRelationTypeAlias( requiredString(input.type, 'type', MAX_NAME_LENGTH), ontology ) if (!type) { throw new Error('Relation type is not defined by the library ontology') } 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 ) this.assertOntologyRelation( database, knowledgeBaseId, sourceEntityId, targetEntityId, type ) 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) } findRelationByIdentity( knowledgeBaseId: string, sourceEntityId: string, targetEntityId: string, type: string ): GraphRelation | undefined { const row = this.requireDatabase() .prepare( `SELECT * FROM graph_relations WHERE knowledge_base_id = ? AND source_entity_id = ? AND target_entity_id = ? AND type = ? ORDER BY created_at ASC, id ASC LIMIT 1` ) .get( requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ), requiredString(sourceEntityId, 'sourceEntityId', MAX_ID_LENGTH), requiredString(targetEntityId, 'targetEntityId', MAX_ID_LENGTH), requiredString(type, 'type', MAX_NAME_LENGTH) ) return row ? mapRelation(row) : undefined } 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 ) const ontology = this.requiredKnowledgeBase(current.knowledgeBaseId).ontologySettings const type = input.type === undefined ? current.type : normalizeRelationTypeAlias( requiredString(input.type, 'type', MAX_NAME_LENGTH), ontology ) if (!type) { throw new Error('Relation type is not defined by the library ontology') } this.assertOntologyRelation( database, current.knowledgeBaseId, sourceEntityId, targetEntityId, type ) database .prepare( `UPDATE graph_relations SET source_entity_id = ?, target_entity_id = ?, type = ?, label = ?, properties = ?, locked = ?, updated_at = ? WHERE id = ?` ) .run( sourceEntityId, targetEntityId, type, 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 ) const start = input.start === undefined ? undefined : boundedInteger(input.start, 'start', 0, MAX_CONTENT_LENGTH) const end = input.end === undefined ? undefined : boundedInteger(input.end, 'end', 0, MAX_CONTENT_LENGTH) if ( (start === undefined) !== (end === undefined) || (start !== undefined && end !== undefined && start >= end) ) { throw new RangeError('Evidence offsets must form a non-empty range') } const confidence = input.confidence if ( confidence !== undefined && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) ) { throw new RangeError('Evidence confidence must be between 0 and 1') } const source = enumValue( input.source ?? 'manual', 'source', ['rules', 'model', 'manual', 'legacy'] as const ) const provenance = jsonObject(input.provenance, 'provenance') 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, start_offset, end_offset, confidence, source, provenance) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, knowledgeBaseId, entityId ?? null, relationId ?? null, documentId, chunkId ?? null, quote ?? null, location ?? null, new Date().toISOString(), start ?? null, end ?? null, confidence ?? null, source, provenance ) 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) } listGraphSnapshot(knowledgeBaseId: string): { entities: GraphEntity[] relations: GraphRelation[] evidence: Evidence[] } { const normalizedId = requiredString( knowledgeBaseId, 'knowledgeBaseId', MAX_ID_LENGTH ) const database = this.requireDatabase() return { entities: database .prepare( `SELECT * FROM graph_entities WHERE knowledge_base_id = ? ORDER BY name COLLATE NOCASE ASC, id ASC` ) .all(normalizedId) .map(mapEntity), relations: database .prepare( `SELECT * FROM graph_relations WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC` ) .all(normalizedId) .map(mapRelation), evidence: database .prepare( `SELECT * FROM graph_evidence WHERE knowledge_base_id = ? ORDER BY created_at ASC, id ASC` ) .all(normalizedId) .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') } if ( target.type !== source.type && (target.locked || source.locked) ) { throw new Error('Entities with different ontology types cannot be merged') } 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[] { return this.vectorSearchScoredWithCount(options).results } private vectorSearchScoredWithCount( options: VectorSearchOptions ): { results: ScoredSearchResult[] scannedCount: number } { 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 <> '' AND c.enabled = 1 AND c.role <> 'parent' ORDER BY ce.chunk_id ASC` ) .iterate( knowledgeBaseId, provider, model, queryVector.dimensions ) const winners: Array<{ chunkId: string; similarity: number }> = [] let scanned = 0 for (const row of rows) { scanned += 1 if (scanned % 256 === 0) { options.signal?.throwIfAborted() } const similarity = cosineSimilarity( queryVector.values, queryVector.magnitude, asBytes(row, 'embedding_vector'), asNumber(row, 'embedding_dimensions'), asNumber(row, 'embedding_magnitude') ) if (similarity === undefined || similarity < minimumSimilarity) { continue } const candidate = { chunkId: asString(row, 'chunk_id'), similarity } let insertionIndex = 0 while ( insertionIndex < winners.length && (winners[insertionIndex]!.similarity > candidate.similarity || (winners[insertionIndex]!.similarity === candidate.similarity && winners[insertionIndex]!.chunkId.localeCompare( candidate.chunkId ) < 0)) ) { insertionIndex += 1 } if (insertionIndex < limit) { winners.splice(insertionIndex, 0, candidate) if (winners.length > limit) { winners.pop() } } } if (winners.length === 0) { return { results: [], scannedCount: scanned } } 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 { results: 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 } } ] : [] }), scannedCount: scanned } } 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 AND ec.enabled = 1 AND ec.role <> 'parent' ) 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 rc.enabled = 1 AND rc.role <> 'parent' ) 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 AND nc.enabled = 1 AND nc.role <> 'parent' ) ), 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 = ? AND c.enabled = 1 AND c.role <> 'parent' 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 { const migrationTable = database .prepare( `SELECT 1 AS found FROM sqlite_schema WHERE type = 'table' AND name = 'schema_migrations'` ) .get() if (migrationTable) { const versions = database .prepare( `SELECT (SELECT COALESCE(MAX(version), 0) FROM schema_migrations) AS migration_version, user_version FROM pragma_user_version` ) .get() if ( versions && asNumber(versions, 'migration_version') === DATABASE_VERSION && asNumber(versions, 'user_version') === DATABASE_VERSION ) { return } } 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}` ) } const userVersionRow = database .prepare('PRAGMA user_version') .get() const userVersion = userVersionRow ? asNumber(userVersionRow, 'user_version') : 0 if (userVersion > DATABASE_VERSION) { throw new Error( `Knowledge database user version ${userVersion} 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()) } if (currentVersion < 5) { this.migrateToVersion5(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(5, new Date().toISOString()) } if (currentVersion < 6) { this.migrateToVersion6(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(6, new Date().toISOString()) } if (currentVersion < 7) { this.migrateToVersion7(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(7, new Date().toISOString()) } if (currentVersion < 8) { this.migrateToVersion8(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(8, new Date().toISOString()) } if (currentVersion < 9) { this.migrateToVersion9(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(9, new Date().toISOString()) } if (currentVersion < 10) { this.migrateToVersion10(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(10, new Date().toISOString()) } if (currentVersion < 11) { this.migrateToVersion11(database) database .prepare( 'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)' ) .run(11, 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 migrateToVersion5(database: DatabaseSync): void { const addColumn = ( table: 'knowledge_bases' | 'chunks', name: string, definition: string ): void => { const exists = database .prepare(`PRAGMA table_info(${table})`) .all() .some((row) => asString(row, 'name') === name) if (!exists) { database.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`) } } addColumn( 'knowledge_bases', 'retrieval_settings', `retrieval_settings TEXT NOT NULL DEFAULT '{"version":1,"topK":6,"minimumVectorSimilarity":0,"ftsWeight":1,"vectorWeight":1,"graphWeight":0.8,"candidateMultiplier":4,"contextMaxCharacters":16000,"adjacentChunkCount":0,"localRerankEnabled":false}'` ) addColumn( 'knowledge_bases', 'chunking_settings', `chunking_settings TEXT NOT NULL DEFAULT '{"version":1,"mode":"structure","targetCharacters":1600,"overlapCharacters":160,"parentCharacters":4800,"childCharacters":900}'` ) addColumn( 'knowledge_bases', 'chunking_rebuild_required', `chunking_rebuild_required INTEGER NOT NULL DEFAULT 0 CHECK (chunking_rebuild_required IN (0, 1))` ) addColumn( 'chunks', 'enabled', 'enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1))' ) addColumn( 'chunks', 'role', `role TEXT NOT NULL DEFAULT 'standalone' CHECK (role IN ('standalone', 'parent', 'child'))` ) addColumn( 'chunks', 'parent_chunk_id', 'parent_chunk_id TEXT REFERENCES chunks(id) ON DELETE SET NULL' ) addColumn( 'chunks', 'manually_edited', `manually_edited INTEGER NOT NULL DEFAULT 0 CHECK (manually_edited IN (0, 1))` ) addColumn('chunks', 'updated_at', 'updated_at TEXT') addColumn( 'chunks', 'cjk_search', `cjk_search TEXT NOT NULL DEFAULT ''` ) database.exec(` CREATE INDEX IF NOT EXISTS chunks_document_state_idx ON chunks(document_id, enabled, role, ordinal, id); CREATE INDEX IF NOT EXISTS chunks_parent_idx ON chunks(parent_chunk_id); DROP TRIGGER IF EXISTS chunks_after_insert; DROP TRIGGER IF EXISTS chunks_after_delete; DROP TRIGGER IF EXISTS chunks_after_update; DROP TRIGGER IF EXISTS chunks_cjk_after_insert; DROP TRIGGER IF EXISTS chunks_cjk_after_delete; DROP TRIGGER IF EXISTS chunks_cjk_after_update; `) const update = database.prepare( 'UPDATE chunks SET cjk_search = ?, updated_at = created_at WHERE id = ?' ) for (const row of database .prepare('SELECT id, content, heading FROM chunks ORDER BY rowid') .iterate()) { update.run( createCjkSearchText( `${asOptionalString(row, 'heading') ?? ''}\n${asString(row, 'content')}` ), asString(row, 'id') ) } database.exec(` CREATE TRIGGER chunks_after_insert AFTER INSERT ON chunks BEGIN INSERT INTO chunks_fts(rowid, content) SELECT new.rowid, new.content WHERE new.enabled = 1 AND new.role <> 'parent'; END; CREATE TRIGGER chunks_after_delete AFTER DELETE ON chunks WHEN old.enabled = 1 AND old.role <> 'parent' 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) SELECT 'delete', old.rowid, old.content WHERE old.enabled = 1 AND old.role <> 'parent'; INSERT INTO chunks_fts(rowid, content) SELECT new.rowid, new.content WHERE new.enabled = 1 AND new.role <> 'parent'; END; CREATE VIRTUAL TABLE IF NOT EXISTS chunks_cjk USING fts5( cjk_search, content='chunks', content_rowid='rowid', tokenize='unicode61' ); CREATE TRIGGER chunks_cjk_after_insert AFTER INSERT ON chunks BEGIN INSERT INTO chunks_cjk(rowid, cjk_search) SELECT new.rowid, new.cjk_search WHERE new.enabled = 1 AND new.role <> 'parent'; END; CREATE TRIGGER chunks_cjk_after_delete AFTER DELETE ON chunks WHEN old.enabled = 1 AND old.role <> 'parent' BEGIN INSERT INTO chunks_cjk(chunks_cjk, rowid, cjk_search) VALUES ('delete', old.rowid, old.cjk_search); END; CREATE TRIGGER chunks_cjk_after_update AFTER UPDATE ON chunks BEGIN INSERT INTO chunks_cjk(chunks_cjk, rowid, cjk_search) SELECT 'delete', old.rowid, old.cjk_search WHERE old.enabled = 1 AND old.role <> 'parent'; INSERT INTO chunks_cjk(rowid, cjk_search) SELECT new.rowid, new.cjk_search WHERE new.enabled = 1 AND new.role <> 'parent'; END; INSERT INTO chunks_cjk(chunks_cjk) VALUES ('rebuild'); `) } private migrateToVersion6(database: DatabaseSync): void { database.exec(` ALTER TABLE embedding_index_job RENAME TO embedding_index_job_global; CREATE TABLE embedding_index_job ( knowledge_base_id TEXT PRIMARY KEY REFERENCES knowledge_bases(id) ON DELETE CASCADE, status_json TEXT NOT NULL CHECK (length(status_json) <= 32768), updated_at TEXT NOT NULL ); INSERT INTO embedding_index_job (knowledge_base_id, status_json, updated_at) SELECT kb.id, legacy.status_json, legacy.updated_at FROM embedding_index_job_global legacy CROSS JOIN (SELECT id FROM knowledge_bases LIMIT 1) kb WHERE (SELECT COUNT(*) FROM knowledge_bases) = 1; DROP TABLE embedding_index_job_global; `) } private migrateToVersion7(database: DatabaseSync): void { database.exec(` CREATE TABLE knowledge_tasks ( id TEXT PRIMARY KEY, library_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, parent_task_id TEXT REFERENCES knowledge_tasks(id) ON DELETE SET NULL, retry_of_task_id TEXT REFERENCES knowledge_tasks(id) ON DELETE SET NULL, source_id TEXT REFERENCES knowledge_sources(id) ON DELETE SET NULL, document_id TEXT REFERENCES documents(id) ON DELETE SET NULL, document_name TEXT NOT NULL CHECK (length(document_name) BETWEEN 1 AND 512), scope TEXT NOT NULL CHECK (scope IN ('library', 'source', 'document')), kind TEXT NOT NULL CHECK (kind IN ( 'source-sync', 'document-process', 'document-rebuild', 'library-rebuild', 'embedding-rebuild', 'graph-rebuild', 'parsing', 'embedding', 'graph' )), stage TEXT NOT NULL CHECK (stage IN ( 'queued', 'syncing', 'reading', 'parsing', 'chunking', 'indexing', 'embedding', 'graph', 'finalizing' )), status TEXT NOT NULL CHECK (status IN ( 'queued', 'running', 'succeeded', 'failed', 'cancelled', 'skipped', 'interrupted' )), progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), completed_items INTEGER CHECK (completed_items IS NULL OR completed_items >= 0), total_items INTEGER CHECK (total_items IS NULL OR total_items >= 0), message TEXT CHECK (message IS NULL OR length(message) <= 1000), error_message TEXT CHECK (error_message IS NULL OR length(error_message) BETWEEN 1 AND 1000), error_remedy TEXT CHECK (error_remedy IS NULL OR length(error_remedy) BETWEEN 1 AND 1000), attempt INTEGER NOT NULL CHECK (attempt >= 1), dedupe_key TEXT CHECK (dedupe_key IS NULL OR length(dedupe_key) BETWEEN 1 AND 512), embedding_job_id TEXT CHECK (embedding_job_id IS NULL OR length(embedding_job_id) BETWEEN 1 AND 256), created_at TEXT NOT NULL, started_at TEXT, completed_at TEXT, updated_at TEXT NOT NULL, CHECK ( completed_items IS NULL OR total_items IS NULL OR completed_items <= total_items ), CHECK (status <> 'failed' OR error_message IS NOT NULL), CHECK (status <> 'succeeded' OR progress = 100), CHECK ( status IN ('queued', 'running') OR completed_at IS NOT NULL ) ); CREATE UNIQUE INDEX knowledge_tasks_active_dedupe_idx ON knowledge_tasks(library_id, dedupe_key) WHERE dedupe_key IS NOT NULL AND status IN ('queued', 'running'); CREATE INDEX knowledge_tasks_library_list_idx ON knowledge_tasks(library_id, created_at DESC, id DESC); CREATE INDEX knowledge_tasks_parent_idx ON knowledge_tasks(parent_task_id, created_at ASC, id ASC); CREATE INDEX knowledge_tasks_retry_idx ON knowledge_tasks(retry_of_task_id); CREATE INDEX knowledge_tasks_embedding_job_idx ON knowledge_tasks(embedding_job_id) WHERE embedding_job_id IS NOT NULL; `) } private migrateToVersion8(database: DatabaseSync): void { const ontology = JSON.stringify(defaultKnowledgeOntologySettings) .replaceAll("'", "''") const addColumn = ( table: 'knowledge_bases' | 'graph_evidence', name: string, definition: string ): void => { const exists = database .prepare(`PRAGMA table_info(${table})`) .all() .some((row) => asString(row, 'name') === name) if (!exists) { database.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`) } } addColumn( 'knowledge_bases', 'ontology_settings', `ontology_settings TEXT NOT NULL DEFAULT '${ontology}'` ) addColumn( 'knowledge_bases', 'ontology_rebuild_required', `ontology_rebuild_required INTEGER NOT NULL DEFAULT 0 CHECK (ontology_rebuild_required IN (0, 1))` ) addColumn( 'graph_evidence', 'start_offset', 'start_offset INTEGER CHECK (start_offset IS NULL OR start_offset >= 0)' ) addColumn( 'graph_evidence', 'end_offset', 'end_offset INTEGER CHECK (end_offset IS NULL OR end_offset >= 0)' ) addColumn( 'graph_evidence', 'confidence', `confidence REAL CHECK ( confidence IS NULL OR (confidence >= 0 AND confidence <= 1) )` ) addColumn( 'graph_evidence', 'source', `source TEXT NOT NULL DEFAULT 'legacy' CHECK (source IN ('rules', 'model', 'manual', 'legacy'))` ) addColumn( 'graph_evidence', 'provenance', `provenance TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(provenance))` ) } private migrateToVersion9(database: DatabaseSync): void { const hasIndexContent = database .prepare('PRAGMA table_info(chunks)') .all() .some((row) => asString(row, 'name') === 'index_content') if (!hasIndexContent) { database.exec( `ALTER TABLE chunks ADD COLUMN index_content TEXT NOT NULL DEFAULT ''` ) } database.exec(` DROP TRIGGER IF EXISTS chunks_after_insert; DROP TRIGGER IF EXISTS chunks_after_delete; DROP TRIGGER IF EXISTS chunks_after_update; DROP TRIGGER IF EXISTS chunks_cjk_after_insert; DROP TRIGGER IF EXISTS chunks_cjk_after_delete; DROP TRIGGER IF EXISTS chunks_cjk_after_update; DROP TABLE IF EXISTS chunks_fts; `) const update = database.prepare( `UPDATE chunks SET index_content = ?, cjk_search = ? WHERE id = ?` ) const selectPage = database.prepare( `SELECT rowid, id, content, heading, metadata FROM chunks WHERE rowid > ? ORDER BY rowid LIMIT 500` ) let lastRowId = 0 while (true) { const rows = selectPage.all(lastRowId) if (rows.length === 0) { break } for (const row of rows) { const content = chunkIndexContent( asString(row, 'content'), parseObject(asString(row, 'metadata')) ) update.run( content, createCjkSearchText( `${asOptionalString(row, 'heading') ?? ''}\n${content}` ), asString(row, 'id') ) } lastRowId = asNumber(rows.at(-1)!, 'rowid') } database.exec(` CREATE VIRTUAL TABLE chunks_fts USING fts5( index_content, content='chunks', content_rowid='rowid', tokenize='unicode61' ); CREATE TRIGGER chunks_after_insert AFTER INSERT ON chunks BEGIN INSERT INTO chunks_fts(rowid, index_content) SELECT new.rowid, new.index_content WHERE new.enabled = 1 AND new.role <> 'parent'; END; CREATE TRIGGER chunks_after_delete AFTER DELETE ON chunks WHEN old.enabled = 1 AND old.role <> 'parent' BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, index_content) VALUES ('delete', old.rowid, old.index_content); END; CREATE TRIGGER chunks_after_update AFTER UPDATE ON chunks BEGIN INSERT INTO chunks_fts(chunks_fts, rowid, index_content) SELECT 'delete', old.rowid, old.index_content WHERE old.enabled = 1 AND old.role <> 'parent'; INSERT INTO chunks_fts(rowid, index_content) SELECT new.rowid, new.index_content WHERE new.enabled = 1 AND new.role <> 'parent'; END; CREATE TRIGGER chunks_cjk_after_insert AFTER INSERT ON chunks BEGIN INSERT INTO chunks_cjk(rowid, cjk_search) SELECT new.rowid, new.cjk_search WHERE new.enabled = 1 AND new.role <> 'parent'; END; CREATE TRIGGER chunks_cjk_after_delete AFTER DELETE ON chunks WHEN old.enabled = 1 AND old.role <> 'parent' BEGIN INSERT INTO chunks_cjk(chunks_cjk, rowid, cjk_search) VALUES ('delete', old.rowid, old.cjk_search); END; CREATE TRIGGER chunks_cjk_after_update AFTER UPDATE ON chunks BEGIN INSERT INTO chunks_cjk(chunks_cjk, rowid, cjk_search) SELECT 'delete', old.rowid, old.cjk_search WHERE old.enabled = 1 AND old.role <> 'parent'; INSERT INTO chunks_cjk(rowid, cjk_search) SELECT new.rowid, new.cjk_search WHERE new.enabled = 1 AND new.role <> 'parent'; END; INSERT INTO chunks_fts(chunks_fts) VALUES ('rebuild'); INSERT INTO chunks_cjk(chunks_cjk) VALUES ('rebuild'); UPDATE knowledge_bases SET chunking_rebuild_required = CASE WHEN EXISTS ( SELECT 1 FROM documents WHERE documents.knowledge_base_id = knowledge_bases.id ) THEN 1 ELSE 0 END WHERE json_extract( chunking_settings, '$.contextualIndexingEnabled' ) = 1; `) } private migrateToVersion10(database: DatabaseSync): void { database.exec(` CREATE INDEX IF NOT EXISTS graph_evidence_chunk_idx ON graph_evidence(knowledge_base_id, chunk_id); CREATE INDEX IF NOT EXISTS graph_evidence_entity_idx ON graph_evidence(knowledge_base_id, entity_id); CREATE INDEX IF NOT EXISTS graph_evidence_relation_idx ON graph_evidence(knowledge_base_id, relation_id); CREATE INDEX IF NOT EXISTS graph_relations_source_idx ON graph_relations(knowledge_base_id, source_entity_id); CREATE INDEX IF NOT EXISTS graph_relations_target_idx ON graph_relations(knowledge_base_id, target_entity_id); `) } private migrateToVersion11(database: DatabaseSync): void { database.exec('DROP TABLE embedding_rebuild_staging;') database.exec(` CREATE TABLE embedding_rebuild_staging ( replacement_id TEXT NOT NULL, document_id TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, chunk_id 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, 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 validatePreparedDocumentEmbeddings( database: DatabaseSync, replacement: PreparedEmbeddingReplacement, documentId: string, chunks: ReturnType ): void { const indexableChunks = chunks.filter( (chunk) => chunk.enabled && chunk.role !== 'parent' ) if (indexableChunks.length === 0) { throw new Error( 'Prepared embedding replacement requires indexable chunks' ) } const stagedRows = database .prepare( `SELECT chunk_id, content_checksum, dimensions FROM embedding_rebuild_staging WHERE replacement_id = ? AND document_id = ? AND provider = ? AND model = ? ORDER BY chunk_id` ) .all( replacement.replacementId, documentId, replacement.provider, replacement.model ) if (stagedRows.length !== indexableChunks.length) { throw new Error('Embeddings must cover every candidate document chunk') } const chunksById = new Map( indexableChunks.map((chunk) => [ chunk.id, contentChecksum( chunkIndexContent(chunk.content, parseObject(chunk.metadata)) ) ]) ) let dimensions: number | undefined for (const row of stagedRows) { const chunkId = asString(row, 'chunk_id') const checksum = chunksById.get(chunkId) if ( checksum === undefined || checksum !== asString(row, 'content_checksum') ) { throw new Error('Prepared embedding content does not match the chunk') } chunksById.delete(chunkId) const rowDimensions = asNumber(row, 'dimensions') if (dimensions === undefined) { dimensions = rowDimensions } else if (dimensions !== rowDimensions) { throw new Error( 'Document embeddings must have consistent dimensions' ) } } if (chunksById.size > 0) { throw new Error('Embeddings must cover every candidate document chunk') } } private publishPreparedDocumentEmbeddings( database: DatabaseSync, replacement: PreparedEmbeddingReplacement, documentId: string, knowledgeBaseId: string, now: string ): void { let dimensions: number | undefined const indexHash = createHash('sha256') let firstChecksum = true for (const row of database .prepare( `SELECT chunk_id, content_checksum, dimensions FROM embedding_rebuild_staging WHERE replacement_id = ? AND document_id = ? AND provider = ? AND model = ? ORDER BY chunk_id` ) .iterate( replacement.replacementId, documentId, replacement.provider, replacement.model )) { const chunkId = asString(row, 'chunk_id') const checksum = asString(row, 'content_checksum') if (!firstChecksum) { indexHash.update('\n') } indexHash.update(`${chunkId}\0${checksum}`) firstChecksum = false dimensions ??= asNumber(row, 'dimensions') } 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 = ? AND document_id = ? AND provider = ? AND model = ?` ) .run( knowledgeBaseId, now, now, replacement.replacementId, documentId, replacement.provider, replacement.model ) 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, ?)` ) .run( documentId, knowledgeBaseId, replacement.provider, replacement.model, dimensions ?? null, indexHash.digest('hex'), now ) database .prepare( 'DELETE FROM embedding_rebuild_staging WHERE replacement_id = ?' ) .run(replacement.replacementId) } private insertEmbeddingReplacementBatch( database: DatabaseSync, input: { replacementId: string documentId: string provider: string model: string embeddings: ReadonlyArray<{ chunkId: string checksum: string bytes: Buffer dimensions: number magnitude: number }> } ): void { 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, () => { const existingTarget = database .prepare( `SELECT document_id, provider, model FROM embedding_rebuild_staging WHERE replacement_id = ? LIMIT 1` ) .get(input.replacementId) if ( existingTarget && ( asString(existingTarget, 'document_id') !== input.documentId || asString(existingTarget, 'provider') !== input.provider || asString(existingTarget, 'model') !== input.model ) ) { throw new Error( 'Embedding replacement target cannot change between batches' ) } for (const item of input.embeddings) { insert.run( input.replacementId, input.documentId, input.provider, input.model, item.chunkId, item.dimensions, item.checksum, item.bytes, item.magnitude ) } }) } private normalizeChunks(chunks: ReplaceChunkInput[]): Array<{ id: string ordinal: number content: string tokenCount?: number heading?: string location?: string metadata: string enabled: boolean role: KnowledgeChunkRole parentChunkId?: string manuallyEdited: boolean }> { let totalContent = 0 const ordinals = new Set() const ids = new Set() const normalized = 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`), enabled: chunk.enabled ?? true, role: knowledgeChunkRoleSchema.parse(chunk.role ?? 'standalone'), parentChunkId: optionalString( chunk.parentChunkId, `chunks[${index}].parentChunkId`, MAX_ID_LENGTH ), manuallyEdited: chunk.manuallyEdited ?? false } }) const byId = new Map(normalized.map((chunk) => [chunk.id, chunk])) for (const chunk of normalized) { if (chunk.role === 'child') { const parent = chunk.parentChunkId ? byId.get(chunk.parentChunkId) : undefined if (!parent || parent.role !== 'parent') { throw new Error('Child chunks must reference a parent chunk in the batch') } } else if (chunk.parentChunkId !== undefined) { throw new Error('Only child chunks may reference a parent chunk') } } return normalized } 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 assertOntologyRelation( database: DatabaseSync, knowledgeBaseId: string, sourceEntityId: string, targetEntityId: string, relationType: string ): void { if (sourceEntityId === targetEntityId) { throw new Error('Knowledge graph relations cannot target the same entity') } const rows = database .prepare( `SELECT id, type FROM graph_entities WHERE knowledge_base_id = ? AND id IN (?, ?)` ) .all(knowledgeBaseId, sourceEntityId, targetEntityId) const types = new Map( rows.map((row) => [asString(row, 'id'), asString(row, 'type')]) ) const sourceType = types.get(sourceEntityId) const targetType = types.get(targetEntityId) if ( !sourceType || !targetType || !isRelationEndpointAllowed( relationType, sourceType, targetType, this.requiredKnowledgeBase(knowledgeBaseId).ontologySettings ) ) { throw new Error( 'Relation endpoints are not allowed by the library ontology' ) } } 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 validateWeight(value: number, field: string): number { if (!Number.isFinite(value) || value < 0 || value > 2) { throw new RangeError(`${field} must be between 0 and 2`) } return value } private transaction(database: DatabaseSync, operation: () => void): void { if (database.isTransaction) { operation() return } 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 } private requiredKnowledgeTask(id: string): KnowledgeTaskItem { const value = this.getKnowledgeTask(id) if (!value) { throw new Error(`Knowledge task not found: ${id}`) } return value } }