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