Skip to content

Commit dfa896e

Browse files
committed
feat(RAG): allow deletion of files from KB
1 parent 99b96c3 commit dfa896e

File tree

7 files changed

+147
-12
lines changed

7 files changed

+147
-12
lines changed

admin/app/controllers/rag_controller.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { HttpContext } from '@adonisjs/core/http'
55
import app from '@adonisjs/core/services/app'
66
import { randomBytes } from 'node:crypto'
77
import { sanitizeFilename } from '../utils/fs.js'
8-
import { getJobStatusSchema } from '#validators/rag'
8+
import { deleteFileSchema, getJobStatusSchema } from '#validators/rag'
99

1010
@inject()
1111
export default class RagController {
@@ -65,6 +65,15 @@ export default class RagController {
6565
return response.status(200).json({ files })
6666
}
6767

68+
public async deleteFile({ request, response }: HttpContext) {
69+
const { source } = await request.validateUsing(deleteFileSchema)
70+
const result = await this.ragService.deleteFileBySource(source)
71+
if (!result.success) {
72+
return response.status(500).json({ error: result.message })
73+
}
74+
return response.status(200).json({ message: result.message })
75+
}
76+
6877
public async scanAndSync({ response }: HttpContext) {
6978
try {
7079
const syncResult = await this.ragService.scanAndSyncStorage()

admin/app/services/rag_service.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { OllamaService } from './ollama_service.js'
1212
import { SERVICE_NAMES } from '../../constants/service_names.js'
1313
import { removeStopwords } from 'stopword'
1414
import { randomUUID } from 'node:crypto'
15-
import { join } from 'node:path'
15+
import { join, resolve, sep } from 'node:path'
1616
import KVStore from '#models/kv_store'
1717
import { ZIMExtractionService } from './zim_extraction_service.js'
1818
import { ZIM_BATCH_SIZE } from '../../constants/zim_extraction.js'
@@ -853,7 +853,7 @@ export class RagService {
853853

854854
/**
855855
* Retrieve all unique source files that have been stored in the knowledge base.
856-
* @returns Array of unique source file identifiers
856+
* @returns Array of unique full source paths
857857
*/
858858
public async getStoredFiles(): Promise<string[]> {
859859
try {
@@ -886,19 +886,54 @@ export class RagService {
886886
offset = scrollResult.next_page_offset || null
887887
} while (offset !== null)
888888

889-
const sourcesArr = Array.from(sources)
890-
891-
// The source is a full path - only extract the filename for display
892-
return sourcesArr.map((src) => {
893-
const parts = src.split(/[/\\]/)
894-
return parts[parts.length - 1] // Return the last part as filename
895-
})
889+
return Array.from(sources)
896890
} catch (error) {
897891
logger.error('Error retrieving stored files:', error)
898892
return []
899893
}
900894
}
901895

896+
/**
897+
* Delete all Qdrant points associated with a given source path and remove
898+
* the corresponding file from disk if it lives under the uploads directory.
899+
* @param source - Full source path as stored in Qdrant payloads
900+
*/
901+
public async deleteFileBySource(source: string): Promise<{ success: boolean; message: string }> {
902+
try {
903+
await this._ensureCollection(
904+
RagService.CONTENT_COLLECTION_NAME,
905+
RagService.EMBEDDING_DIMENSION
906+
)
907+
908+
await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, {
909+
filter: {
910+
must: [{ key: 'source', match: { value: source } }],
911+
},
912+
})
913+
914+
logger.info(`[RAG] Deleted all points for source: ${source}`)
915+
916+
/** Delete the physical file only if it lives inside the uploads directory.
917+
* resolve() normalises path traversal sequences (e.g. "/../..") before the
918+
* check to prevent path traversal vulns
919+
* The trailing sep is to ensure a prefix like "kb_uploads_{something_incorrect}" can't slip through.
920+
*/
921+
const uploadsAbsPath = join(process.cwd(), RagService.UPLOADS_STORAGE_PATH)
922+
const resolvedSource = resolve(source)
923+
if (resolvedSource.startsWith(uploadsAbsPath + sep)) {
924+
await deleteFileIfExists(resolvedSource)
925+
logger.info(`[RAG] Deleted uploaded file from disk: ${resolvedSource}`)
926+
} else {
927+
logger.warn(`[RAG] File was removed from knowledge base but doesn't live in Nomad's uploads directory, so it can't be safely removed. Skipping deletion of physical file...`)
928+
}
929+
930+
return { success: true, message: 'File removed from knowledge base.' }
931+
} catch (error) {
932+
logger.error('[RAG] Error deleting file from knowledge base:', error)
933+
return { success: false, message: 'Error deleting file from knowledge base.' }
934+
}
935+
}
936+
902937
public async discoverNomadDocs(force?: boolean): Promise<{ success: boolean; message: string }> {
903938
try {
904939
const README_PATH = join(process.cwd(), 'README.md')

admin/app/validators/rag.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,9 @@ export const getJobStatusSchema = vine.compile(
55
filePath: vine.string(),
66
})
77
)
8+
9+
export const deleteFileSchema = vine.compile(
10+
vine.object({
11+
source: vine.string(),
12+
})
13+
)

admin/docs/release-notes.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Release Notes
22

3+
## Unreleased
4+
5+
### Features
6+
- **RAG**: Added support for viewing active embedding jobs in the processing queue and improved job progress tracking with more granular status updates
7+
- **RAG**: Added support for removing documents from the knowledge base (deletion from Qdrant and local storage)
8+
9+
### Bug Fixes
10+
- **Install**: Fixed broken url's in install script and updated to prompt for Apache 2.0 license acceptance
11+
- **Docs**: Updated legal notices to reflect Apache 2.0 license and added Qdrant attribution
12+
- **Dependencies**: Various minor dependency updates to close security vulnerabilities
13+
14+
### Improvements
15+
- **License**: Added Apache 2.0 license file to repository for clarity and legal compliance
16+
317
## Version 1.27.0 - March 4, 2026
418

519
### Features

admin/inertia/components/chat/KnowledgeBaseModal.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMutation, useQuery } from '@tanstack/react-query'
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
import { useRef, useState } from 'react'
33
import FileUploader from '~/components/file-uploader'
44
import StyledButton from '~/components/StyledButton'
@@ -16,11 +16,18 @@ interface KnowledgeBaseModalProps {
1616
onClose: () => void
1717
}
1818

19+
function sourceToDisplayName(source: string): string {
20+
const parts = source.split(/[/\\]/)
21+
return parts[parts.length - 1]
22+
}
23+
1924
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
2025
const { addNotification } = useNotifications()
2126
const [files, setFiles] = useState<File[]>([])
27+
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
2228
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
2329
const { openModal, closeModal } = useModals()
30+
const queryClient = useQueryClient()
2431

2532
const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({
2633
queryKey: ['storedFiles'],
@@ -48,6 +55,19 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
4855
},
4956
})
5057

58+
const deleteMutation = useMutation({
59+
mutationFn: (source: string) => api.deleteRAGFile(source),
60+
onSuccess: () => {
61+
addNotification({ type: 'success', message: 'File removed from knowledge base.' })
62+
setConfirmDeleteSource(null)
63+
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
64+
},
65+
onError: (error: any) => {
66+
addNotification({ type: 'error', message: error?.message || 'Failed to delete file.' })
67+
setConfirmDeleteSource(null)
68+
},
69+
})
70+
5171
const syncMutation = useMutation({
5272
mutationFn: () => api.syncRAGStorage(),
5373
onSuccess: (data) => {
@@ -212,7 +232,50 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
212232
accessor: 'source',
213233
title: 'File Name',
214234
render(record) {
215-
return <span className="text-gray-700">{record.source}</span>
235+
return <span className="text-gray-700">{sourceToDisplayName(record.source)}</span>
236+
},
237+
},
238+
{
239+
accessor: 'source',
240+
title: '',
241+
render(record) {
242+
const isConfirming = confirmDeleteSource === record.source
243+
const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source
244+
if (isConfirming) {
245+
return (
246+
<div className="flex items-center gap-2 justify-end">
247+
<span className="text-sm text-gray-600">Remove from knowledge base?</span>
248+
<StyledButton
249+
variant='danger'
250+
size='sm'
251+
onClick={() => deleteMutation.mutate(record.source)}
252+
disabled={isDeleting}
253+
>
254+
{isDeleting ? 'Deleting…' : 'Confirm'}
255+
</StyledButton>
256+
<StyledButton
257+
variant='ghost'
258+
size='sm'
259+
onClick={() => setConfirmDeleteSource(null)}
260+
disabled={isDeleting}
261+
>
262+
Cancel
263+
</StyledButton>
264+
</div>
265+
)
266+
}
267+
return (
268+
<div className="flex justify-end">
269+
<StyledButton
270+
variant="danger"
271+
size="sm"
272+
icon="IconTrash"
273+
onClick={() => setConfirmDeleteSource(record.source)}
274+
disabled={deleteMutation.isPending}
275+
loading={deleteMutation.isPending && confirmDeleteSource === record.source}
276+
>Delete</StyledButton>
277+
</div>
278+
)
216279
},
217280
},
218281
]}

admin/inertia/lib/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,13 @@ class API {
379379
})()
380380
}
381381

382+
async deleteRAGFile(source: string) {
383+
return catchInternal(async () => {
384+
const response = await this.client.delete<{ message: string }>('/rag/files', { data: { source } })
385+
return response.data
386+
})()
387+
}
388+
382389
async getSystemInfo() {
383390
return catchInternal(async () => {
384391
const response = await this.client.get<SystemInformationResponse>('/system/info')

admin/start/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ router
126126
.group(() => {
127127
router.post('/upload', [RagController, 'upload'])
128128
router.get('/files', [RagController, 'getStoredFiles'])
129+
router.delete('/files', [RagController, 'deleteFile'])
129130
router.get('/active-jobs', [RagController, 'getActiveJobs'])
130131
router.get('/job-status', [RagController, 'getJobStatus'])
131132
router.post('/sync', [RagController, 'scanAndSync'])

0 commit comments

Comments
 (0)