fix(KB): add re-embed and reset & rebuild opts to fix broken embeddings (#886)
This commit is contained in:
parent
d28eb9be59
commit
4c211964e0
|
|
@ -98,6 +98,26 @@ export default class RagController {
|
|||
}
|
||||
}
|
||||
|
||||
public async reembedAll({ response }: HttpContext) {
|
||||
try {
|
||||
const result = await this.ragService.reembedAll()
|
||||
return response.status(200).json(result)
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[RagController] Error during re-embed all')
|
||||
return response.status(500).json({ error: 'Error during re-embed all' })
|
||||
}
|
||||
}
|
||||
|
||||
public async resetAndRebuild({ response }: HttpContext) {
|
||||
try {
|
||||
const result = await this.ragService.resetAndRebuild()
|
||||
return response.status(200).json(result)
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[RagController] Error during reset and rebuild')
|
||||
return response.status(500).json({ error: 'Error during reset and rebuild' })
|
||||
}
|
||||
}
|
||||
|
||||
public async health({ response }: HttpContext) {
|
||||
const result = await this.ragService.checkQdrantHealth()
|
||||
return response.status(200).json(result)
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export class EmbedFileJob {
|
|||
return await queue.getJob(jobId)
|
||||
}
|
||||
|
||||
static async dispatch(params: EmbedFileJobParams) {
|
||||
static async dispatch(params: EmbedFileJobParams, options?: { force?: boolean }) {
|
||||
const queueService = QueueService.getInstance()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
|
|
@ -255,7 +255,11 @@ export class EmbedFileJob {
|
|||
// them as independent queue entries that each process via handle().
|
||||
// Initial dispatches keep the deterministic jobId so re-triggering an install
|
||||
// (UI re-click, sync rescan, etc.) is still idempotent.
|
||||
// `force` skips the deterministic jobId for bulk callers (reembedAll /
|
||||
// resetAndRebuild) where historical entries in :completed would otherwise
|
||||
// silently swallow the new dispatch.
|
||||
const isContinuation = !!(params.batchOffset && params.batchOffset > 0)
|
||||
const force = !!options?.force
|
||||
const initialJobId = this.getJobId(params.filePath)
|
||||
|
||||
const jobOptions: Parameters<typeof queue.add>[2] = {
|
||||
|
|
@ -267,18 +271,20 @@ export class EmbedFileJob {
|
|||
removeOnComplete: { count: 50 }, // Keep last 50 completed jobs for history
|
||||
removeOnFail: { count: 20 }, // Keep last 20 failed jobs for debugging
|
||||
}
|
||||
if (!isContinuation) {
|
||||
if (!isContinuation && !force) {
|
||||
jobOptions.jobId = initialJobId
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await queue.add(this.key, params, jobOptions)
|
||||
|
||||
const continuationLabel = isContinuation
|
||||
const label = isContinuation
|
||||
? ` (continuation @ offset ${params.batchOffset})`
|
||||
: ''
|
||||
: force
|
||||
? ' (forced re-dispatch)'
|
||||
: ''
|
||||
logger.info(
|
||||
`[EmbedFileJob] Dispatched embedding job for file: ${params.fileName}${continuationLabel}`
|
||||
`[EmbedFileJob] Dispatched embedding job for file: ${params.fileName}${label}`
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -288,7 +294,12 @@ export class EmbedFileJob {
|
|||
message: `File queued for embedding: ${params.fileName}`,
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isContinuation && error.message && error.message.includes('job already exists')) {
|
||||
if (
|
||||
!isContinuation &&
|
||||
!force &&
|
||||
error.message &&
|
||||
error.message.includes('job already exists')
|
||||
) {
|
||||
const existing = await queue.getJob(initialJobId)
|
||||
logger.info(`[EmbedFileJob] Job already exists for file: ${params.fileName}`)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1182,12 +1182,114 @@ export class RagService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk kb_uploads and zim storage directories, returning the full path of
|
||||
* every embeddable file. Non-embeddable types (e.g. kiwix-library.xml) are
|
||||
* filtered out so they aren't dispatched only to fail with "Unsupported file
|
||||
* type" and retry on every sync.
|
||||
*/
|
||||
private async _discoverKbFiles(): Promise<string[]> {
|
||||
const KB_UPLOADS_PATH = join(process.cwd(), RagService.UPLOADS_STORAGE_PATH)
|
||||
const ZIM_PATH = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||
const filesInStorage: string[] = []
|
||||
|
||||
for (const [label, dirPath] of [
|
||||
[RagService.UPLOADS_STORAGE_PATH, KB_UPLOADS_PATH] as const,
|
||||
[ZIM_STORAGE_PATH, ZIM_PATH] as const,
|
||||
]) {
|
||||
try {
|
||||
const contents = await listDirectoryContentsRecursive(dirPath)
|
||||
contents.forEach((entry) => {
|
||||
if (entry.type === 'file') filesInStorage.push(entry.key)
|
||||
})
|
||||
logger.debug(`[RAG] Found ${contents.length} files in ${label}`)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.debug(`[RAG] ${label} directory does not exist, skipping`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filesInStorage.filter((f) => determineFileType(f) !== 'unknown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one EmbedFileJob per file path. Returns honest counts: `queuedCount`
|
||||
* is jobs newly enqueued, `dedupedCount` is jobs that hit BullMQ's per-file
|
||||
* jobId dedupe (an existing :completed/:waiting/etc. entry was returned
|
||||
* instead of a new enqueue), and `failedPaths` lists files whose dispatch
|
||||
* threw. Pass `force: true` for bulk callers that need to bypass dedupe
|
||||
* entirely. Per-file errors are logged but don't abort the batch — callers
|
||||
* must inspect `failedPaths` to surface partial failure to the operator.
|
||||
*/
|
||||
private async _dispatchEmbedJobsFor(
|
||||
filePaths: string[],
|
||||
options?: { force?: boolean }
|
||||
): Promise<{ queuedCount: number; dedupedCount: number; failedPaths: string[] }> {
|
||||
const { EmbedFileJob } = await import('#jobs/embed_file_job')
|
||||
let queuedCount = 0
|
||||
let dedupedCount = 0
|
||||
const failedPaths: string[] = []
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath
|
||||
const stats = await getFileStatsIfExists(filePath)
|
||||
const result = await EmbedFileJob.dispatch(
|
||||
{
|
||||
filePath,
|
||||
fileName,
|
||||
fileSize: stats?.size,
|
||||
},
|
||||
{ force: options?.force }
|
||||
)
|
||||
if (result.created) {
|
||||
queuedCount++
|
||||
} else {
|
||||
dedupedCount++
|
||||
}
|
||||
} catch (fileError) {
|
||||
failedPaths.push(filePath)
|
||||
logger.error(`[RAG] Error dispatching job for file ${filePath}:`, fileError)
|
||||
}
|
||||
}
|
||||
return { queuedCount, dedupedCount, failedPaths }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all Qdrant points whose `source` payload matches the given path.
|
||||
* Unlike deleteFileBySource(), this does NOT touch the file on disk — used
|
||||
* by reembedAll() where the file must remain so it can be re-ingested.
|
||||
*/
|
||||
private async _deletePointsBySource(source: string): Promise<void> {
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, {
|
||||
filter: { must: [{ key: 'source', match: { value: source } }] },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the file-embeddings queue has any in-flight work
|
||||
* (waiting, active, delayed, or paused). Bulk re-embed actions use this
|
||||
* to refuse mid-flight to avoid racing with deletes/dispatches already
|
||||
* in progress.
|
||||
*/
|
||||
private async _hasInflightEmbedJobs(): Promise<boolean> {
|
||||
const { EmbedFileJob } = await import('#jobs/embed_file_job')
|
||||
const { QueueService } = await import('#services/queue_service')
|
||||
const queue = QueueService.getInstance().getQueue(EmbedFileJob.queue)
|
||||
const counts = await queue.getJobCounts('waiting', 'active', 'delayed', 'paused')
|
||||
return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0) + (counts.paused || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the knowledge base storage directories and syncs with Qdrant.
|
||||
* Identifies files that exist in storage but haven't been embedded yet,
|
||||
* and dispatches EmbedFileJob for each missing file.
|
||||
*
|
||||
* @returns Object containing success status, message, and counts of scanned/queued files
|
||||
*/
|
||||
public async scanAndSyncStorage(): Promise<{
|
||||
success: boolean
|
||||
|
|
@ -1198,91 +1300,38 @@ export class RagService {
|
|||
try {
|
||||
logger.info('[RAG] Starting knowledge base sync scan')
|
||||
|
||||
const KB_UPLOADS_PATH = join(process.cwd(), RagService.UPLOADS_STORAGE_PATH)
|
||||
const ZIM_PATH = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||
|
||||
const filesInStorage: string[] = []
|
||||
|
||||
// Force resync of Nomad docs
|
||||
await this.discoverNomadDocs(true).catch((error) => {
|
||||
logger.error('[RAG] Error during Nomad docs discovery in sync process:', error)
|
||||
})
|
||||
|
||||
// Scan kb_uploads directory
|
||||
try {
|
||||
const kbContents = await listDirectoryContentsRecursive(KB_UPLOADS_PATH)
|
||||
kbContents.forEach((entry) => {
|
||||
if (entry.type === 'file') {
|
||||
filesInStorage.push(entry.key)
|
||||
}
|
||||
})
|
||||
logger.debug(`[RAG] Found ${kbContents.length} files in ${RagService.UPLOADS_STORAGE_PATH}`)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.debug(`[RAG] ${RagService.UPLOADS_STORAGE_PATH} directory does not exist, skipping`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const filesInStorage = await this._discoverKbFiles()
|
||||
logger.info(`[RAG] Found ${filesInStorage.length} embeddable files in storage`)
|
||||
|
||||
// Scan zim directory
|
||||
try {
|
||||
const zimContents = await listDirectoryContentsRecursive(ZIM_PATH)
|
||||
zimContents.forEach((entry) => {
|
||||
if (entry.type === 'file') {
|
||||
filesInStorage.push(entry.key)
|
||||
}
|
||||
})
|
||||
logger.debug(`[RAG] Found ${zimContents.length} files in ${ZIM_STORAGE_PATH}`)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.debug(`[RAG] ${ZIM_STORAGE_PATH} directory does not exist, skipping`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[RAG] Found ${filesInStorage.length} total files in storage directories`)
|
||||
|
||||
// Get all stored sources from Qdrant
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
// Collect every unique `source` already in Qdrant so we can skip files
|
||||
// that have already been embedded.
|
||||
const sourcesInQdrant = new Set<string>()
|
||||
let offset: string | number | null | Record<string, unknown> = null
|
||||
const batchSize = 100
|
||||
|
||||
// Scroll through all points to get sources
|
||||
do {
|
||||
const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {
|
||||
limit: batchSize,
|
||||
offset: offset,
|
||||
with_payload: ['source'], // Only fetch source field for efficiency
|
||||
limit: 100,
|
||||
offset,
|
||||
with_payload: ['source'],
|
||||
with_vector: false,
|
||||
})
|
||||
|
||||
scrollResult.points.forEach((point) => {
|
||||
const source = point.payload?.source
|
||||
if (source && typeof source === 'string') {
|
||||
sourcesInQdrant.add(source)
|
||||
}
|
||||
if (source && typeof source === 'string') sourcesInQdrant.add(source)
|
||||
})
|
||||
|
||||
offset = scrollResult.next_page_offset || null
|
||||
} while (offset !== null)
|
||||
|
||||
logger.info(`[RAG] Found ${sourcesInQdrant.size} unique sources in Qdrant`)
|
||||
|
||||
// Find files that are in storage, not already in Qdrant, and have an embeddable type.
|
||||
// Non-embeddable files (e.g. kiwix-library.xml in /storage/zim) would otherwise be
|
||||
// dispatched to EmbedFileJob, fail with "Unsupported file type", and retry on every sync.
|
||||
const filesToEmbed = filesInStorage.filter(
|
||||
(filePath) => !sourcesInQdrant.has(filePath) && determineFileType(filePath) !== 'unknown'
|
||||
)
|
||||
|
||||
logger.info(`[RAG] Found ${filesToEmbed.length} files that need embedding`)
|
||||
const filesToEmbed = filesInStorage.filter((f) => !sourcesInQdrant.has(f))
|
||||
logger.info(`[RAG] ${filesToEmbed.length} of ${filesInStorage.length} files need embedding`)
|
||||
|
||||
if (filesToEmbed.length === 0) {
|
||||
return {
|
||||
|
|
@ -1293,41 +1342,193 @@ export class RagService {
|
|||
}
|
||||
}
|
||||
|
||||
// Import EmbedFileJob dynamically to avoid circular dependencies
|
||||
const { EmbedFileJob } = await import('#jobs/embed_file_job')
|
||||
|
||||
// Dispatch jobs for files that need embedding
|
||||
let queuedCount = 0
|
||||
for (const filePath of filesToEmbed) {
|
||||
try {
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath
|
||||
const stats = await getFileStatsIfExists(filePath)
|
||||
|
||||
logger.info(`[RAG] Dispatching embed job for: ${fileName}`)
|
||||
await EmbedFileJob.dispatch({
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
fileSize: stats?.size,
|
||||
})
|
||||
queuedCount++
|
||||
logger.debug(`[RAG] Successfully dispatched job for ${fileName}`)
|
||||
} catch (fileError) {
|
||||
logger.error(`[RAG] Error dispatching job for file ${filePath}:`, fileError)
|
||||
}
|
||||
}
|
||||
|
||||
const { queuedCount, dedupedCount } = await this._dispatchEmbedJobsFor(filesToEmbed)
|
||||
const dedupeNote = dedupedCount > 0 ? ` (${dedupedCount} already queued)` : ''
|
||||
return {
|
||||
success: true,
|
||||
message: `Scanned ${filesInStorage.length} files, queued ${queuedCount} for embedding`,
|
||||
message: `Scanned ${filesInStorage.length} files, queued ${queuedCount} for embedding${dedupeNote}`,
|
||||
filesScanned: filesInStorage.length,
|
||||
filesQueued: queuedCount,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error scanning and syncing knowledge base:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Error scanning and syncing knowledge base',
|
||||
return { success: false, message: 'Error scanning and syncing knowledge base' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-embed every file on disk (per-file replace). For each discovered file:
|
||||
* delete its existing Qdrant points by `source` match, then dispatch a fresh
|
||||
* EmbedFileJob. Files are NOT removed from disk. Any orphan points (points
|
||||
* whose source file no longer exists) are intentionally preserved — use
|
||||
* resetAndRebuild() if a clean slate is required.
|
||||
*
|
||||
* Refuses to run if the embeddings queue already has in-flight work.
|
||||
*/
|
||||
public async reembedAll(): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
filesScanned?: number
|
||||
filesQueued?: number
|
||||
failedPaths?: string[]
|
||||
}> {
|
||||
try {
|
||||
if (await this._hasInflightEmbedJobs()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Embed jobs are already in progress. Wait for the queue to drain (or clean up failed jobs) before triggering a bulk re-embed.',
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[RAG] Starting full re-embed (per-file replace)')
|
||||
|
||||
await this.discoverNomadDocs(true).catch((error) => {
|
||||
logger.error('[RAG] Error re-running Nomad docs discovery during re-embed:', error)
|
||||
})
|
||||
|
||||
const filesInStorage = await this._discoverKbFiles()
|
||||
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
// Per-file: delete-then-dispatch. We tried dispatch-then-delete but that
|
||||
// opens a race where a fast worker can write new points before our
|
||||
// delete-by-source runs, wiping both. Instead we delete first, then
|
||||
// dispatch — and if dispatch fails, we surface the failed paths in the
|
||||
// response so the operator knows which files dropped out (rather than
|
||||
// silently leaving them unindexed). A subsequent sync rescan picks them
|
||||
// back up. Note: a delete-failure aborts the per-file pair (we don't
|
||||
// dispatch a job whose old points are still present, since they'd live
|
||||
// alongside the new vectors forever).
|
||||
const { EmbedFileJob } = await import('#jobs/embed_file_job')
|
||||
let queuedCount = 0
|
||||
const failedPaths: string[] = []
|
||||
for (const filePath of filesInStorage) {
|
||||
try {
|
||||
await this._deletePointsBySource(filePath)
|
||||
} catch (err) {
|
||||
logger.error(`[RAG] Failed to delete prior points for ${filePath}; skipping dispatch:`, err)
|
||||
failedPaths.push(filePath)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath
|
||||
const stats = await getFileStatsIfExists(filePath)
|
||||
const result = await EmbedFileJob.dispatch(
|
||||
{ filePath, fileName, fileSize: stats?.size },
|
||||
{ force: true }
|
||||
)
|
||||
if (result.created) queuedCount++
|
||||
} catch (fileError) {
|
||||
// Old points already deleted but the new job never made it onto the
|
||||
// queue. Logged + surfaced so an operator can rerun a sync.
|
||||
logger.error(`[RAG] Re-embed dispatch failed for ${filePath} after delete; file is now unindexed until next sync:`, fileError)
|
||||
failedPaths.push(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[RAG] Re-embed dispatched ${queuedCount}/${filesInStorage.length} files` +
|
||||
(failedPaths.length > 0 ? ` (${failedPaths.length} failed)` : '')
|
||||
)
|
||||
|
||||
const failureSuffix =
|
||||
failedPaths.length > 0
|
||||
? ` ${failedPaths.length} file${failedPaths.length === 1 ? '' : 's'} failed to dispatch and are temporarily unindexed — run a sync rescan to recover.`
|
||||
: ''
|
||||
|
||||
return {
|
||||
success: failedPaths.length === 0,
|
||||
message:
|
||||
`Re-embedding ${queuedCount} file${queuedCount === 1 ? '' : 's'}. Existing points were replaced.` +
|
||||
failureSuffix,
|
||||
filesScanned: filesInStorage.length,
|
||||
filesQueued: queuedCount,
|
||||
...(failedPaths.length > 0 ? { failedPaths } : {}),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error during re-embed:', error)
|
||||
return { success: false, message: 'Error during re-embed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive rebuild. Drops the entire Qdrant collection (wiping every
|
||||
* point including orphans), recreates it with the correct dimension, clears
|
||||
* the Nomad-docs discovery flag, then dispatches an EmbedFileJob for every
|
||||
* file currently on disk.
|
||||
*
|
||||
* Refuses to run if the embeddings queue already has in-flight work.
|
||||
*/
|
||||
public async resetAndRebuild(): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
filesScanned?: number
|
||||
filesQueued?: number
|
||||
failedPaths?: string[]
|
||||
}> {
|
||||
try {
|
||||
if (await this._hasInflightEmbedJobs()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Embed jobs are already in progress. Wait for the queue to drain (or clean up failed jobs) before triggering a reset.',
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[RAG] Starting destructive reset & rebuild')
|
||||
|
||||
await this._initializeQdrantClient()
|
||||
try {
|
||||
await this.qdrant!.deleteCollection(RagService.CONTENT_COLLECTION_NAME)
|
||||
logger.info(`[RAG] Dropped collection ${RagService.CONTENT_COLLECTION_NAME}`)
|
||||
} catch (err) {
|
||||
// Collection may not exist yet on a fresh install — log and continue.
|
||||
logger.warn(`[RAG] deleteCollection failed (may not exist): ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
// Force Nomad docs to be re-dispatched.
|
||||
await KVStore.setValue('rag.docsEmbedded', false)
|
||||
await this.discoverNomadDocs(true).catch((error) => {
|
||||
logger.error('[RAG] Error re-running Nomad docs discovery after reset:', error)
|
||||
})
|
||||
|
||||
const filesInStorage = await this._discoverKbFiles()
|
||||
const { queuedCount, failedPaths } = await this._dispatchEmbedJobsFor(filesInStorage, {
|
||||
force: true,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[RAG] Reset complete — dispatched ${queuedCount}/${filesInStorage.length} files` +
|
||||
(failedPaths.length > 0 ? ` (${failedPaths.length} failed)` : '')
|
||||
)
|
||||
|
||||
// Collection was already dropped, so dispatch failures here mean the
|
||||
// file is gone from Qdrant with no pending job to repopulate it. Surface
|
||||
// the count + paths so the operator can rerun a sync rescan to recover.
|
||||
const failureSuffix =
|
||||
failedPaths.length > 0
|
||||
? ` ${failedPaths.length} file${failedPaths.length === 1 ? '' : 's'} failed to dispatch and are temporarily unindexed — run a sync rescan to recover.`
|
||||
: ''
|
||||
|
||||
return {
|
||||
success: failedPaths.length === 0,
|
||||
message:
|
||||
`Collection wiped. Queued ${queuedCount} file${queuedCount === 1 ? '' : 's'} for a full rebuild.` +
|
||||
failureSuffix,
|
||||
filesScanned: filesInStorage.length,
|
||||
filesQueued: queuedCount,
|
||||
...(failedPaths.length > 0 ? { failedPaths } : {}),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error during reset & rebuild:', error)
|
||||
return { success: false, message: 'Error during reset & rebuild' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
|
||||
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
|
||||
const [resetTyped, setResetTyped] = useState('')
|
||||
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
|
||||
const { openModal, closeModal } = useModals()
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -105,6 +107,44 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
})
|
||||
|
||||
const reembedMutation = useMutation({
|
||||
mutationFn: () => api.reembedAllRAG(),
|
||||
onSuccess: (data) => {
|
||||
addNotification({
|
||||
type: data?.success ? 'success' : 'error',
|
||||
message: data?.message || 'Re-embed completed.',
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
|
||||
setBulkMode(null)
|
||||
setResetTyped('')
|
||||
},
|
||||
onError: () => {
|
||||
addNotification({ type: 'error', message: 'Failed to re-embed knowledge base.' })
|
||||
setBulkMode(null)
|
||||
},
|
||||
})
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => api.resetAndRebuildRAG(),
|
||||
onSuccess: (data) => {
|
||||
addNotification({
|
||||
type: data?.success ? 'success' : 'error',
|
||||
message: data?.message || 'Reset complete.',
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
|
||||
setBulkMode(null)
|
||||
setResetTyped('')
|
||||
},
|
||||
onError: () => {
|
||||
addNotification({ type: 'error', message: 'Failed to reset knowledge base.' })
|
||||
setBulkMode(null)
|
||||
},
|
||||
})
|
||||
|
||||
const bulkBusy = reembedMutation.isPending || resetMutation.isPending
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return
|
||||
setIsUploading(true)
|
||||
|
|
@ -286,18 +326,41 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
</div>
|
||||
|
||||
<div className="my-12">
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='flex items-center justify-between mb-6 gap-2 flex-wrap'>
|
||||
<StyledSectionHeader title="Stored Knowledge Base Files" className='!mb-0' />
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon='IconRefresh'
|
||||
onClick={handleConfirmSync}
|
||||
disabled={syncMutation.isPending || isUploading || qdrantOffline}
|
||||
loading={syncMutation.isPending || isUploading}
|
||||
>
|
||||
Sync Storage
|
||||
</StyledButton>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
icon='IconAlertTriangle'
|
||||
onClick={() => { setResetTyped(''); setBulkMode('reset') }}
|
||||
disabled={isUploading || qdrantOffline || bulkBusy}
|
||||
loading={resetMutation.isPending}
|
||||
>
|
||||
Reset & Rebuild
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon='IconRefreshAlert'
|
||||
onClick={() => setBulkMode('reembed')}
|
||||
disabled={isUploading || qdrantOffline || bulkBusy || storedFiles.length === 0}
|
||||
loading={reembedMutation.isPending}
|
||||
>
|
||||
Re-embed All
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon='IconRefresh'
|
||||
onClick={handleConfirmSync}
|
||||
disabled={syncMutation.isPending || isUploading || qdrantOffline || bulkBusy}
|
||||
loading={syncMutation.isPending || isUploading}
|
||||
>
|
||||
Sync Storage
|
||||
</StyledButton>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<StyledTable<{ source: string }>
|
||||
className="font-semibold"
|
||||
|
|
@ -360,6 +423,101 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bulkMode === 'reembed' && (
|
||||
<StyledModal
|
||||
title='Re-embed All Documents?'
|
||||
open={true}
|
||||
confirmText={reembedMutation.isPending ? 'Re-embedding…' : 'Re-embed All'}
|
||||
cancelText='Cancel'
|
||||
confirmVariant='primary'
|
||||
confirmLoading={reembedMutation.isPending}
|
||||
onConfirm={() => reembedMutation.mutate()}
|
||||
onCancel={() => setBulkMode(null)}
|
||||
>
|
||||
<div className='text-text-primary text-sm space-y-3 text-left'>
|
||||
<p>
|
||||
This will re-process every document currently in your knowledge base — about
|
||||
<strong> {storedFiles.length} file{storedFiles.length === 1 ? '' : 's'}</strong>.
|
||||
For each file, NOMAD will delete the existing embeddings from Qdrant and queue a fresh
|
||||
embedding job using the current chunking and embedding model.
|
||||
</p>
|
||||
<div className='rounded border border-border-subtle bg-surface-secondary p-3'>
|
||||
<p className='font-semibold mb-1'>What this is for</p>
|
||||
<p className='text-text-secondary'>
|
||||
Use this when the embedding model or chunking logic has changed, or when you suspect
|
||||
stored vectors are stale. Files on disk are <em>not</em> deleted, and any orphan
|
||||
points whose source file is no longer present will be preserved untouched (see
|
||||
<em> Reset & Rebuild </em>if you want a fully clean slate).
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded border border-amber-300 bg-amber-50 dark:bg-amber-950 dark:border-amber-800 p-3 text-amber-900 dark:text-amber-200'>
|
||||
<p className='font-semibold mb-1'>Heads up</p>
|
||||
<ul className='list-disc pl-5 space-y-1'>
|
||||
<li>Embedding {storedFiles.length} file{storedFiles.length === 1 ? '' : 's'} may take a long time, especially for large PDFs or ZIM archives.</li>
|
||||
<li>On systems without GPU acceleration, expect sustained high CPU usage for the duration.</li>
|
||||
<li>Knowledge Base search results may be incomplete until every file finishes re-embedding.</li>
|
||||
<li>If embed jobs are already in progress, this action will be refused — wait for the queue to drain first.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{bulkMode === 'reset' && (
|
||||
<StyledModal
|
||||
title='Reset & Rebuild Knowledge Base?'
|
||||
open={true}
|
||||
confirmText={resetMutation.isPending ? 'Resetting…' : 'Wipe & Rebuild'}
|
||||
cancelText='Cancel'
|
||||
confirmVariant='danger'
|
||||
confirmLoading={resetMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (resetTyped === 'RESET') resetMutation.mutate()
|
||||
}}
|
||||
onCancel={() => { setBulkMode(null); setResetTyped('') }}
|
||||
>
|
||||
<div className='text-text-primary text-sm space-y-3 text-left'>
|
||||
<p>
|
||||
This will <strong>permanently delete every point</strong> in the
|
||||
<code> nomad_knowledge_base </code>Qdrant collection and rebuild from the
|
||||
<strong> {storedFiles.length} file{storedFiles.length === 1 ? '' : 's'}</strong> currently
|
||||
on disk. The collection is dropped, recreated, and every file is re-queued for embedding.
|
||||
</p>
|
||||
<div className='rounded border border-border-subtle bg-surface-secondary p-3'>
|
||||
<p className='font-semibold mb-1'>How this differs from Re-embed All</p>
|
||||
<ul className='list-disc pl-5 space-y-1 text-text-secondary'>
|
||||
<li><strong>Re-embed All</strong> replaces vectors file-by-file. Any orphan points (vectors whose source file was deleted from disk at some point) are preserved.</li>
|
||||
<li><strong>Reset & Rebuild</strong> drops the entire collection. Orphan points are <strong>gone forever</strong>. Only files currently on disk will exist in Qdrant afterwards.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='rounded border border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-800 p-3 text-red-900 dark:text-red-200'>
|
||||
<p className='font-semibold mb-1'>This action is destructive and cannot be undone</p>
|
||||
<ul className='list-disc pl-5 space-y-1'>
|
||||
<li>Knowledge Base search will be empty until embedding finishes (potentially hours on CPU-only systems).</li>
|
||||
<li>For a few seconds during the reset, the Qdrant collection does not exist — any chat-with-RAG queries in that window may return a "collection not found" error. Avoid using chat until the rebuild has begun.</li>
|
||||
<li>If embed jobs are already in progress, this action will be refused — wait for the queue to drain first.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-semibold mb-1'>
|
||||
Type <code>RESET</code> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={resetTyped}
|
||||
onChange={(e) => setResetTyped(e.target.value)}
|
||||
placeholder='RESET'
|
||||
autoFocus
|
||||
className='w-full rounded border border-border-subtle bg-surface-primary px-3 py-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-red-500'
|
||||
/>
|
||||
{resetTyped.length > 0 && resetTyped !== 'RESET' && (
|
||||
<p className='text-xs text-red-600 mt-1'>Type RESET exactly (uppercase, no spaces) to enable the confirm button.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -811,6 +811,30 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async reembedAllRAG() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{
|
||||
success: boolean
|
||||
message: string
|
||||
filesScanned?: number
|
||||
filesQueued?: number
|
||||
}>('/rag/re-embed-all')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async resetAndRebuildRAG() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{
|
||||
success: boolean
|
||||
message: string
|
||||
filesScanned?: number
|
||||
filesQueued?: number
|
||||
}>('/rag/reset-and-rebuild')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
// Wikipedia selector methods
|
||||
|
||||
async getWikipediaState(): Promise<WikipediaState | undefined> {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ router
|
|||
router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs'])
|
||||
router.get('/job-status', [RagController, 'getJobStatus'])
|
||||
router.post('/sync', [RagController, 'scanAndSync'])
|
||||
router.post('/re-embed-all', [RagController, 'reembedAll'])
|
||||
router.post('/reset-and-rebuild', [RagController, 'resetAndRebuild'])
|
||||
router.get('/health', [RagController, 'health'])
|
||||
})
|
||||
.prefix('/api/rag')
|
||||
|
|
|
|||
Loading…
Reference in New Issue