3255 lines
98 KiB
TypeScript
3255 lines
98 KiB
TypeScript
import {
|
||
cp,
|
||
lstat,
|
||
mkdir,
|
||
readdir,
|
||
realpath,
|
||
rm,
|
||
stat
|
||
} from 'node:fs/promises'
|
||
import { watch, type FSWatcher } from 'node:fs'
|
||
import { createHash, randomUUID } from 'node:crypto'
|
||
import {
|
||
basename,
|
||
extname,
|
||
join,
|
||
relative,
|
||
resolve
|
||
} from 'node:path'
|
||
import {
|
||
buildChunkContextPrefix,
|
||
chunkDocumentAdvanced,
|
||
parseDocument,
|
||
supportedDocumentExtensions,
|
||
type ParsedDocument
|
||
} from './document-parser'
|
||
import {
|
||
knowledgeChunkDeleteInputSchema,
|
||
knowledgeChunksListInputSchema,
|
||
knowledgeChunkUpdateInputSchema,
|
||
knowledgeDocumentRebuildInputSchema,
|
||
knowledgeLibraryRebuildInputSchema,
|
||
knowledgeReferenceContextInputSchema,
|
||
knowledgeRetrieveInputSchema,
|
||
knowledgeSettingsUpdateInputSchema,
|
||
type KnowledgeChunkDeleteInput,
|
||
type KnowledgeChunksListInput,
|
||
type KnowledgeChunkUpdateInput,
|
||
type KnowledgeDocumentRebuildInput,
|
||
type KnowledgeLibraryRebuildInput,
|
||
type KnowledgeReferenceContextInput,
|
||
type KnowledgeRetrievalResponse,
|
||
type KnowledgeSettingsUpdateInput
|
||
} from '../../shared/knowledge-contracts'
|
||
import type {
|
||
KnowledgeTaskItem,
|
||
KnowledgeTaskKind,
|
||
KnowledgeTaskScope,
|
||
KnowledgeTaskStage,
|
||
KnowledgeTaskStatus
|
||
} from '../../shared/knowledge-task-contracts'
|
||
import type { RerankExecutionDiagnostics } from '../../shared/rerank-contracts'
|
||
import { classifyEmbeddingError } from './embedding-errors'
|
||
import { classifyRerankError } from './rerank-errors'
|
||
import { embeddingStorageProvider } from './embedding-provider-key'
|
||
import {
|
||
EmbeddingIndexCoordinator,
|
||
type EmbeddingIndexProvider
|
||
} from './embedding-index-coordinator'
|
||
import { KnowledgeEmbeddingIndexRepository } from './knowledge-embedding-index-repository'
|
||
import type {
|
||
EmbeddingIndexJob,
|
||
EmbeddingConfigurationSummary,
|
||
KnowledgeEmbeddingIndexSnapshot
|
||
} from '../../shared/embedding-contracts'
|
||
import {
|
||
extractKnowledgeGraph,
|
||
normalizeEntityAlias,
|
||
type ExtractStructured,
|
||
type GraphExtractionResult
|
||
} from './graph-extractor'
|
||
import { KnowledgeDatabase } from './knowledge-database'
|
||
import {
|
||
containsHanText,
|
||
contextualIndexText,
|
||
knowledgeRetrievalTerms
|
||
} from './retrieval-text'
|
||
import type {
|
||
CreateKnowledgeBaseInput,
|
||
Document,
|
||
GraphStrategy,
|
||
GraphEntity,
|
||
GraphRelation,
|
||
EmbeddingProvider,
|
||
HybridSearchResult,
|
||
KnowledgeBase,
|
||
KnowledgeSource,
|
||
RerankProvider,
|
||
ReplaceChunkInput,
|
||
SearchResult
|
||
} from './types'
|
||
import { UrlImporter } from './url-importer'
|
||
import { mimeTypeFromFileName } from '../file-media-type'
|
||
import {
|
||
isPathInside,
|
||
readBoundedFile
|
||
} from '../workspace-file-access'
|
||
|
||
type ScannedFile = {
|
||
absolutePath: string
|
||
relativePath: string
|
||
size: number
|
||
}
|
||
|
||
export type KnowledgeLibrarySnapshot = KnowledgeBase & {
|
||
sourceCount: number
|
||
documentCount: number
|
||
indexedDocumentCount: number
|
||
}
|
||
|
||
export type KnowledgeSourceSnapshot = KnowledgeSource & {
|
||
documentCount: number
|
||
progress: number
|
||
lastSyncedAt?: string
|
||
}
|
||
|
||
export type KnowledgeDocumentSnapshot = Document & {
|
||
chunkCount: number
|
||
status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed'
|
||
size?: number
|
||
error?: string
|
||
}
|
||
|
||
export type KnowledgeTaskSnapshot = KnowledgeTaskItem
|
||
|
||
export type KnowledgeSnapshot = {
|
||
libraries: KnowledgeLibrarySnapshot[]
|
||
sources: KnowledgeSourceSnapshot[]
|
||
documents: KnowledgeDocumentSnapshot[]
|
||
entities: GraphEntity[]
|
||
relations: GraphRelation[]
|
||
evidence: ReturnType<KnowledgeDatabase['listEvidence']>
|
||
tasks: KnowledgeTaskSnapshot[]
|
||
}
|
||
|
||
export type KnowledgeServiceOptions = {
|
||
databasePath: string
|
||
managedRoot: string
|
||
extractStructured?: ExtractStructured
|
||
urlImporter?: UrlImporter
|
||
embeddingProvider?: EmbeddingProvider
|
||
rerankProvider?: RerankProvider
|
||
embeddingBatchSize?: number
|
||
parseDocument?: (
|
||
name: string,
|
||
buffer: Buffer,
|
||
purpose: 'knowledge-index',
|
||
signal?: AbortSignal
|
||
) => Promise<ParsedDocument>
|
||
}
|
||
|
||
const supportedExtensions = new Set<string>(supportedDocumentExtensions)
|
||
const maximumFileBytes = 20 * 1024 * 1024
|
||
const maximumSourceBytes = 500 * 1024 * 1024
|
||
const maximumFilesPerSource = 2_000
|
||
const maximumEmbeddingChunksPerBatch = 32
|
||
|
||
interface PreparedQueryEmbedding {
|
||
provider?: EmbeddingProvider
|
||
providerStorageKey?: string
|
||
vector?: readonly number[]
|
||
durationMs: number
|
||
degradationReason?: string
|
||
}
|
||
|
||
export class KnowledgeService {
|
||
readonly database: KnowledgeDatabase
|
||
private readonly managedRoot: string
|
||
private readonly extractStructured?: ExtractStructured
|
||
private readonly urlImporter: UrlImporter
|
||
private readonly documentParser: NonNullable<
|
||
KnowledgeServiceOptions['parseDocument']
|
||
>
|
||
private embeddingProvider?: EmbeddingProvider
|
||
private rerankProvider?: RerankProvider
|
||
private readonly embeddingBatchSize: number
|
||
private readonly watchers = new Map<string, FSWatcher>()
|
||
private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||
private readonly embeddingEditTimers = new Map<
|
||
string,
|
||
ReturnType<typeof setTimeout>
|
||
>()
|
||
private readonly activeSyncs = new Map<string, Promise<void>>()
|
||
private readonly activeDocumentRebuilds = new Map<
|
||
string,
|
||
Promise<Document>
|
||
>()
|
||
private readonly backgroundEmbeddingReindexes = new Map<
|
||
string,
|
||
Promise<void>
|
||
>()
|
||
private readonly pendingEmbeddingReindexes = new Set<string>()
|
||
private readonly taskControllers = new Map<string, AbortController>()
|
||
private readonly taskOperations = new Map<string, Promise<unknown>>()
|
||
private readonly sourceSyncTaskIds = new Map<string, string>()
|
||
private readonly libraryRebuildControllers = new Map<
|
||
string,
|
||
AbortController
|
||
>()
|
||
private readonly embeddingIndexCoordinators = new Map<
|
||
string,
|
||
EmbeddingIndexCoordinator
|
||
>()
|
||
private readonly lifecycleController = new AbortController()
|
||
|
||
constructor(options: KnowledgeServiceOptions) {
|
||
this.database = new KnowledgeDatabase(options.databasePath)
|
||
this.managedRoot = resolve(options.managedRoot)
|
||
this.extractStructured = options.extractStructured
|
||
this.urlImporter = options.urlImporter ?? new UrlImporter()
|
||
this.documentParser =
|
||
options.parseDocument ??
|
||
((name, buffer) => parseDocument(name, buffer))
|
||
this.embeddingProvider = options.embeddingProvider
|
||
this.rerankProvider = options.rerankProvider
|
||
const embeddingBatchSize = options.embeddingBatchSize ?? 16
|
||
if (
|
||
!Number.isSafeInteger(embeddingBatchSize) ||
|
||
embeddingBatchSize < 1 ||
|
||
embeddingBatchSize > maximumEmbeddingChunksPerBatch
|
||
) {
|
||
throw new RangeError(
|
||
`embeddingBatchSize must be between 1 and ${maximumEmbeddingChunksPerBatch}`
|
||
)
|
||
}
|
||
this.embeddingBatchSize = embeddingBatchSize
|
||
}
|
||
|
||
async initialize(): Promise<void> {
|
||
await mkdir(this.managedRoot, { recursive: true })
|
||
this.database.initialize()
|
||
for (const library of this.database.listKnowledgeBases()) {
|
||
for (const source of this.database.listSources(library.id)) {
|
||
if (
|
||
library.storageMode === 'reference' &&
|
||
source.type !== 'url' &&
|
||
source.status === 'ready'
|
||
) {
|
||
this.startWatcher(source)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async dispose(): Promise<void> {
|
||
this.lifecycleController.abort(
|
||
new Error('Knowledge service is shutting down')
|
||
)
|
||
this.database.interruptActiveKnowledgeTasks('应用关闭,任务已中断')
|
||
for (const controller of this.taskControllers.values()) {
|
||
controller.abort(new Error('Knowledge task cancelled during shutdown'))
|
||
}
|
||
for (const controller of this.libraryRebuildControllers.values()) {
|
||
controller.abort(new Error('Knowledge rebuild cancelled during shutdown'))
|
||
}
|
||
for (const timer of this.syncTimers.values()) {
|
||
clearTimeout(timer)
|
||
}
|
||
this.syncTimers.clear()
|
||
for (const timer of this.embeddingEditTimers.values()) {
|
||
clearTimeout(timer)
|
||
}
|
||
this.embeddingEditTimers.clear()
|
||
this.pendingEmbeddingReindexes.clear()
|
||
for (const watcher of this.watchers.values()) {
|
||
watcher.close()
|
||
}
|
||
this.watchers.clear()
|
||
const embeddingCompletions = [
|
||
...this.embeddingIndexCoordinators.values()
|
||
].map((coordinator) => {
|
||
coordinator.cancel()
|
||
return coordinator.waitForCompletion()
|
||
})
|
||
await Promise.allSettled([
|
||
...this.activeSyncs.values(),
|
||
...this.activeDocumentRebuilds.values(),
|
||
...this.backgroundEmbeddingReindexes.values(),
|
||
...this.taskOperations.values(),
|
||
...embeddingCompletions
|
||
])
|
||
this.taskControllers.clear()
|
||
this.taskOperations.clear()
|
||
this.sourceSyncTaskIds.clear()
|
||
this.libraryRebuildControllers.clear()
|
||
this.database.close()
|
||
}
|
||
|
||
setEmbeddingProvider(provider?: EmbeddingProvider): Promise<void> {
|
||
const previous = this.embeddingProvider
|
||
if (
|
||
previous &&
|
||
(!provider ||
|
||
embeddingStorageProvider(previous) !==
|
||
embeddingStorageProvider(provider) ||
|
||
previous.model !== provider.model)
|
||
) {
|
||
for (const coordinator of this.embeddingIndexCoordinators.values()) {
|
||
coordinator.cancel()
|
||
}
|
||
}
|
||
this.embeddingProvider = provider
|
||
return Promise.resolve()
|
||
}
|
||
|
||
setRerankProvider(provider?: RerankProvider): Promise<void> {
|
||
this.rerankProvider = provider
|
||
return Promise.resolve()
|
||
}
|
||
|
||
async getEmbeddingIndexSnapshot(
|
||
knowledgeBaseId: string,
|
||
configuration?: EmbeddingConfigurationSummary
|
||
): Promise<KnowledgeEmbeddingIndexSnapshot> {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
const provider = this.embeddingProvider
|
||
const coordinator =
|
||
await this.getEmbeddingIndexCoordinator(library.id)
|
||
const fallbackTotal = provider
|
||
? 0
|
||
: this.database.countEmbeddingIndexDocuments(library.id)
|
||
this.reconcileEmbeddingTask(library.id, coordinator.status().job)
|
||
return {
|
||
knowledgeBaseId: library.id,
|
||
enabled: Boolean(provider),
|
||
...(provider && configuration ? { configuration } : {}),
|
||
coverage: provider
|
||
? this.database.getEmbeddingIndexCoverage(
|
||
library.id,
|
||
embeddingStorageProvider(provider),
|
||
provider.model
|
||
)
|
||
: {
|
||
total: fallbackTotal,
|
||
indexed: 0,
|
||
missing: fallbackTotal,
|
||
error: 0
|
||
},
|
||
indexStatus: coordinator.status()
|
||
}
|
||
}
|
||
|
||
async rebuildEmbeddingIndex(
|
||
knowledgeBaseId: string,
|
||
configuration: EmbeddingConfigurationSummary,
|
||
retryOfTaskId?: string
|
||
): Promise<KnowledgeEmbeddingIndexSnapshot> {
|
||
const provider = this.embeddingProvider
|
||
if (!provider) {
|
||
throw new Error('请先启用并保存向量模型设置')
|
||
}
|
||
const coordinator =
|
||
await this.getEmbeddingIndexCoordinator(knowledgeBaseId)
|
||
const job = coordinator.startRebuild(provider as EmbeddingIndexProvider)
|
||
this.createKnowledgeTask({
|
||
libraryId: knowledgeBaseId,
|
||
retryOfTaskId,
|
||
documentName: this.requireLibrary(knowledgeBaseId).name,
|
||
scope: 'library',
|
||
kind: 'embedding-rebuild',
|
||
stage: 'queued',
|
||
message: '等待重建向量索引',
|
||
dedupeKey: `embedding-rebuild:${knowledgeBaseId}`,
|
||
embeddingJobId: job.id,
|
||
attempt:
|
||
retryOfTaskId
|
||
? (this.database.getKnowledgeTask(retryOfTaskId)?.attempt ?? 0) + 1
|
||
: 1,
|
||
totalItems: job.progress.total
|
||
})
|
||
return this.getEmbeddingIndexSnapshot(
|
||
knowledgeBaseId,
|
||
configuration
|
||
)
|
||
}
|
||
|
||
async cancelEmbeddingIndex(
|
||
knowledgeBaseId: string,
|
||
jobId: string
|
||
): Promise<boolean> {
|
||
this.requireLibrary(knowledgeBaseId)
|
||
const coordinator =
|
||
await this.getEmbeddingIndexCoordinator(knowledgeBaseId)
|
||
return coordinator.cancel(jobId)
|
||
}
|
||
|
||
private async getEmbeddingIndexCoordinator(
|
||
knowledgeBaseId: string
|
||
): Promise<EmbeddingIndexCoordinator> {
|
||
const existing = this.embeddingIndexCoordinators.get(knowledgeBaseId)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
const coordinator = new EmbeddingIndexCoordinator(
|
||
new KnowledgeEmbeddingIndexRepository(
|
||
this.database,
|
||
knowledgeBaseId
|
||
)
|
||
)
|
||
await coordinator.initialize()
|
||
const concurrent = this.embeddingIndexCoordinators.get(knowledgeBaseId)
|
||
if (concurrent) {
|
||
return concurrent
|
||
}
|
||
this.embeddingIndexCoordinators.set(knowledgeBaseId, coordinator)
|
||
return coordinator
|
||
}
|
||
|
||
private createKnowledgeTask(input: {
|
||
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?: KnowledgeTaskItem['error']
|
||
attempt?: number
|
||
dedupeKey?: string
|
||
embeddingJobId?: string
|
||
}): KnowledgeTaskItem {
|
||
return this.database.createKnowledgeTask({
|
||
...input,
|
||
scope:
|
||
input.scope ??
|
||
(input.documentId
|
||
? 'document'
|
||
: input.sourceId
|
||
? 'source'
|
||
: 'library')
|
||
})
|
||
}
|
||
|
||
private updateKnowledgeTask(
|
||
taskId: string,
|
||
update: {
|
||
status?: KnowledgeTaskSnapshot['status']
|
||
progress?: number
|
||
message?: string
|
||
documentId?: string
|
||
documentName?: string
|
||
stage?: KnowledgeTaskStage
|
||
completedItems?: number
|
||
totalItems?: number
|
||
embeddingJobId?: string
|
||
}
|
||
): void {
|
||
this.database.updateKnowledgeTask(taskId, {
|
||
...update,
|
||
progress:
|
||
update.progress === undefined
|
||
? undefined
|
||
: Math.max(0, Math.min(100, Math.round(update.progress)))
|
||
})
|
||
}
|
||
|
||
private static taskErrorMessage(error: unknown): string {
|
||
const message = error instanceof Error ? error.message : '任务失败'
|
||
return message.slice(0, 1_000) || '任务失败'
|
||
}
|
||
|
||
private failKnowledgeTask(taskId: string, error: unknown): void {
|
||
const current = this.database.getKnowledgeTask(taskId)
|
||
if (
|
||
current?.status === 'succeeded' ||
|
||
current?.status === 'skipped'
|
||
) {
|
||
return
|
||
}
|
||
const message = KnowledgeService.taskErrorMessage(error)
|
||
this.database.updateKnowledgeTask(taskId, {
|
||
status: 'failed',
|
||
message,
|
||
error: { message }
|
||
})
|
||
}
|
||
|
||
private registerTaskController(
|
||
taskId: string,
|
||
controller: AbortController
|
||
): void {
|
||
this.taskControllers.set(taskId, controller)
|
||
}
|
||
|
||
private releaseTaskController(
|
||
taskId: string,
|
||
controller: AbortController
|
||
): void {
|
||
if (this.taskControllers.get(taskId) === controller) {
|
||
this.taskControllers.delete(taskId)
|
||
}
|
||
}
|
||
|
||
private trackTaskOperation<T>(
|
||
taskId: string,
|
||
operation: Promise<T>
|
||
): Promise<T> {
|
||
this.taskOperations.set(taskId, operation)
|
||
void operation
|
||
.finally(() => {
|
||
if (this.taskOperations.get(taskId) === operation) {
|
||
this.taskOperations.delete(taskId)
|
||
}
|
||
})
|
||
.catch(() => undefined)
|
||
return operation
|
||
}
|
||
|
||
private async cancelTasks(
|
||
tasks: readonly KnowledgeTaskItem[],
|
||
reason: string
|
||
): Promise<void> {
|
||
const operations = new Set<Promise<unknown>>()
|
||
const pending = [...tasks]
|
||
const visited = new Set<string>()
|
||
while (pending.length > 0) {
|
||
const task = pending.pop()
|
||
if (!task || visited.has(task.id)) {
|
||
continue
|
||
}
|
||
visited.add(task.id)
|
||
const controller = this.taskControllers.get(task.id)
|
||
if (controller && !controller.signal.aborted) {
|
||
controller.abort(new Error(reason))
|
||
}
|
||
const operation = this.taskOperations.get(task.id)
|
||
if (operation) {
|
||
operations.add(operation)
|
||
}
|
||
if (task.parentTaskId) {
|
||
const parent = this.database.getKnowledgeTask(task.parentTaskId)
|
||
if (parent) {
|
||
pending.push(parent)
|
||
}
|
||
}
|
||
}
|
||
await Promise.allSettled(operations)
|
||
}
|
||
|
||
private reconcileEmbeddingTask(
|
||
libraryId: string,
|
||
job: EmbeddingIndexJob | null
|
||
): void {
|
||
if (!job) {
|
||
return
|
||
}
|
||
const task =
|
||
this.database.getKnowledgeTaskByEmbeddingJobId(libraryId, job.id) ??
|
||
this.createKnowledgeTask({
|
||
libraryId,
|
||
documentName: this.requireLibrary(libraryId).name,
|
||
scope: 'library',
|
||
kind: 'embedding-rebuild',
|
||
stage: job.status === 'queued' ? 'queued' : 'embedding',
|
||
status:
|
||
job.status === 'completed'
|
||
? 'succeeded'
|
||
: job.status === 'failed'
|
||
? 'failed'
|
||
: job.status,
|
||
progress: Math.round(job.progress.percent),
|
||
completedItems: job.progress.completed,
|
||
totalItems: job.progress.total,
|
||
message: '向量索引重建',
|
||
error:
|
||
job.status === 'failed'
|
||
? { message: job.error?.message ?? '向量索引重建失败' }
|
||
: undefined,
|
||
embeddingJobId: job.id
|
||
})
|
||
const status: KnowledgeTaskStatus =
|
||
job.status === 'completed'
|
||
? 'succeeded'
|
||
: job.status === 'failed'
|
||
? 'failed'
|
||
: job.status
|
||
const stage = status === 'succeeded' ? 'finalizing' : 'embedding'
|
||
const progress = Math.round(job.progress.percent)
|
||
const message =
|
||
status === 'succeeded'
|
||
? '向量索引重建完成'
|
||
: status === 'cancelled'
|
||
? '向量索引重建已取消'
|
||
: '正在重建向量索引'
|
||
const error =
|
||
status === 'failed'
|
||
? {
|
||
message: job.error?.message ?? '向量索引重建失败',
|
||
remedy: job.error?.remedy
|
||
}
|
||
: undefined
|
||
if (
|
||
task.status === status &&
|
||
task.stage === stage &&
|
||
task.progress === progress &&
|
||
task.completedItems === job.progress.completed &&
|
||
task.totalItems === job.progress.total &&
|
||
task.message === message &&
|
||
task.error?.message === error?.message &&
|
||
task.error?.remedy === error?.remedy
|
||
) {
|
||
return
|
||
}
|
||
this.database.updateKnowledgeTask(task.id, {
|
||
status,
|
||
stage,
|
||
progress,
|
||
completedItems: job.progress.completed,
|
||
totalItems: job.progress.total,
|
||
message,
|
||
error: error ?? null
|
||
})
|
||
}
|
||
|
||
createLibrary(input: CreateKnowledgeBaseInput): KnowledgeBase {
|
||
return this.database.createKnowledgeBase(input)
|
||
}
|
||
|
||
async deleteLibrary(id: string): Promise<boolean> {
|
||
const library = this.database.getKnowledgeBase(id)
|
||
if (!library) {
|
||
return false
|
||
}
|
||
for (const source of this.database.listSources(id)) {
|
||
this.stopWatcher(source.id)
|
||
}
|
||
await this.cancelTasks(
|
||
this.database.listActiveKnowledgeTasks(id),
|
||
'Knowledge library deleted'
|
||
)
|
||
const embeddingCoordinator = this.embeddingIndexCoordinators.get(id)
|
||
embeddingCoordinator?.cancel()
|
||
await embeddingCoordinator?.waitForCompletion()
|
||
this.embeddingIndexCoordinators.delete(id)
|
||
const deleted = this.database.deleteKnowledgeBase(id)
|
||
if (deleted && library.storageMode === 'managed') {
|
||
const path = join(this.managedRoot, id)
|
||
if (isPathInside(this.managedRoot, path)) {
|
||
await rm(path, { recursive: true, force: true })
|
||
}
|
||
}
|
||
return deleted
|
||
}
|
||
|
||
snapshot(selectedLibraryId?: string): KnowledgeSnapshot {
|
||
const libraryCounts = this.database.getKnowledgeBaseCounts()
|
||
const libraries = this.database.listKnowledgeBases().map((library) => {
|
||
const counts = libraryCounts.get(library.id) ?? {
|
||
sourceCount: 0,
|
||
documentCount: 0,
|
||
indexedDocumentCount: 0
|
||
}
|
||
return {
|
||
...library,
|
||
...counts
|
||
}
|
||
})
|
||
const libraryId = selectedLibraryId ?? libraries[0]?.id
|
||
if (!libraryId) {
|
||
return {
|
||
libraries,
|
||
sources: [],
|
||
documents: [],
|
||
entities: [],
|
||
relations: [],
|
||
evidence: [],
|
||
tasks: []
|
||
}
|
||
}
|
||
const embeddingCoordinator =
|
||
this.embeddingIndexCoordinators.get(libraryId)
|
||
if (embeddingCoordinator) {
|
||
this.reconcileEmbeddingTask(
|
||
libraryId,
|
||
embeddingCoordinator.status().job
|
||
)
|
||
}
|
||
const libraryDocuments = this.database.listDocuments(libraryId)
|
||
const documentCountsBySource = new Map<string, number>()
|
||
for (const document of libraryDocuments) {
|
||
documentCountsBySource.set(
|
||
document.sourceId,
|
||
(documentCountsBySource.get(document.sourceId) ?? 0) + 1
|
||
)
|
||
}
|
||
const sources = this.database.listSources(libraryId).map((source) => ({
|
||
...source,
|
||
documentCount: documentCountsBySource.get(source.id) ?? 0,
|
||
progress:
|
||
typeof source.metadata.progress === 'number'
|
||
? source.metadata.progress
|
||
: source.status === 'ready'
|
||
? 100
|
||
: 0,
|
||
lastSyncedAt:
|
||
typeof source.metadata.lastSyncedAt === 'string'
|
||
? source.metadata.lastSyncedAt
|
||
: undefined
|
||
}))
|
||
const chunkCounts = this.database.getDocumentChunkCounts(libraryId)
|
||
const documents = libraryDocuments.map((document) => {
|
||
const status =
|
||
typeof document.metadata.status === 'string' &&
|
||
['queued', 'parsing', 'indexing', 'ready', 'failed'].includes(
|
||
document.metadata.status
|
||
)
|
||
? (document.metadata.status as KnowledgeDocumentSnapshot['status'])
|
||
: 'ready'
|
||
return {
|
||
...document,
|
||
chunkCount: chunkCounts.get(document.id) ?? 0,
|
||
status,
|
||
size:
|
||
typeof document.metadata.size === 'number'
|
||
? document.metadata.size
|
||
: undefined,
|
||
error:
|
||
typeof document.metadata.error === 'string'
|
||
? document.metadata.error
|
||
: undefined
|
||
}
|
||
})
|
||
const graph = this.database.listGraphSnapshot(libraryId)
|
||
return {
|
||
libraries,
|
||
sources,
|
||
documents,
|
||
entities: graph.entities,
|
||
relations: graph.relations,
|
||
evidence: graph.evidence,
|
||
tasks: this.database.listKnowledgeTasks(libraryId)
|
||
}
|
||
}
|
||
|
||
search(knowledgeBaseId: string, query: string, limit = 6): SearchResult[] {
|
||
return this.database.search({
|
||
knowledgeBaseId,
|
||
query,
|
||
limit
|
||
})
|
||
}
|
||
|
||
async retrieve(
|
||
rawInput: unknown,
|
||
signal?: AbortSignal,
|
||
preparedQueryEmbedding?: PreparedQueryEmbedding
|
||
): Promise<KnowledgeRetrievalResponse> {
|
||
const input = knowledgeRetrieveInputSchema.parse(rawInput)
|
||
const library = this.requireLibrary(input.knowledgeBaseId)
|
||
const settings = input.settings ?? library.retrievalSettings
|
||
const hasHanQuery = containsHanText(input.query)
|
||
const startedAt = Date.now()
|
||
const requestedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'> = []
|
||
if (settings.ftsWeight > 0) {
|
||
requestedChannels.push('fts')
|
||
if (hasHanQuery) {
|
||
requestedChannels.push('cjk')
|
||
}
|
||
}
|
||
if (settings.vectorWeight > 0) {
|
||
requestedChannels.push('vector')
|
||
}
|
||
if (settings.graphWeight > 0) {
|
||
requestedChannels.push('graph')
|
||
}
|
||
const degradedChannels: Array<{
|
||
channel: 'fts' | 'cjk' | 'vector' | 'graph'
|
||
reason: string
|
||
}> = []
|
||
const preparedEmbedding =
|
||
settings.vectorWeight > 0
|
||
? preparedQueryEmbedding ??
|
||
await this.prepareQueryEmbedding(input.query, signal)
|
||
: { durationMs: 0 }
|
||
const {
|
||
provider,
|
||
providerStorageKey,
|
||
vector,
|
||
durationMs: vectorDurationMs
|
||
} = preparedEmbedding
|
||
if (settings.vectorWeight > 0) {
|
||
if (preparedEmbedding.degradationReason) {
|
||
degradedChannels.push({
|
||
channel: 'vector',
|
||
reason: preparedEmbedding.degradationReason
|
||
})
|
||
}
|
||
}
|
||
if (settings.graphWeight > 0 && !library.graphEnabled) {
|
||
degradedChannels.push({
|
||
channel: 'graph',
|
||
reason: '知识图谱未启用。'
|
||
})
|
||
}
|
||
|
||
const retrievalStartedAt = Date.now()
|
||
const candidateLimit = Math.min(
|
||
100,
|
||
settings.topK * settings.candidateMultiplier
|
||
)
|
||
const searchPage = this.database.hybridSearchWithDiagnostics({
|
||
knowledgeBaseId: library.id,
|
||
query: input.query,
|
||
limit: candidateLimit,
|
||
provider: vector ? providerStorageKey : undefined,
|
||
model: vector ? provider?.model : undefined,
|
||
vector,
|
||
graphEnabled: library.graphEnabled,
|
||
minimumVectorSimilarity: settings.minimumVectorSimilarity,
|
||
candidateMultiplier: 1,
|
||
ftsWeight: settings.ftsWeight,
|
||
vectorWeight: settings.vectorWeight,
|
||
graphWeight: settings.graphWeight,
|
||
signal
|
||
})
|
||
const candidates = searchPage.results
|
||
const vectorScannedCount = searchPage.vectorScannedCount
|
||
if (
|
||
settings.vectorWeight > 0 &&
|
||
provider &&
|
||
providerStorageKey &&
|
||
vector &&
|
||
vectorScannedCount === 0
|
||
) {
|
||
degradedChannels.push({
|
||
channel: 'vector',
|
||
reason: '当前向量模型没有可用的兼容索引。'
|
||
})
|
||
}
|
||
const retrievalDurationMs = Date.now() - retrievalStartedAt
|
||
const maximumScore = Math.max(
|
||
...candidates.map((candidate) => candidate.retrieval.score),
|
||
0.000_001
|
||
)
|
||
const queryTerms = knowledgeRetrievalTerms(input.query, 64)
|
||
const documentOccurrences = new Map<string, number>()
|
||
type ScoredCandidate = {
|
||
candidate: HybridSearchResult
|
||
relevance: number
|
||
coverage: number
|
||
phrase: boolean
|
||
duplicatePenalty: number
|
||
preRerankRank: number
|
||
rerankScore?: number
|
||
}
|
||
let scored: ScoredCandidate[] = candidates.map((candidate, index) => {
|
||
const indexContent =
|
||
contextualIndexText(
|
||
candidate.chunk.content,
|
||
candidate.chunk.metadata.contextPrefix
|
||
)
|
||
.normalize('NFKC')
|
||
.toLowerCase()
|
||
const matched = queryTerms.filter((term) =>
|
||
indexContent.includes(term)
|
||
).length
|
||
const coverage =
|
||
queryTerms.length === 0 ? 0 : matched / queryTerms.length
|
||
const phrase = indexContent.includes(
|
||
input.query.normalize('NFKC').trim().toLowerCase()
|
||
)
|
||
const titleAndPath = `${candidate.document.title}\n${candidate.source.location}`
|
||
.normalize('NFKC')
|
||
.toLowerCase()
|
||
const metadataMatch = queryTerms.some((term) =>
|
||
titleAndPath.includes(term)
|
||
)
|
||
const duplicateIndex =
|
||
documentOccurrences.get(candidate.document.id) ?? 0
|
||
documentOccurrences.set(candidate.document.id, duplicateIndex + 1)
|
||
const base = candidate.retrieval.score / maximumScore
|
||
const similarity =
|
||
candidate.retrieval.similarity === undefined
|
||
? 0
|
||
: (candidate.retrieval.similarity + 1) / 2
|
||
const duplicatePenalty = Math.min(0.3, duplicateIndex * 0.08)
|
||
const localRelevance = Math.max(
|
||
0,
|
||
Math.min(
|
||
1,
|
||
base * 0.4 +
|
||
coverage * 0.3 +
|
||
(phrase ? 0.12 : 0) +
|
||
(metadataMatch ? 0.08 : 0) +
|
||
similarity * 0.1 -
|
||
duplicatePenalty
|
||
)
|
||
)
|
||
const relevance =
|
||
settings.rerankMode === 'none'
|
||
? Math.max(0, Math.min(1, base))
|
||
: localRelevance
|
||
return {
|
||
candidate,
|
||
relevance,
|
||
coverage,
|
||
phrase,
|
||
duplicatePenalty,
|
||
preRerankRank: index + 1
|
||
}
|
||
})
|
||
const localRerank = (): void => {
|
||
scored.sort(
|
||
(left, right) =>
|
||
right.relevance - left.relevance ||
|
||
left.preRerankRank - right.preRerankRank ||
|
||
left.candidate.chunk.id.localeCompare(right.candidate.chunk.id)
|
||
)
|
||
}
|
||
let rerankDiagnostics: RerankExecutionDiagnostics = {
|
||
requested: settings.rerankMode,
|
||
used: 'none',
|
||
status: 'skipped',
|
||
candidateCount: candidates.length,
|
||
durationMs: 0
|
||
}
|
||
if (settings.rerankMode === 'local') {
|
||
const rerankStartedAt = Date.now()
|
||
localRerank()
|
||
rerankDiagnostics = {
|
||
requested: 'local',
|
||
used: 'local',
|
||
status: 'applied',
|
||
candidateCount: candidates.length,
|
||
durationMs: Date.now() - rerankStartedAt
|
||
}
|
||
} else if (settings.rerankMode === 'learned') {
|
||
const rerankStartedAt = Date.now()
|
||
const rerankProvider = this.rerankProvider
|
||
if (!rerankProvider || scored.length === 0) {
|
||
const reason = rerankProvider
|
||
? '没有可供重排的候选。'
|
||
: '未配置可用的学习型重排模型。'
|
||
if (scored.length > 0) {
|
||
localRerank()
|
||
rerankDiagnostics = {
|
||
requested: 'learned',
|
||
used: 'local',
|
||
status: 'fallback',
|
||
candidateCount: candidates.length,
|
||
durationMs: Date.now() - rerankStartedAt,
|
||
reason
|
||
}
|
||
} else {
|
||
rerankDiagnostics = {
|
||
requested: 'learned',
|
||
used: 'none',
|
||
status: scored.length === 0 ? 'skipped' : 'fallback',
|
||
candidateCount: candidates.length,
|
||
durationMs: Date.now() - rerankStartedAt,
|
||
reason
|
||
}
|
||
}
|
||
} else {
|
||
const effectiveSignal = signal
|
||
? AbortSignal.any([signal, this.lifecycleController.signal])
|
||
: this.lifecycleController.signal
|
||
try {
|
||
const reranked = await rerankProvider.rerank(
|
||
input.query,
|
||
scored.map((item) =>
|
||
`${item.candidate.document.title}\n${
|
||
item.candidate.chunk.heading ?? ''
|
||
}\n${item.candidate.chunk.content}`.slice(0, 8_000)
|
||
),
|
||
Math.min(settings.topK, scored.length),
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
const byIndex = new Map(
|
||
reranked.map((result) => [result.index, result.relevanceScore])
|
||
)
|
||
scored = scored
|
||
.map((item, index) => ({
|
||
...item,
|
||
relevance: byIndex.get(index) ?? item.relevance,
|
||
rerankScore: byIndex.get(index)
|
||
}))
|
||
.sort((left, right) => {
|
||
const leftScore = left.rerankScore
|
||
const rightScore = right.rerankScore
|
||
if (leftScore === undefined && rightScore !== undefined) {
|
||
return 1
|
||
}
|
||
if (leftScore !== undefined && rightScore === undefined) {
|
||
return -1
|
||
}
|
||
return (
|
||
(rightScore ?? 0) - (leftScore ?? 0) ||
|
||
left.preRerankRank - right.preRerankRank
|
||
)
|
||
})
|
||
rerankDiagnostics = {
|
||
requested: 'learned',
|
||
used: 'learned',
|
||
status: 'applied',
|
||
candidateCount: candidates.length,
|
||
durationMs: Date.now() - rerankStartedAt,
|
||
model: rerankProvider.model
|
||
}
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
throw effectiveSignal.reason
|
||
}
|
||
const safeError = classifyRerankError(error)
|
||
localRerank()
|
||
rerankDiagnostics = {
|
||
requested: 'learned',
|
||
used: 'local',
|
||
status: 'fallback',
|
||
candidateCount: candidates.length,
|
||
durationMs: Date.now() - rerankStartedAt,
|
||
reason: safeError.message
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const selected = scored.slice(0, settings.topK)
|
||
const usedChannels = new Set<'fts' | 'cjk' | 'vector' | 'graph'>()
|
||
const results = selected.map((item, index) => {
|
||
const channels = item.candidate.retrieval.channels.flatMap((channel) => {
|
||
if (channel === 'fts' && hasHanQuery) {
|
||
return ['fts', 'cjk'] as const
|
||
}
|
||
return [channel]
|
||
})
|
||
for (const channel of channels) {
|
||
usedChannels.add(channel)
|
||
}
|
||
return {
|
||
knowledgeBaseId: library.id,
|
||
documentId: item.candidate.document.id,
|
||
sourceId: item.candidate.source.id,
|
||
chunkId: item.candidate.chunk.id,
|
||
parentChunkId: item.candidate.chunk.parentChunkId,
|
||
documentTitle: item.candidate.document.title,
|
||
sourceDisplayName: item.candidate.source.displayName,
|
||
sourceType: item.candidate.source.type,
|
||
heading: item.candidate.chunk.heading,
|
||
location: item.candidate.chunk.location,
|
||
snippet: item.candidate.snippet.slice(0, 8_000),
|
||
relevance: item.relevance,
|
||
rank: index + 1,
|
||
preRerankRank: rerankDiagnostics.used !== 'none'
|
||
? item.preRerankRank
|
||
: undefined,
|
||
channels: [...new Set(channels)],
|
||
scores: {
|
||
ftsRank: item.candidate.retrieval.lexicalRank,
|
||
cjkRank: hasHanQuery
|
||
? item.candidate.retrieval.lexicalRank
|
||
: undefined,
|
||
vectorRank: item.candidate.retrieval.vectorRank,
|
||
graphRank: item.candidate.retrieval.graphRank,
|
||
vectorSimilarity: item.candidate.retrieval.similarity,
|
||
fusedScore: item.candidate.retrieval.score,
|
||
phraseMatch: item.phrase,
|
||
tokenCoverage: item.coverage,
|
||
duplicatePenalty: item.duplicatePenalty,
|
||
rerankScore: item.rerankScore
|
||
}
|
||
}
|
||
})
|
||
if (
|
||
library.graphEnabled &&
|
||
settings.graphWeight > 0 &&
|
||
!usedChannels.has('graph')
|
||
) {
|
||
degradedChannels.push({
|
||
channel: 'graph',
|
||
reason: '知识图谱没有找到可用证据。'
|
||
})
|
||
}
|
||
|
||
let remaining = settings.contextMaxCharacters
|
||
let contextTruncated = false
|
||
let filteredByBudgetCount = 0
|
||
const emittedChunks = new Set<string>()
|
||
const groups: KnowledgeRetrievalResponse['context']['groups'] = []
|
||
for (const result of results) {
|
||
const reference = this.database.getChunkForReference(
|
||
result.knowledgeBaseId,
|
||
result.documentId,
|
||
result.chunkId
|
||
)
|
||
if (!reference) {
|
||
continue
|
||
}
|
||
const chunks = this.database
|
||
.listContextChunks(reference.chunk, settings.adjacentChunkCount)
|
||
.filter((chunk) => !emittedChunks.has(chunk.id))
|
||
if (chunks.length === 0) {
|
||
continue
|
||
}
|
||
const fullContent = chunks.map((chunk) => chunk.content).join('\n\n')
|
||
if (remaining <= 0) {
|
||
filteredByBudgetCount += 1
|
||
contextTruncated = true
|
||
continue
|
||
}
|
||
const content =
|
||
fullContent.length <= remaining
|
||
? fullContent
|
||
: this.truncateContext(fullContent, remaining)
|
||
const truncated = content.length < fullContent.length
|
||
if (truncated) {
|
||
contextTruncated = true
|
||
}
|
||
for (const chunk of chunks) {
|
||
emittedChunks.add(chunk.id)
|
||
}
|
||
groups.push({
|
||
resultChunkId: result.chunkId,
|
||
chunkIds: chunks.map((chunk) => chunk.id),
|
||
documentId: result.documentId,
|
||
content,
|
||
characterCount: content.length,
|
||
truncated
|
||
})
|
||
remaining -= content.length
|
||
}
|
||
const lexicalCandidates = candidates.filter((candidate) =>
|
||
candidate.retrieval.channels.includes('fts')
|
||
).length
|
||
const vectorCandidates = candidates.filter((candidate) =>
|
||
candidate.retrieval.channels.includes('vector')
|
||
).length
|
||
const graphCandidates = candidates.filter((candidate) =>
|
||
candidate.retrieval.channels.includes('graph')
|
||
).length
|
||
return {
|
||
query: input.query,
|
||
durationMs: Date.now() - startedAt,
|
||
settings,
|
||
diagnostics: {
|
||
requestedChannels,
|
||
usedChannels: [...usedChannels],
|
||
degradedChannels,
|
||
candidateCounts: {
|
||
fts: lexicalCandidates,
|
||
cjk: hasHanQuery ? lexicalCandidates : undefined,
|
||
vector: vectorCandidates,
|
||
graph: graphCandidates
|
||
},
|
||
channelDurationMs: {
|
||
fts: retrievalDurationMs,
|
||
cjk: hasHanQuery ? retrievalDurationMs : undefined,
|
||
vector: vectorDurationMs,
|
||
graph: retrievalDurationMs
|
||
},
|
||
vectorScannedCount,
|
||
filteredByThresholdCount: Math.max(
|
||
0,
|
||
vectorScannedCount - vectorCandidates
|
||
),
|
||
filteredByBudgetCount,
|
||
rerank: rerankDiagnostics
|
||
},
|
||
results,
|
||
context: {
|
||
characterCount:
|
||
settings.contextMaxCharacters - remaining,
|
||
truncated: contextTruncated,
|
||
groups
|
||
}
|
||
}
|
||
}
|
||
|
||
async retrieveMany(
|
||
knowledgeBaseIds: readonly string[],
|
||
query: string,
|
||
signal?: AbortSignal
|
||
): Promise<
|
||
Array<{
|
||
knowledgeBaseId: string
|
||
response: KnowledgeRetrievalResponse
|
||
}>
|
||
> {
|
||
const libraries = knowledgeBaseIds.map((id) =>
|
||
this.requireLibrary(id)
|
||
)
|
||
const preparedEmbedding = libraries.some(
|
||
(library) => library.retrievalSettings.vectorWeight > 0
|
||
)
|
||
? await this.prepareQueryEmbedding(query, signal)
|
||
: undefined
|
||
signal?.throwIfAborted()
|
||
return Promise.all(
|
||
libraries.map(async (library) => ({
|
||
knowledgeBaseId: library.id,
|
||
response: await this.retrieve(
|
||
{
|
||
knowledgeBaseId: library.id,
|
||
query
|
||
},
|
||
signal,
|
||
preparedEmbedding
|
||
)
|
||
}))
|
||
)
|
||
}
|
||
|
||
updateSettings(rawInput: KnowledgeSettingsUpdateInput): KnowledgeBase {
|
||
const input = knowledgeSettingsUpdateInputSchema.parse(rawInput)
|
||
return this.database.updateKnowledgeSettings(input)
|
||
}
|
||
|
||
listChunks(input: KnowledgeChunksListInput) {
|
||
return this.database.listChunksPage(
|
||
knowledgeChunksListInputSchema.parse(input)
|
||
)
|
||
}
|
||
|
||
async updateChunk(
|
||
rawInput: KnowledgeChunkUpdateInput
|
||
) {
|
||
const input = knowledgeChunkUpdateInputSchema.parse(rawInput)
|
||
const current = this.database.getChunkForReference(
|
||
input.knowledgeBaseId,
|
||
input.documentId,
|
||
input.chunkId
|
||
)?.chunk
|
||
const chunk = this.database.updateChunk(input)
|
||
if (
|
||
current &&
|
||
(
|
||
(input.content !== undefined &&
|
||
input.content !== current.content) ||
|
||
(input.enabled !== undefined &&
|
||
input.enabled !== current.enabled)
|
||
)
|
||
) {
|
||
this.scheduleEmbeddingReindex(input.documentId)
|
||
}
|
||
if (
|
||
current &&
|
||
input.content !== undefined &&
|
||
input.content !== current.content
|
||
) {
|
||
this.database.pruneUnreferencedGeneratedGraph(
|
||
input.knowledgeBaseId
|
||
)
|
||
}
|
||
return chunk
|
||
}
|
||
|
||
deleteChunk(rawInput: KnowledgeChunkDeleteInput): boolean {
|
||
const input = knowledgeChunkDeleteInputSchema.parse(rawInput)
|
||
const deleted = this.database.deleteChunk(input)
|
||
if (deleted) {
|
||
this.scheduleEmbeddingReindex(input.documentId)
|
||
this.database.pruneUnreferencedGeneratedGraph(
|
||
input.knowledgeBaseId
|
||
)
|
||
}
|
||
return deleted
|
||
}
|
||
|
||
getReferenceContext(rawInput: KnowledgeReferenceContextInput) {
|
||
const input = knowledgeReferenceContextInputSchema.parse(rawInput)
|
||
const reference = this.database.getChunkForReference(
|
||
input.knowledgeBaseId,
|
||
input.documentId,
|
||
input.chunkId
|
||
)
|
||
if (!reference || !reference.chunk.enabled) {
|
||
return undefined
|
||
}
|
||
return {
|
||
...reference,
|
||
contextChunks: this.database.listContextChunks(reference.chunk, 2)
|
||
}
|
||
}
|
||
|
||
async searchHybrid(
|
||
knowledgeBaseId: string,
|
||
query: string,
|
||
limit = 6,
|
||
signal?: AbortSignal
|
||
): Promise<HybridSearchResult[]> {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
const settings = library.retrievalSettings
|
||
const vector = await this.embedQuery(query, signal)
|
||
return this.database.hybridSearch({
|
||
knowledgeBaseId,
|
||
query,
|
||
limit: Math.min(limit, settings.topK),
|
||
provider:
|
||
vector && this.embeddingProvider
|
||
? embeddingStorageProvider(this.embeddingProvider)
|
||
: undefined,
|
||
model: vector ? this.embeddingProvider?.model : undefined,
|
||
vector,
|
||
graphEnabled: library.graphEnabled,
|
||
minimumVectorSimilarity: settings.minimumVectorSimilarity,
|
||
candidateMultiplier: settings.candidateMultiplier,
|
||
ftsWeight: settings.ftsWeight,
|
||
vectorWeight: settings.vectorWeight,
|
||
graphWeight: settings.graphWeight,
|
||
signal
|
||
})
|
||
}
|
||
|
||
async searchHybridMany(
|
||
knowledgeBaseIds: readonly string[],
|
||
query: string,
|
||
limitPerLibrary = 6,
|
||
signal?: AbortSignal
|
||
): Promise<
|
||
Array<{ knowledgeBaseId: string; result: HybridSearchResult }>
|
||
> {
|
||
const vector = await this.embedQuery(query, signal)
|
||
return knowledgeBaseIds.flatMap((knowledgeBaseId) => {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
const settings = library.retrievalSettings
|
||
return this.database
|
||
.hybridSearch({
|
||
knowledgeBaseId,
|
||
query,
|
||
limit: limitPerLibrary,
|
||
provider:
|
||
vector && this.embeddingProvider
|
||
? embeddingStorageProvider(this.embeddingProvider)
|
||
: undefined,
|
||
model: vector ? this.embeddingProvider?.model : undefined,
|
||
vector,
|
||
graphEnabled: library.graphEnabled,
|
||
minimumVectorSimilarity: settings.minimumVectorSimilarity,
|
||
candidateMultiplier: settings.candidateMultiplier,
|
||
ftsWeight: settings.ftsWeight,
|
||
vectorWeight: settings.vectorWeight,
|
||
graphWeight: settings.graphWeight,
|
||
signal
|
||
})
|
||
.map((result) => ({ knowledgeBaseId, result }))
|
||
}).sort(
|
||
(left, right) =>
|
||
right.result.retrieval.score - left.result.retrieval.score ||
|
||
left.result.chunk.id.localeCompare(right.result.chunk.id)
|
||
).slice(0, limitPerLibrary)
|
||
}
|
||
|
||
private async embedQuery(
|
||
query: string,
|
||
signal?: AbortSignal
|
||
): Promise<readonly number[] | undefined> {
|
||
return (await this.prepareQueryEmbedding(query, signal)).vector
|
||
}
|
||
|
||
private async prepareQueryEmbedding(
|
||
query: string,
|
||
signal?: AbortSignal
|
||
): Promise<PreparedQueryEmbedding> {
|
||
const provider = this.embeddingProvider
|
||
if (!provider) {
|
||
return {
|
||
durationMs: 0,
|
||
degradationReason: '未配置向量模型,已使用本地全文检索。'
|
||
}
|
||
}
|
||
const startedAt = Date.now()
|
||
const effectiveSignal = signal
|
||
? AbortSignal.any([signal, this.lifecycleController.signal])
|
||
: this.lifecycleController.signal
|
||
try {
|
||
const result = await provider.embed(
|
||
[query],
|
||
effectiveSignal
|
||
)
|
||
const vector = result.length === 1 ? result[0] : undefined
|
||
if (
|
||
!vector ||
|
||
vector.length === 0 ||
|
||
vector.some((value) => !Number.isFinite(value)) ||
|
||
vector.every((value) => value === 0)
|
||
) {
|
||
return {
|
||
provider,
|
||
providerStorageKey: embeddingStorageProvider(provider),
|
||
durationMs: Date.now() - startedAt,
|
||
degradationReason: '向量服务返回了无效的查询向量。'
|
||
}
|
||
}
|
||
return {
|
||
provider,
|
||
providerStorageKey: embeddingStorageProvider(provider),
|
||
vector,
|
||
durationMs: Date.now() - startedAt
|
||
}
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
throw effectiveSignal.reason
|
||
}
|
||
return {
|
||
provider,
|
||
providerStorageKey: embeddingStorageProvider(provider),
|
||
durationMs: Date.now() - startedAt,
|
||
degradationReason: classifyEmbeddingError(error).message
|
||
}
|
||
}
|
||
}
|
||
|
||
async importPaths(
|
||
knowledgeBaseId: string,
|
||
selectedPaths: string[],
|
||
graphStrategy?: Exclude<GraphStrategy, 'ask'>
|
||
): Promise<void> {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
if (selectedPaths.length === 0 || selectedPaths.length > 20) {
|
||
throw new Error('每次请选择 1 至 20 个文件或目录')
|
||
}
|
||
for (const selectedPath of selectedPaths) {
|
||
const canonicalPath = await realpath(selectedPath)
|
||
const fileStat = await lstat(canonicalPath)
|
||
if (fileStat.isSymbolicLink()) {
|
||
throw new Error('不能导入符号链接')
|
||
}
|
||
const sourceId = randomUUID()
|
||
const sourceType = fileStat.isDirectory() ? 'directory' : 'file'
|
||
const target =
|
||
library.storageMode === 'managed'
|
||
? join(
|
||
this.managedRoot,
|
||
knowledgeBaseId,
|
||
sourceId,
|
||
basename(canonicalPath)
|
||
)
|
||
: canonicalPath
|
||
let source = this.database.upsertSource({
|
||
id: sourceId,
|
||
knowledgeBaseId,
|
||
type: sourceType,
|
||
location: target,
|
||
displayName: basename(canonicalPath),
|
||
status: 'indexing',
|
||
metadata: {
|
||
originalLocation: canonicalPath,
|
||
progress: 0
|
||
}
|
||
})
|
||
const sourceTask = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
sourceId: source.id,
|
||
documentName: source.displayName,
|
||
scope: 'source',
|
||
kind: 'source-sync',
|
||
stage: 'syncing',
|
||
status: 'running',
|
||
progress: 0,
|
||
message: '正在导入知识来源',
|
||
dedupeKey: `source-sync:${source.id}`
|
||
})
|
||
const controller = new AbortController()
|
||
this.registerTaskController(sourceTask.id, controller)
|
||
this.sourceSyncTaskIds.set(source.id, sourceTask.id)
|
||
const effectiveSignal = AbortSignal.any([
|
||
controller.signal,
|
||
this.lifecycleController.signal
|
||
])
|
||
const operation = (async (): Promise<void> => {
|
||
try {
|
||
effectiveSignal.throwIfAborted()
|
||
if (library.storageMode === 'managed') {
|
||
await this.copySupportedSource(canonicalPath, target)
|
||
effectiveSignal.throwIfAborted()
|
||
}
|
||
await this.indexSource(
|
||
library,
|
||
source,
|
||
effectiveSignal,
|
||
sourceTask.id,
|
||
graphStrategy
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
source = this.database.upsertSource({
|
||
...source,
|
||
status: 'ready',
|
||
metadata: {
|
||
...source.metadata,
|
||
progress: 100,
|
||
lastSyncedAt: new Date().toISOString()
|
||
}
|
||
})
|
||
if (library.storageMode === 'reference') {
|
||
this.startWatcher(source)
|
||
}
|
||
this.updateKnowledgeTask(sourceTask.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '知识来源导入完成'
|
||
})
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(
|
||
sourceTask.id,
|
||
'知识来源导入已取消'
|
||
)
|
||
} else {
|
||
this.failKnowledgeTask(sourceTask.id, error)
|
||
}
|
||
if (this.database.getSource(source.id)) {
|
||
this.database.upsertSource({
|
||
...source,
|
||
status: effectiveSignal.aborted ? 'paused' : 'error',
|
||
lastError:
|
||
effectiveSignal.aborted
|
||
? null
|
||
: error instanceof Error
|
||
? error.message.slice(0, 1_000)
|
||
: '来源导入失败',
|
||
metadata: {
|
||
...source.metadata,
|
||
progress: 0
|
||
}
|
||
})
|
||
}
|
||
throw error
|
||
}
|
||
})()
|
||
this.trackTaskOperation(sourceTask.id, operation)
|
||
try {
|
||
await operation
|
||
} finally {
|
||
this.releaseTaskController(sourceTask.id, controller)
|
||
if (this.sourceSyncTaskIds.get(source.id) === sourceTask.id) {
|
||
this.sourceSyncTaskIds.delete(source.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private trackStandaloneUrlImport(
|
||
taskId: string,
|
||
operation: Promise<void>,
|
||
controller?: AbortController
|
||
): Promise<void> {
|
||
if (!controller) {
|
||
return operation
|
||
}
|
||
return this.trackTaskOperation(taskId, operation)
|
||
}
|
||
|
||
async importUrl(
|
||
knowledgeBaseId: string,
|
||
input: string,
|
||
signal: AbortSignal,
|
||
sourceId?: string,
|
||
graphStrategy?: Exclude<GraphStrategy, 'ask'>,
|
||
parentTaskId?: string
|
||
): Promise<void> {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
const parsingTask = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
parentTaskId,
|
||
sourceId,
|
||
documentName: new URL(input).hostname,
|
||
scope: sourceId ? 'document' : 'source',
|
||
kind: 'document-process'
|
||
})
|
||
const controller = parentTaskId ? undefined : new AbortController()
|
||
if (controller) {
|
||
this.registerTaskController(parsingTask.id, controller)
|
||
}
|
||
const effectiveSignal = AbortSignal.any([
|
||
signal,
|
||
this.lifecycleController.signal,
|
||
AbortSignal.timeout(60_000),
|
||
...(controller ? [controller.signal] : [])
|
||
])
|
||
const operation = (async (): Promise<void> => {
|
||
let result: Awaited<ReturnType<UrlImporter['import']>>
|
||
try {
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
status: 'running',
|
||
stage: 'reading',
|
||
progress: 10,
|
||
message: '正在抓取并解析网页'
|
||
})
|
||
result = await this.urlImporter.import(input, effectiveSignal)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
stage: 'indexing',
|
||
progress: 70,
|
||
message: '正在保存网页内容'
|
||
})
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(parsingTask.id, '网页处理已取消')
|
||
} else {
|
||
this.failKnowledgeTask(parsingTask.id, error)
|
||
}
|
||
throw error
|
||
}
|
||
let source = this.database.upsertSource({
|
||
id: sourceId,
|
||
knowledgeBaseId,
|
||
type: 'url',
|
||
location: result.url,
|
||
displayName: result.title,
|
||
status: 'indexing',
|
||
metadata: {
|
||
etag: result.etag ?? '',
|
||
lastModified: result.lastModified ?? '',
|
||
contentType: result.contentType,
|
||
discoveredUrls: result.discoveredUrls
|
||
}
|
||
})
|
||
try {
|
||
effectiveSignal.throwIfAborted()
|
||
const effectiveLibrary = this.resolveImportLibrary(
|
||
knowledgeBaseId,
|
||
graphStrategy
|
||
)
|
||
const previousDocument = this.database
|
||
.listDocumentsForSource(source.id)
|
||
.find((document) => document.externalId === result.url)
|
||
if (previousDocument) {
|
||
this.database.removeEvidenceForDocument(previousDocument.id)
|
||
}
|
||
const document = this.database.upsertDocument(
|
||
{
|
||
knowledgeBaseId,
|
||
sourceId: source.id,
|
||
externalId: result.url,
|
||
title: result.title,
|
||
mimeType: result.contentType,
|
||
sourceLocation: result.url,
|
||
checksum: createHash('sha256')
|
||
.update(result.document.content)
|
||
.digest('hex'),
|
||
metadata: {
|
||
status: 'ready',
|
||
size: Buffer.byteLength(result.document.content)
|
||
}
|
||
},
|
||
this.createDocumentChunks(
|
||
result.document,
|
||
effectiveLibrary
|
||
)
|
||
)
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
stage: 'embedding',
|
||
progress: 80,
|
||
message: '网页解析完成,正在生成索引'
|
||
})
|
||
await this.indexDocumentEmbeddings(
|
||
document,
|
||
undefined,
|
||
parsingTask.id,
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
stage: 'graph',
|
||
progress: 92,
|
||
message: '正在处理知识图谱'
|
||
})
|
||
await this.extractGraph(
|
||
effectiveLibrary,
|
||
document,
|
||
parsingTask.id,
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
source = this.database.upsertSource({
|
||
...source,
|
||
status: 'ready',
|
||
metadata: {
|
||
...source.metadata,
|
||
progress: 100,
|
||
lastSyncedAt: new Date().toISOString()
|
||
}
|
||
})
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '网页处理完成'
|
||
})
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(parsingTask.id, '网页处理已取消')
|
||
} else {
|
||
this.failKnowledgeTask(parsingTask.id, error)
|
||
}
|
||
if (!effectiveSignal.aborted) {
|
||
this.database.upsertSource({
|
||
...source,
|
||
status: 'error',
|
||
lastError:
|
||
error instanceof Error
|
||
? error.message.slice(0, 1_000)
|
||
: 'URL 导入失败'
|
||
})
|
||
}
|
||
throw error
|
||
}
|
||
})()
|
||
this.trackStandaloneUrlImport(parsingTask.id, operation, controller)
|
||
try {
|
||
await operation
|
||
} finally {
|
||
if (controller) {
|
||
this.releaseTaskController(parsingTask.id, controller)
|
||
}
|
||
}
|
||
}
|
||
|
||
pauseSource(sourceId: string): void {
|
||
const source = this.requireSource(sourceId)
|
||
this.stopWatcher(sourceId)
|
||
const taskId = this.sourceSyncTaskIds.get(sourceId)
|
||
const controller = taskId
|
||
? this.taskControllers.get(taskId)
|
||
: undefined
|
||
controller?.abort(new Error('Knowledge source paused'))
|
||
this.database.upsertSource({
|
||
...source,
|
||
status: 'paused'
|
||
})
|
||
}
|
||
|
||
async syncSource(sourceId: string): Promise<void> {
|
||
return this.syncSourceOperation(sourceId)
|
||
}
|
||
|
||
private async syncSourceOperation(
|
||
sourceId: string,
|
||
retryOfTaskId?: string
|
||
): Promise<void> {
|
||
const existing = this.activeSyncs.get(sourceId)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
const operation = this.performSyncSource(
|
||
sourceId,
|
||
retryOfTaskId
|
||
).finally(() => {
|
||
if (this.activeSyncs.get(sourceId) === operation) {
|
||
this.activeSyncs.delete(sourceId)
|
||
}
|
||
})
|
||
this.activeSyncs.set(sourceId, operation)
|
||
return operation
|
||
}
|
||
|
||
async retrySource(sourceId: string): Promise<void> {
|
||
return this.syncSource(sourceId)
|
||
}
|
||
|
||
async rebuildDocument(
|
||
rawInput: KnowledgeDocumentRebuildInput,
|
||
signal?: AbortSignal,
|
||
parentTaskId?: string,
|
||
retryOfTaskId?: string
|
||
): Promise<Document> {
|
||
const input = knowledgeDocumentRebuildInputSchema.parse(rawInput)
|
||
if (!parentTaskId) {
|
||
const existing = this.activeDocumentRebuilds.get(input.documentId)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
const operation = this.performRebuildDocument(
|
||
input,
|
||
signal,
|
||
undefined,
|
||
retryOfTaskId
|
||
).finally(() => {
|
||
if (this.activeDocumentRebuilds.get(input.documentId) === operation) {
|
||
this.activeDocumentRebuilds.delete(input.documentId)
|
||
}
|
||
})
|
||
this.activeDocumentRebuilds.set(input.documentId, operation)
|
||
return operation
|
||
}
|
||
return this.performRebuildDocument(
|
||
input,
|
||
signal,
|
||
parentTaskId,
|
||
retryOfTaskId
|
||
)
|
||
}
|
||
|
||
private async performRebuildDocument(
|
||
input: KnowledgeDocumentRebuildInput,
|
||
signal?: AbortSignal,
|
||
parentTaskId?: string,
|
||
retryOfTaskId?: string
|
||
): Promise<Document> {
|
||
const document = this.database.getDocument(input.documentId)
|
||
if (!document || document.knowledgeBaseId !== input.knowledgeBaseId) {
|
||
throw new Error('文档不属于指定知识库')
|
||
}
|
||
const library = this.requireLibrary(input.knowledgeBaseId)
|
||
const source = this.database.getSource(document.sourceId)
|
||
if (!source) {
|
||
throw new Error('知识来源不存在')
|
||
}
|
||
const task = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
parentTaskId,
|
||
retryOfTaskId,
|
||
sourceId: source.id,
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
scope: 'document',
|
||
kind: 'document-rebuild',
|
||
dedupeKey: parentTaskId
|
||
? undefined
|
||
: `document-rebuild:${document.id}`,
|
||
attempt:
|
||
retryOfTaskId
|
||
? (this.database.getKnowledgeTask(retryOfTaskId)?.attempt ?? 0) + 1
|
||
: 1
|
||
})
|
||
const ownController = parentTaskId ? undefined : new AbortController()
|
||
if (ownController) {
|
||
this.registerTaskController(task.id, ownController)
|
||
}
|
||
const signals = [this.lifecycleController.signal]
|
||
if (signal) {
|
||
signals.push(signal)
|
||
}
|
||
if (ownController) {
|
||
signals.push(ownController.signal)
|
||
}
|
||
const effectiveSignal = AbortSignal.any(signals)
|
||
const operation = (async (): Promise<Document> => {
|
||
effectiveSignal.throwIfAborted()
|
||
if (source.type === 'url') {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'running',
|
||
stage: 'reading',
|
||
progress: 5,
|
||
message: '正在重新抓取网页'
|
||
})
|
||
await this.importUrl(
|
||
library.id,
|
||
source.location,
|
||
effectiveSignal,
|
||
source.id,
|
||
undefined,
|
||
task.id
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
const rebuilt = this.database.getDocument(document.id) ?? document
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '文档重建完成'
|
||
})
|
||
return rebuilt
|
||
}
|
||
const location = document.sourceLocation
|
||
if (!location) {
|
||
throw new Error('文档没有可重建的原始文件位置')
|
||
}
|
||
const fileStat = await lstat(location)
|
||
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
|
||
throw new Error('原始文件不存在或不是普通文件')
|
||
}
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'running',
|
||
stage: 'reading',
|
||
progress: 5,
|
||
message: '正在读取文档'
|
||
})
|
||
const buffer = await readBoundedFile(
|
||
location,
|
||
maximumFileBytes,
|
||
'文件超过 20MB',
|
||
'文件不是普通文件'
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
stage: 'parsing',
|
||
progress: 20,
|
||
message: '正在解析文档'
|
||
})
|
||
const parsed = await this.documentParser(
|
||
basename(location),
|
||
buffer,
|
||
'knowledge-index',
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
stage: 'chunking',
|
||
progress: 45,
|
||
message: '正在切分文档'
|
||
})
|
||
this.database.removeEvidenceForDocument(document.id)
|
||
const rebuilt = this.database.upsertDocument(
|
||
{
|
||
...document,
|
||
checksum: createHash('sha256').update(buffer).digest('hex'),
|
||
metadata: {
|
||
...document.metadata,
|
||
status: 'ready',
|
||
size: fileStat.size
|
||
}
|
||
},
|
||
this.createDocumentChunks(parsed, library)
|
||
)
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
this.updateKnowledgeTask(task.id, {
|
||
stage: 'embedding',
|
||
progress: 65,
|
||
message: '正在生成向量索引'
|
||
})
|
||
await this.indexDocumentEmbeddings(
|
||
rebuilt,
|
||
undefined,
|
||
task.id,
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
stage: 'graph',
|
||
progress: 88,
|
||
message: '正在处理知识图谱'
|
||
})
|
||
await this.extractGraph(library, rebuilt, task.id, effectiveSignal)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '文档重建完成'
|
||
})
|
||
return rebuilt
|
||
})()
|
||
this.trackTaskOperation(task.id, operation)
|
||
try {
|
||
return await operation
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(task.id, '文档重建已取消')
|
||
} else {
|
||
this.failKnowledgeTask(task.id, error)
|
||
}
|
||
throw error
|
||
} finally {
|
||
if (ownController) {
|
||
this.releaseTaskController(task.id, ownController)
|
||
}
|
||
}
|
||
}
|
||
|
||
async rebuildLibrary(
|
||
rawInput: KnowledgeLibraryRebuildInput,
|
||
signal?: AbortSignal,
|
||
retryOfTaskId?: string
|
||
): Promise<{ rebuilt: number; failed: number }> {
|
||
const input = knowledgeLibraryRebuildInputSchema.parse(rawInput)
|
||
const library = this.requireLibrary(input.knowledgeBaseId)
|
||
const ownController = new AbortController()
|
||
const previous = this.libraryRebuildControllers.get(library.id)
|
||
if (previous) {
|
||
throw new Error('知识库重建已在进行中')
|
||
}
|
||
this.libraryRebuildControllers.set(library.id, ownController)
|
||
const effectiveSignal = signal
|
||
? AbortSignal.any([
|
||
signal,
|
||
ownController.signal,
|
||
this.lifecycleController.signal
|
||
])
|
||
: AbortSignal.any([ownController.signal, this.lifecycleController.signal])
|
||
const documents = this.database.listDocumentsForLibraryRebuild(library.id)
|
||
const task = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
retryOfTaskId,
|
||
documentName: library.name,
|
||
scope: 'library',
|
||
kind: 'library-rebuild',
|
||
stage: 'queued',
|
||
totalItems: documents.length,
|
||
message: '等待重建知识库',
|
||
dedupeKey: `library-rebuild:${library.id}`,
|
||
attempt:
|
||
retryOfTaskId
|
||
? (this.database.getKnowledgeTask(retryOfTaskId)?.attempt ?? 0) + 1
|
||
: 1
|
||
})
|
||
this.registerTaskController(task.id, ownController)
|
||
let rebuilt = 0
|
||
let failed = 0
|
||
const operation = (async (): Promise<{
|
||
rebuilt: number
|
||
failed: number
|
||
}> => {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'running',
|
||
stage: 'parsing',
|
||
message: '正在重建知识库'
|
||
})
|
||
for (const document of documents) {
|
||
effectiveSignal.throwIfAborted()
|
||
try {
|
||
await this.rebuildDocument(
|
||
{
|
||
knowledgeBaseId: library.id,
|
||
documentId: document.id
|
||
},
|
||
effectiveSignal,
|
||
task.id
|
||
)
|
||
rebuilt += 1
|
||
} catch {
|
||
if (effectiveSignal.aborted) {
|
||
throw effectiveSignal.reason
|
||
}
|
||
failed += 1
|
||
}
|
||
this.updateKnowledgeTask(task.id, {
|
||
progress: ((rebuilt + failed) / Math.max(documents.length, 1)) * 100,
|
||
completedItems: rebuilt + failed,
|
||
totalItems: documents.length,
|
||
message: `已处理 ${rebuilt + failed}/${documents.length} 个文档`
|
||
})
|
||
}
|
||
if (failed === 0) {
|
||
this.database.markKnowledgeChunkingRebuilt(library.id)
|
||
if (library.graphEnabled && library.graphStrategy !== 'ask') {
|
||
this.database.markKnowledgeOntologyRebuilt(library.id)
|
||
}
|
||
}
|
||
if (failed === 0) {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: `已重建 ${rebuilt} 个文档`
|
||
})
|
||
} else {
|
||
this.database.updateKnowledgeTask(task.id, {
|
||
status: 'failed',
|
||
stage: 'finalizing',
|
||
message: `${failed} 个文档重建失败`,
|
||
error: { message: `${failed} 个文档重建失败` }
|
||
})
|
||
}
|
||
return { rebuilt, failed }
|
||
})()
|
||
this.trackTaskOperation(task.id, operation)
|
||
try {
|
||
return await operation
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(task.id, '知识库重建已取消')
|
||
} else {
|
||
this.failKnowledgeTask(task.id, error)
|
||
}
|
||
throw error
|
||
} finally {
|
||
this.releaseTaskController(task.id, ownController)
|
||
if (this.libraryRebuildControllers.get(library.id) === ownController) {
|
||
this.libraryRebuildControllers.delete(library.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
cancelLibraryRebuild(knowledgeBaseId: string): boolean {
|
||
const controller = this.libraryRebuildControllers.get(knowledgeBaseId)
|
||
if (!controller || controller.signal.aborted) {
|
||
return false
|
||
}
|
||
controller.abort(new Error('Knowledge library rebuild cancelled'))
|
||
return true
|
||
}
|
||
|
||
async cancelTask(taskId: string): Promise<boolean> {
|
||
const task = this.database.getKnowledgeTask(taskId)
|
||
if (!task || !task.canCancel) {
|
||
return false
|
||
}
|
||
if (task.kind === 'embedding-rebuild' && task.embeddingJobId) {
|
||
const coordinator =
|
||
await this.getEmbeddingIndexCoordinator(task.libraryId)
|
||
const cancelled = coordinator.cancel(task.embeddingJobId)
|
||
if (cancelled) {
|
||
this.database.cancelKnowledgeTask(task.id, '向量索引重建已取消')
|
||
}
|
||
return cancelled
|
||
}
|
||
if (
|
||
task.kind === 'library-rebuild' &&
|
||
this.cancelLibraryRebuild(task.libraryId)
|
||
) {
|
||
this.database.cancelKnowledgeTask(task.id, '知识库重建已取消')
|
||
return true
|
||
}
|
||
const controller = this.taskControllers.get(task.id)
|
||
if (controller && !controller.signal.aborted) {
|
||
controller.abort(new Error('Knowledge task cancelled'))
|
||
this.database.cancelKnowledgeTask(task.id)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
async retryTask(taskId: string): Promise<void> {
|
||
const task = this.database.getKnowledgeTask(taskId)
|
||
if (!task || !task.canRetry) {
|
||
throw new Error('该任务当前不可重试')
|
||
}
|
||
switch (task.kind) {
|
||
case 'source-sync': {
|
||
if (!task.sourceId) {
|
||
throw new Error('任务缺少知识来源')
|
||
}
|
||
await this.syncSourceOperation(task.sourceId, task.id)
|
||
return
|
||
}
|
||
case 'document-rebuild': {
|
||
if (!task.documentId) {
|
||
throw new Error('任务缺少文档')
|
||
}
|
||
await this.rebuildDocument(
|
||
{
|
||
knowledgeBaseId: task.libraryId,
|
||
documentId: task.documentId
|
||
},
|
||
undefined,
|
||
undefined,
|
||
task.id
|
||
)
|
||
return
|
||
}
|
||
case 'library-rebuild':
|
||
await this.rebuildLibrary(
|
||
{ knowledgeBaseId: task.libraryId },
|
||
undefined,
|
||
task.id
|
||
)
|
||
return
|
||
case 'graph-rebuild':
|
||
await this.reextractGraph(task.libraryId, task.id)
|
||
return
|
||
case 'embedding-rebuild': {
|
||
const provider = this.embeddingProvider
|
||
if (!provider) {
|
||
throw new Error('请先启用并保存向量模型设置')
|
||
}
|
||
await this.rebuildEmbeddingIndex(
|
||
task.libraryId,
|
||
{
|
||
provider: provider.provider,
|
||
model: provider.model,
|
||
credentialConfigured: false
|
||
},
|
||
task.id
|
||
)
|
||
return
|
||
}
|
||
default:
|
||
throw new Error('该任务类型不支持重试')
|
||
}
|
||
}
|
||
|
||
async reextractGraph(
|
||
knowledgeBaseId: string,
|
||
retryOfTaskId?: string
|
||
): Promise<void> {
|
||
const existing = this.database.getActiveKnowledgeTaskByDedupeKey(
|
||
knowledgeBaseId,
|
||
`graph-rebuild:${knowledgeBaseId}`
|
||
)
|
||
const existingOperation = existing
|
||
? this.taskOperations.get(existing.id)
|
||
: undefined
|
||
if (existingOperation) {
|
||
await existingOperation
|
||
return
|
||
}
|
||
await this.performReextractGraph(knowledgeBaseId, retryOfTaskId)
|
||
}
|
||
|
||
private async performReextractGraph(
|
||
knowledgeBaseId: string,
|
||
retryOfTaskId?: string
|
||
): Promise<void> {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
if (!library.graphEnabled) {
|
||
throw new Error('请先启用知识图谱')
|
||
}
|
||
if (library.graphStrategy === 'ask') {
|
||
throw new Error('按需询问策略不会自动抽取,请在设置中选择其他策略')
|
||
}
|
||
const documents =
|
||
this.database.listDocumentsForLibraryRebuild(library.id)
|
||
const parentTask = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
retryOfTaskId,
|
||
documentName: library.name,
|
||
scope: 'library',
|
||
kind: 'graph-rebuild',
|
||
stage: 'graph',
|
||
status: 'running',
|
||
totalItems: documents.length,
|
||
message: '正在重建知识图谱',
|
||
dedupeKey: `graph-rebuild:${library.id}`,
|
||
attempt:
|
||
retryOfTaskId
|
||
? (this.database.getKnowledgeTask(retryOfTaskId)?.attempt ?? 0) + 1
|
||
: 1
|
||
})
|
||
const controller = new AbortController()
|
||
this.registerTaskController(parentTask.id, controller)
|
||
const effectiveSignal = AbortSignal.any([
|
||
controller.signal,
|
||
this.lifecycleController.signal
|
||
])
|
||
const tasks: KnowledgeTaskItem[] = []
|
||
const operation = (async (): Promise<void> => {
|
||
for (let index = 0; index < documents.length; index += 1) {
|
||
effectiveSignal.throwIfAborted()
|
||
const document = documents[index]
|
||
if (!document) {
|
||
continue
|
||
}
|
||
const task = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
parentTaskId: parentTask.id,
|
||
sourceId: document.sourceId,
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
kind: 'graph',
|
||
message: '等待重新抽取'
|
||
})
|
||
tasks.push(task)
|
||
try {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'running',
|
||
progress: 10,
|
||
message: '正在重新抽取知识图谱'
|
||
})
|
||
const result = await this.extractGraphResult(
|
||
library,
|
||
document,
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
progress: 85,
|
||
message: '正在保存实体和关系'
|
||
})
|
||
this.database.replaceEvidenceForDocument(document.id, () => {
|
||
this.storeExtractedGraph(library, document, result)
|
||
})
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系`
|
||
})
|
||
this.updateKnowledgeTask(parentTask.id, {
|
||
progress: ((index + 1) / Math.max(documents.length, 1)) * 100,
|
||
completedItems: index + 1,
|
||
totalItems: documents.length,
|
||
message: `已处理 ${index + 1}/${documents.length} 个文档`
|
||
})
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(
|
||
task.id,
|
||
'知识图谱重建已取消'
|
||
)
|
||
} else {
|
||
this.failKnowledgeTask(task.id, error)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
effectiveSignal.throwIfAborted()
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
this.database.markKnowledgeOntologyRebuilt(library.id)
|
||
this.updateKnowledgeTask(parentTask.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: `已重建 ${documents.length} 个文档的知识图谱`
|
||
})
|
||
})()
|
||
this.trackTaskOperation(parentTask.id, operation)
|
||
try {
|
||
await operation
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(
|
||
parentTask.id,
|
||
'知识图谱重建已取消'
|
||
)
|
||
} else {
|
||
this.failKnowledgeTask(parentTask.id, error)
|
||
}
|
||
for (const childTask of tasks) {
|
||
const current = this.database.getKnowledgeTask(childTask.id)
|
||
if (current?.status === 'queued' || current?.status === 'running') {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(
|
||
childTask.id,
|
||
'知识图谱重建已取消'
|
||
)
|
||
} else {
|
||
this.updateKnowledgeTask(childTask.id, {
|
||
status: 'skipped',
|
||
message: '因前序图谱任务失败而未执行'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
throw error
|
||
} finally {
|
||
this.releaseTaskController(parentTask.id, controller)
|
||
}
|
||
}
|
||
|
||
async removeSource(sourceId: string): Promise<boolean> {
|
||
const source = this.requireSource(sourceId)
|
||
const library = this.requireLibrary(source.knowledgeBaseId)
|
||
this.stopWatcher(sourceId)
|
||
const sourceTaskId = this.sourceSyncTaskIds.get(sourceId)
|
||
await this.cancelTasks(
|
||
this.database
|
||
.listActiveKnowledgeTasks(library.id)
|
||
.filter(
|
||
(task) =>
|
||
task.sourceId === sourceId ||
|
||
(sourceTaskId !== undefined && task.id === sourceTaskId)
|
||
),
|
||
'Knowledge source deleted'
|
||
)
|
||
const removed = this.database.removeSource(sourceId)
|
||
if (removed) {
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
}
|
||
if (
|
||
removed &&
|
||
library.storageMode === 'managed' &&
|
||
source.type !== 'url' &&
|
||
isPathInside(this.managedRoot, source.location)
|
||
) {
|
||
await rm(
|
||
join(this.managedRoot, library.id, source.id),
|
||
{ recursive: true, force: true }
|
||
)
|
||
}
|
||
return removed
|
||
}
|
||
|
||
private async performSyncSource(
|
||
sourceId: string,
|
||
retryOfTaskId?: string
|
||
): Promise<void> {
|
||
let source = this.requireSource(sourceId)
|
||
const library = this.requireLibrary(source.knowledgeBaseId)
|
||
const task = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
sourceId: source.id,
|
||
documentName: source.displayName,
|
||
scope: 'source',
|
||
kind: 'source-sync',
|
||
stage: 'syncing',
|
||
status: 'running',
|
||
message: '正在同步知识来源',
|
||
dedupeKey: `source-sync:${source.id}`,
|
||
retryOfTaskId,
|
||
attempt:
|
||
retryOfTaskId
|
||
? (this.database.getKnowledgeTask(retryOfTaskId)?.attempt ?? 0) + 1
|
||
: 1
|
||
})
|
||
const controller = new AbortController()
|
||
this.registerTaskController(task.id, controller)
|
||
this.sourceSyncTaskIds.set(source.id, task.id)
|
||
const effectiveSignal = AbortSignal.any([
|
||
controller.signal,
|
||
this.lifecycleController.signal
|
||
])
|
||
const operation = (async (): Promise<void> => {
|
||
if (source.type === 'url') {
|
||
await this.importUrl(
|
||
library.id,
|
||
source.location,
|
||
effectiveSignal,
|
||
source.id,
|
||
undefined,
|
||
task.id
|
||
)
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '知识来源同步完成'
|
||
})
|
||
return
|
||
}
|
||
source = this.database.upsertSource({
|
||
...source,
|
||
status: 'indexing',
|
||
lastError: null,
|
||
metadata: { ...source.metadata, progress: 0 }
|
||
})
|
||
await this.indexSource(library, source, effectiveSignal, task.id)
|
||
effectiveSignal.throwIfAborted()
|
||
source = this.database.upsertSource({
|
||
...source,
|
||
status: 'ready',
|
||
metadata: {
|
||
...source.metadata,
|
||
progress: 100,
|
||
lastSyncedAt: new Date().toISOString()
|
||
}
|
||
})
|
||
if (library.storageMode === 'reference') {
|
||
this.startWatcher(source)
|
||
}
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '知识来源同步完成'
|
||
})
|
||
})()
|
||
this.trackTaskOperation(task.id, operation)
|
||
try {
|
||
await operation
|
||
} catch (error) {
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(task.id, '知识来源同步已取消')
|
||
if (this.database.getSource(source.id)) {
|
||
this.database.upsertSource({
|
||
...source,
|
||
status: 'paused',
|
||
lastError: null
|
||
})
|
||
}
|
||
} else {
|
||
this.failKnowledgeTask(task.id, error)
|
||
}
|
||
if (!effectiveSignal.aborted) {
|
||
const currentSource = this.database.getSource(source.id)
|
||
if (currentSource) {
|
||
this.database.upsertSource({
|
||
...currentSource,
|
||
status: 'error',
|
||
lastError:
|
||
error instanceof Error
|
||
? error.message.slice(0, 1_000)
|
||
: '同步失败'
|
||
})
|
||
}
|
||
}
|
||
throw error
|
||
} finally {
|
||
this.releaseTaskController(task.id, controller)
|
||
if (this.sourceSyncTaskIds.get(source.id) === task.id) {
|
||
this.sourceSyncTaskIds.delete(source.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
private async indexSource(
|
||
library: KnowledgeBase,
|
||
source: KnowledgeSource,
|
||
signal: AbortSignal,
|
||
parentTaskId?: string,
|
||
graphStrategy?: Exclude<GraphStrategy, 'ask'>
|
||
): Promise<void> {
|
||
signal.throwIfAborted()
|
||
const files = await this.scanSource(source.location)
|
||
signal.throwIfAborted()
|
||
const existing = this.database
|
||
.listDocumentsForSource(source.id)
|
||
const currentExternalIds = new Set(files.map((file) => file.relativePath))
|
||
for (const document of existing) {
|
||
signal.throwIfAborted()
|
||
if (!currentExternalIds.has(document.externalId)) {
|
||
this.database.removeDocument(document.id)
|
||
}
|
||
}
|
||
if (existing.some(
|
||
(document) => !currentExternalIds.has(document.externalId)
|
||
)) {
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
}
|
||
|
||
const failures: string[] = []
|
||
for (let index = 0; index < files.length; index += 1) {
|
||
signal.throwIfAborted()
|
||
const file = files[index]
|
||
if (!file) {
|
||
continue
|
||
}
|
||
const parsingTask = this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
parentTaskId,
|
||
sourceId: source.id,
|
||
documentName: file.relativePath,
|
||
scope: 'document',
|
||
kind: 'parsing'
|
||
})
|
||
try {
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
status: 'running',
|
||
stage: 'reading',
|
||
progress: 10,
|
||
message: '正在读取文档'
|
||
})
|
||
const buffer = await readBoundedFile(
|
||
file.absolutePath,
|
||
maximumFileBytes,
|
||
'文件超过 20MB',
|
||
'文件不是普通文件'
|
||
)
|
||
signal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
stage: 'parsing',
|
||
progress: 35,
|
||
message: '正在解析文档内容'
|
||
})
|
||
const checksum = createHash('sha256').update(buffer).digest('hex')
|
||
const previous = existing.find(
|
||
(document) => document.externalId === file.relativePath
|
||
)
|
||
if (previous?.checksum === checksum) {
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
status: 'skipped',
|
||
message: '文档内容未发生变化'
|
||
})
|
||
continue
|
||
}
|
||
const parsed = await this.documentParser(
|
||
basename(file.absolutePath),
|
||
buffer,
|
||
'knowledge-index',
|
||
signal
|
||
)
|
||
signal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
stage: 'indexing',
|
||
progress: 75,
|
||
message: '正在保存解析结果'
|
||
})
|
||
const effectiveLibrary = this.resolveImportLibrary(
|
||
library.id,
|
||
graphStrategy
|
||
)
|
||
const document = this.database.upsertDocument(
|
||
{
|
||
knowledgeBaseId: library.id,
|
||
sourceId: source.id,
|
||
externalId: file.relativePath,
|
||
title: parsed.title,
|
||
mimeType: mimeTypeFromFileName(
|
||
file.absolutePath,
|
||
'text/plain'
|
||
),
|
||
sourceLocation: file.absolutePath,
|
||
checksum,
|
||
metadata: {
|
||
status: 'ready',
|
||
size: file.size
|
||
}
|
||
},
|
||
this.createDocumentChunks(parsed, effectiveLibrary)
|
||
)
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
stage: 'embedding',
|
||
progress: 80,
|
||
message: '文档解析完成,正在生成索引'
|
||
})
|
||
this.database.removeEvidenceForDocument(document.id)
|
||
await this.indexDocumentEmbeddings(
|
||
document,
|
||
undefined,
|
||
parsingTask.id,
|
||
signal
|
||
)
|
||
signal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
stage: 'graph',
|
||
progress: 92,
|
||
message: '正在处理知识图谱'
|
||
})
|
||
await this.extractGraph(
|
||
effectiveLibrary,
|
||
document,
|
||
parsingTask.id,
|
||
signal
|
||
)
|
||
signal.throwIfAborted()
|
||
this.updateKnowledgeTask(parsingTask.id, {
|
||
status: 'succeeded',
|
||
stage: 'finalizing',
|
||
message: '文档处理完成'
|
||
})
|
||
} catch (error) {
|
||
if (signal.aborted) {
|
||
this.database.cancelKnowledgeTask(
|
||
parsingTask.id,
|
||
'文档处理已取消'
|
||
)
|
||
throw signal.reason
|
||
}
|
||
this.failKnowledgeTask(parsingTask.id, error)
|
||
failures.push(
|
||
`${file.relativePath}: ${
|
||
KnowledgeService.taskErrorMessage(error)
|
||
}`
|
||
)
|
||
}
|
||
this.database.upsertSource({
|
||
...source,
|
||
status: 'indexing',
|
||
metadata: {
|
||
...source.metadata,
|
||
progress: Math.round(((index + 1) / Math.max(files.length, 1)) * 100)
|
||
}
|
||
})
|
||
}
|
||
if (failures.length > 0) {
|
||
const detail = failures.slice(0, 5).join(';')
|
||
throw new Error(
|
||
`${failures.length} 个文件处理失败:${detail}`.slice(0, 1_000)
|
||
)
|
||
}
|
||
}
|
||
|
||
private scheduleEmbeddingReindex(documentId: string): void {
|
||
const existing = this.embeddingEditTimers.get(documentId)
|
||
if (existing) {
|
||
clearTimeout(existing)
|
||
}
|
||
const timer = setTimeout(() => {
|
||
this.embeddingEditTimers.delete(documentId)
|
||
if (this.backgroundEmbeddingReindexes.has(documentId)) {
|
||
this.pendingEmbeddingReindexes.add(documentId)
|
||
return
|
||
}
|
||
this.startBackgroundEmbeddingReindex(documentId)
|
||
}, 250)
|
||
timer.unref?.()
|
||
this.embeddingEditTimers.set(documentId, timer)
|
||
}
|
||
|
||
private startBackgroundEmbeddingReindex(documentId: string): void {
|
||
if (
|
||
this.backgroundEmbeddingReindexes.has(documentId) ||
|
||
this.lifecycleController.signal.aborted
|
||
) {
|
||
return
|
||
}
|
||
const operation = (async () => {
|
||
do {
|
||
this.pendingEmbeddingReindexes.delete(documentId)
|
||
const document = this.database.getDocument(documentId)
|
||
if (
|
||
!document ||
|
||
!this.embeddingProvider ||
|
||
this.lifecycleController.signal.aborted
|
||
) {
|
||
return
|
||
}
|
||
await this.indexDocumentEmbeddings(document)
|
||
} while (this.pendingEmbeddingReindexes.delete(documentId))
|
||
})()
|
||
.catch(() => undefined)
|
||
.finally(() => {
|
||
this.pendingEmbeddingReindexes.delete(documentId)
|
||
if (
|
||
this.backgroundEmbeddingReindexes.get(documentId) === operation
|
||
) {
|
||
this.backgroundEmbeddingReindexes.delete(documentId)
|
||
}
|
||
})
|
||
this.backgroundEmbeddingReindexes.set(documentId, operation)
|
||
}
|
||
|
||
private cancelScheduledEmbeddingReindex(documentId: string): void {
|
||
const timer = this.embeddingEditTimers.get(documentId)
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
this.embeddingEditTimers.delete(documentId)
|
||
}
|
||
}
|
||
|
||
private async indexDocumentEmbeddings(
|
||
document: Document,
|
||
requestedProvider?: EmbeddingProvider,
|
||
operationTaskId?: string,
|
||
signal?: AbortSignal
|
||
): Promise<void> {
|
||
this.cancelScheduledEmbeddingReindex(document.id)
|
||
const provider = requestedProvider ?? this.embeddingProvider
|
||
const task = operationTaskId
|
||
? this.createKnowledgeTask({
|
||
libraryId: document.knowledgeBaseId,
|
||
parentTaskId: operationTaskId,
|
||
sourceId: document.sourceId,
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
kind: 'embedding'
|
||
})
|
||
: this.createKnowledgeTask({
|
||
libraryId: document.knowledgeBaseId,
|
||
sourceId: document.sourceId,
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
kind: 'embedding'
|
||
})
|
||
if (!provider) {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'skipped',
|
||
message: '未启用向量化'
|
||
})
|
||
return
|
||
}
|
||
const providerStorageKey = embeddingStorageProvider(provider)
|
||
const effectiveSignal = signal
|
||
? AbortSignal.any([signal, this.lifecycleController.signal])
|
||
: this.lifecycleController.signal
|
||
let replacementId: string | undefined
|
||
try {
|
||
effectiveSignal.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'running',
|
||
stage: 'embedding',
|
||
progress: 5,
|
||
message: '正在准备文档分块'
|
||
})
|
||
const chunks = this.database
|
||
.getEmbeddingIndexDocument(document.id)?.items ?? []
|
||
replacementId = this.database.beginDocumentEmbeddingReplacement(
|
||
document.id,
|
||
providerStorageKey,
|
||
provider.model
|
||
)
|
||
let expectedDimensions: number | undefined
|
||
for (
|
||
let offset = 0;
|
||
offset < chunks.length;
|
||
offset += this.embeddingBatchSize
|
||
) {
|
||
effectiveSignal.throwIfAborted()
|
||
const batch = chunks.slice(offset, offset + this.embeddingBatchSize)
|
||
const vectors = await provider.embed(
|
||
batch.map((chunk) => chunk.content),
|
||
effectiveSignal
|
||
)
|
||
effectiveSignal.throwIfAborted()
|
||
if (vectors.length !== batch.length) {
|
||
throw new Error('Embedding provider returned an invalid result count')
|
||
}
|
||
const embeddings: Array<{
|
||
chunkId: string
|
||
contentChecksum: string
|
||
vector: readonly number[]
|
||
}> = []
|
||
for (let index = 0; index < batch.length; index += 1) {
|
||
const chunk = batch[index]
|
||
const vector = vectors[index]
|
||
if (!chunk || !vector) {
|
||
throw new Error('Embedding provider returned an incomplete batch')
|
||
}
|
||
if (expectedDimensions === undefined) {
|
||
expectedDimensions = vector.length
|
||
} else if (vector.length !== expectedDimensions) {
|
||
throw new Error('Embedding provider returned inconsistent dimensions')
|
||
}
|
||
embeddings.push({
|
||
chunkId: chunk.id,
|
||
contentChecksum:
|
||
chunk.contentChecksum ??
|
||
createHash('sha256').update(chunk.content).digest('hex'),
|
||
vector
|
||
})
|
||
}
|
||
this.database.appendDocumentEmbeddingBatch(
|
||
replacementId,
|
||
document.id,
|
||
providerStorageKey,
|
||
provider.model,
|
||
embeddings
|
||
)
|
||
this.updateKnowledgeTask(task.id, {
|
||
progress:
|
||
5 +
|
||
((offset + batch.length) / Math.max(chunks.length, 1)) * 85,
|
||
message: `正在向量化 ${Math.min(
|
||
offset + batch.length,
|
||
chunks.length
|
||
)}/${chunks.length} 个分块`
|
||
})
|
||
}
|
||
effectiveSignal.throwIfAborted()
|
||
if (this.embeddingProvider !== provider) {
|
||
this.database.discardDocumentEmbeddingReplacement(
|
||
replacementId
|
||
)
|
||
replacementId = undefined
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'skipped',
|
||
message: '向量模型配置已变化'
|
||
})
|
||
return
|
||
}
|
||
this.database.finishDocumentEmbeddingReplacement(
|
||
replacementId,
|
||
document.id,
|
||
providerStorageKey,
|
||
provider.model
|
||
)
|
||
replacementId = undefined
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
message: `已向量化 ${chunks.length} 个分块`
|
||
})
|
||
} catch (error) {
|
||
if (replacementId) {
|
||
this.database.discardDocumentEmbeddingReplacement(
|
||
replacementId
|
||
)
|
||
}
|
||
if (effectiveSignal.aborted) {
|
||
this.database.cancelKnowledgeTask(task.id, '向量化已取消')
|
||
throw effectiveSignal.reason
|
||
}
|
||
const safeError = classifyEmbeddingError(error)
|
||
this.failKnowledgeTask(task.id, safeError)
|
||
try {
|
||
this.database.recordEmbeddingIndexError(
|
||
document.id,
|
||
providerStorageKey,
|
||
provider.model,
|
||
safeError.message
|
||
)
|
||
} catch {
|
||
// FTS indexing is authoritative; embedding diagnostics are best effort.
|
||
}
|
||
}
|
||
}
|
||
|
||
private async extractGraph(
|
||
library: KnowledgeBase,
|
||
document: Document,
|
||
operationTaskId?: string,
|
||
signal?: AbortSignal
|
||
): Promise<void> {
|
||
const task = operationTaskId
|
||
? this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
parentTaskId: operationTaskId,
|
||
sourceId: document.sourceId,
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
kind: 'graph'
|
||
})
|
||
: this.createKnowledgeTask({
|
||
libraryId: library.id,
|
||
sourceId: document.sourceId,
|
||
documentId: document.id,
|
||
documentName: document.title,
|
||
kind: 'graph'
|
||
})
|
||
if (!library.graphEnabled) {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'skipped',
|
||
message: '知识图谱未启用'
|
||
})
|
||
return
|
||
}
|
||
if (library.graphStrategy === 'ask') {
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'skipped',
|
||
message: '按需询问策略不自动抽取'
|
||
})
|
||
return
|
||
}
|
||
try {
|
||
signal?.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'running',
|
||
stage: 'graph',
|
||
progress: 10,
|
||
message: '正在准备图谱抽取'
|
||
})
|
||
const result = await this.extractGraphResult(
|
||
library,
|
||
document,
|
||
signal
|
||
)
|
||
signal?.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
progress: 85,
|
||
message: '正在保存实体和关系'
|
||
})
|
||
this.database.replaceEvidenceForDocument(document.id, () => {
|
||
this.storeExtractedGraph(library, document, result)
|
||
})
|
||
this.database.pruneUnreferencedGeneratedGraph(library.id)
|
||
signal?.throwIfAborted()
|
||
this.updateKnowledgeTask(task.id, {
|
||
status: 'succeeded',
|
||
message: `已抽取 ${result.entities.length} 个实体、${result.relations.length} 条关系`
|
||
})
|
||
} catch (error) {
|
||
if (signal?.aborted) {
|
||
this.database.cancelKnowledgeTask(task.id, '知识图谱处理已取消')
|
||
} else {
|
||
this.failKnowledgeTask(task.id, error)
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async extractGraphResult(
|
||
library: KnowledgeBase,
|
||
document: Document,
|
||
signal?: AbortSignal
|
||
): Promise<GraphExtractionResult> {
|
||
const chunks = this.database
|
||
.listChunks(document.id, 10_000)
|
||
.filter((chunk) => chunk.enabled && chunk.role !== 'parent')
|
||
return extractKnowledgeGraph(
|
||
chunks.map((chunk) => ({
|
||
id: chunk.id,
|
||
content: chunk.content
|
||
})),
|
||
{
|
||
strategy: library.graphStrategy,
|
||
ontology: library.ontologySettings,
|
||
extractStructured: this.extractStructured,
|
||
signal
|
||
}
|
||
)
|
||
}
|
||
|
||
private storeExtractedGraph(
|
||
library: KnowledgeBase,
|
||
document: Document,
|
||
result: GraphExtractionResult
|
||
): void {
|
||
const chunksById = new Map(
|
||
this.database
|
||
.listChunks(document.id)
|
||
.map((chunk) => [chunk.id, chunk])
|
||
)
|
||
const entityIds = new Map<string, string>()
|
||
const existingEntitiesByIdentity = new Map<string, GraphEntity>()
|
||
for (const entity of this.database.listEntitiesForIdentity(library.id)) {
|
||
for (const name of [entity.name, ...entity.aliases]) {
|
||
existingEntitiesByIdentity.set(
|
||
`${entity.type}\0${normalizeEntityAlias(name)}`,
|
||
entity
|
||
)
|
||
}
|
||
}
|
||
for (const entity of result.entities) {
|
||
const identity = `${entity.type}\0${normalizeEntityAlias(entity.name)}`
|
||
const existing = existingEntitiesByIdentity.get(identity)
|
||
const stored = existing
|
||
? existing.locked
|
||
? existing
|
||
: this.database.updateEntity(existing.id, {
|
||
aliases: [...new Set([...existing.aliases, ...entity.aliases])]
|
||
})
|
||
: this.database.createEntity({
|
||
knowledgeBaseId: library.id,
|
||
name: entity.name,
|
||
type: entity.type,
|
||
aliases: entity.aliases,
|
||
locked: false
|
||
})
|
||
for (const name of [stored.name, ...stored.aliases]) {
|
||
existingEntitiesByIdentity.set(
|
||
`${stored.type}\0${normalizeEntityAlias(name)}`,
|
||
stored
|
||
)
|
||
}
|
||
entityIds.set(entity.id, stored.id)
|
||
for (const evidence of entity.evidence) {
|
||
this.database.createEvidence({
|
||
knowledgeBaseId: library.id,
|
||
entityId: stored.id,
|
||
documentId: document.id,
|
||
chunkId: evidence.chunkId,
|
||
quote: evidence.quote,
|
||
location: chunksById.get(evidence.chunkId)?.location,
|
||
start: evidence.start,
|
||
end: evidence.end,
|
||
confidence: evidence.confidence,
|
||
source: evidence.source,
|
||
provenance: {
|
||
strategy: result.strategy,
|
||
ontologyVersion: library.ontologySettings.version
|
||
}
|
||
})
|
||
}
|
||
}
|
||
for (const relation of result.relations) {
|
||
const sourceEntityId = entityIds.get(relation.sourceId)
|
||
const targetEntityId = entityIds.get(relation.targetId)
|
||
if (!sourceEntityId || !targetEntityId) {
|
||
continue
|
||
}
|
||
const existing = this.database.findRelationByIdentity(
|
||
library.id,
|
||
sourceEntityId,
|
||
targetEntityId,
|
||
relation.type
|
||
)
|
||
const stored =
|
||
existing ??
|
||
this.database.createRelation({
|
||
knowledgeBaseId: library.id,
|
||
sourceEntityId,
|
||
targetEntityId,
|
||
type: relation.type,
|
||
locked: false
|
||
})
|
||
for (const evidence of relation.evidence) {
|
||
this.database.createEvidence({
|
||
knowledgeBaseId: library.id,
|
||
relationId: stored.id,
|
||
documentId: document.id,
|
||
chunkId: evidence.chunkId,
|
||
quote: evidence.quote,
|
||
location: chunksById.get(evidence.chunkId)?.location,
|
||
start: evidence.start,
|
||
end: evidence.end,
|
||
confidence: evidence.confidence,
|
||
source: evidence.source,
|
||
provenance: {
|
||
strategy: result.strategy,
|
||
ontologyVersion: library.ontologySettings.version
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
private async scanSource(rootPath: string): Promise<ScannedFile[]> {
|
||
const canonicalRoot = await realpath(rootPath)
|
||
const rootStat = await lstat(canonicalRoot)
|
||
const files: ScannedFile[] = []
|
||
let totalBytes = 0
|
||
const visit = async (path: string): Promise<void> => {
|
||
const entries = await readdir(path, { withFileTypes: true })
|
||
for (const entry of entries) {
|
||
if (entry.isSymbolicLink()) {
|
||
continue
|
||
}
|
||
const child = join(path, entry.name)
|
||
if (entry.isDirectory()) {
|
||
await visit(child)
|
||
} else if (
|
||
entry.isFile() &&
|
||
supportedExtensions.has(extname(entry.name).toLowerCase())
|
||
) {
|
||
const fileStat = await stat(child)
|
||
if (fileStat.size > maximumFileBytes) {
|
||
continue
|
||
}
|
||
totalBytes += fileStat.size
|
||
if (
|
||
files.length >= maximumFilesPerSource ||
|
||
totalBytes > maximumSourceBytes
|
||
) {
|
||
throw new Error('来源超过 2,000 个文件或 500MB 配额')
|
||
}
|
||
files.push({
|
||
absolutePath: child,
|
||
relativePath: relative(canonicalRoot, child) || basename(child),
|
||
size: fileStat.size
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if (rootStat.isFile()) {
|
||
if (!supportedExtensions.has(extname(canonicalRoot).toLowerCase())) {
|
||
throw new Error('不支持该文档类型')
|
||
}
|
||
files.push({
|
||
absolutePath: canonicalRoot,
|
||
relativePath: basename(canonicalRoot),
|
||
size: rootStat.size
|
||
})
|
||
} else if (rootStat.isDirectory()) {
|
||
await visit(canonicalRoot)
|
||
} else {
|
||
throw new Error('来源必须是文件或目录')
|
||
}
|
||
if (files.length === 0) {
|
||
throw new Error('来源中没有可索引的受支持文档')
|
||
}
|
||
return files
|
||
}
|
||
|
||
private async copySupportedSource(
|
||
sourcePath: string,
|
||
targetPath: string
|
||
): Promise<void> {
|
||
const files = await this.scanSource(sourcePath)
|
||
const sourceStat = await lstat(sourcePath)
|
||
if (sourceStat.isFile()) {
|
||
await mkdir(resolve(targetPath, '..'), { recursive: true })
|
||
await cp(files[0]?.absolutePath ?? sourcePath, targetPath, {
|
||
force: false,
|
||
errorOnExist: true
|
||
})
|
||
return
|
||
}
|
||
for (const file of files) {
|
||
const target = join(targetPath, file.relativePath)
|
||
if (!isPathInside(targetPath, target)) {
|
||
throw new Error('来源目录包含越界路径')
|
||
}
|
||
await mkdir(resolve(target, '..'), { recursive: true })
|
||
await cp(file.absolutePath, target, {
|
||
force: false,
|
||
errorOnExist: true
|
||
})
|
||
}
|
||
}
|
||
|
||
private startWatcher(source: KnowledgeSource): void {
|
||
this.stopWatcher(source.id)
|
||
try {
|
||
const watcher = watch(
|
||
source.location,
|
||
{
|
||
recursive: source.type === 'directory',
|
||
persistent: false
|
||
},
|
||
() => {
|
||
const current = this.syncTimers.get(source.id)
|
||
if (current) {
|
||
clearTimeout(current)
|
||
}
|
||
this.syncTimers.set(
|
||
source.id,
|
||
setTimeout(() => {
|
||
this.syncTimers.delete(source.id)
|
||
void this.syncSource(source.id).catch(() => undefined)
|
||
}, 800)
|
||
)
|
||
}
|
||
)
|
||
watcher.on('error', () => this.stopWatcher(source.id))
|
||
this.watchers.set(source.id, watcher)
|
||
} catch {
|
||
this.stopWatcher(source.id)
|
||
}
|
||
}
|
||
|
||
private stopWatcher(sourceId: string): void {
|
||
this.watchers.get(sourceId)?.close()
|
||
this.watchers.delete(sourceId)
|
||
const timer = this.syncTimers.get(sourceId)
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
this.syncTimers.delete(sourceId)
|
||
}
|
||
}
|
||
|
||
private createDocumentChunks(
|
||
parsed: ParsedDocument,
|
||
library: KnowledgeBase
|
||
): ReplaceChunkInput[] {
|
||
const chunks = chunkDocumentAdvanced(parsed, library.chunkingSettings)
|
||
const ids = chunks.map(() => randomUUID())
|
||
return chunks.map((chunk) => ({
|
||
id: ids[chunk.position],
|
||
ordinal: chunk.position,
|
||
content: chunk.content,
|
||
heading: chunk.heading,
|
||
location: chunk.locator,
|
||
role: chunk.role ?? 'standalone',
|
||
metadata: {
|
||
...(chunk.pageNumber === undefined
|
||
? {}
|
||
: { pageNumber: chunk.pageNumber }),
|
||
...(chunk.headingPath
|
||
? { headingPath: chunk.headingPath }
|
||
: {}),
|
||
...(chunk.blockKind ? { blockKind: chunk.blockKind } : {}),
|
||
...(library.chunkingSettings.contextualIndexingEnabled
|
||
? {
|
||
contextPrefix: buildChunkContextPrefix(parsed.title, chunk)
|
||
}
|
||
: {})
|
||
},
|
||
parentChunkId:
|
||
chunk.parentPosition === undefined
|
||
? undefined
|
||
: ids[chunk.parentPosition]
|
||
}))
|
||
}
|
||
|
||
private resolveImportLibrary(
|
||
knowledgeBaseId: string,
|
||
graphStrategy?: Exclude<GraphStrategy, 'ask'>
|
||
): KnowledgeBase {
|
||
const library = this.requireLibrary(knowledgeBaseId)
|
||
return graphStrategy
|
||
? { ...library, graphStrategy }
|
||
: library
|
||
}
|
||
|
||
private truncateContext(value: string, maximum: number): string {
|
||
if (value.length <= maximum) {
|
||
return value
|
||
}
|
||
if (maximum <= 0) {
|
||
return ''
|
||
}
|
||
const candidate = value.slice(0, maximum)
|
||
const minimumBoundary = Math.floor(maximum * 0.7)
|
||
const boundary = Math.max(
|
||
candidate.lastIndexOf('\n\n'),
|
||
candidate.lastIndexOf('\n'),
|
||
candidate.lastIndexOf('。'),
|
||
candidate.lastIndexOf('. ')
|
||
)
|
||
return boundary >= minimumBoundary
|
||
? candidate.slice(0, boundary + 1).trimEnd()
|
||
: candidate
|
||
}
|
||
|
||
private requireLibrary(id: string): KnowledgeBase {
|
||
const library = this.database.getKnowledgeBase(id)
|
||
if (!library) {
|
||
throw new Error('知识库不存在')
|
||
}
|
||
return library
|
||
}
|
||
|
||
private requireSource(id: string): KnowledgeSource {
|
||
const source = this.database.getSource(id)
|
||
if (source) {
|
||
return source
|
||
}
|
||
throw new Error('知识来源不存在')
|
||
}
|
||
}
|