feat[Go]: implement api /api/v1/datasets/<dataset_id>/chunks POST (#16067)

### What problem does this PR solve?

As title

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Haruko386
2026-06-22 18:14:01 +08:00
committed by GitHub
parent c6b3618f9a
commit b777e50291
7 changed files with 1259 additions and 8 deletions

View File

@@ -33,6 +33,14 @@ func (dao *TaskDAO) Create(task *entity.Task) error {
return DB.Create(task).Error
}
// CreateMany creates multiple tasks in one batch.
func (dao *TaskDAO) CreateMany(tasks []*entity.Task) error {
if len(tasks) == 0 {
return nil
}
return DB.Create(&tasks).Error
}
// GetByID gets task by ID
func (dao *TaskDAO) GetByID(id string) (*entity.Task, error) {
var task entity.Task
@@ -61,7 +69,7 @@ func (dao *TaskDAO) DeleteByTenantID(tenantID string) (int64, error) {
// GetByDocID gets all tasks by document ID
func (dao *TaskDAO) GetByDocID(docID string) ([]*entity.Task, error) {
var tasks []*entity.Task
err := DB.Where("doc_id = ?", docID).Find(&tasks).Error
err := DB.Where("doc_id = ?", docID).Order("from_page ASC, create_time ASC").Find(&tasks).Error
return tasks, err
}

View File

@@ -18,8 +18,8 @@ package handler
import (
"encoding/json"
"net/http"
"strings"
"ragflow/internal/common"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -34,6 +34,7 @@ type chunkService interface {
List(req *service.ListChunksRequest, userID string) (*service.ListChunksResponse, error)
UpdateChunk(req *service.UpdateChunkRequest, userID string) error
RemoveChunks(req *service.RemoveChunksRequest, userID string) (int64, error)
Parse(userID, datasetID string, req *service.ParseFileRequest) (map[string]interface{}, common.ErrorCode, error)
}
// ChunkHandler chunk handler
@@ -99,7 +100,7 @@ func (h *ChunkHandler) RetrievalTest(c *gin.Context) {
// an empty result for blank questions rather than an error.
if strings.TrimSpace(req.Question) == "" {
c.JSON(http.StatusOK, gin.H{
"code": int(common.CodeSuccess),
"code": int(common.CodeSuccess),
"data": &service.RetrievalTestResponse{
Chunks: []map[string]interface{}{},
DocAggs: []map[string]interface{}{},
@@ -203,6 +204,59 @@ func (h *ChunkHandler) Get(c *gin.Context) {
})
}
// Parse reparse the datasets' files
func (h *ChunkHandler) Parse(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
userID := strings.TrimSpace(user.ID)
if userID == "" {
c.JSON(http.StatusOK, gin.H{
"code": common.CodeArgumentError,
"data": nil,
"message": "user_id is required",
})
return
}
datasetId := strings.TrimSpace(c.Param("dataset_id"))
if datasetId == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": common.CodeBadRequest,
"message": "dataset_id is required",
})
return
}
var req service.ParseFileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": common.CodeArgumentError,
"data": nil,
"message": err.Error(),
})
return
}
data, code, err := h.chunkService.Parse(userID, datasetId, &req)
if code != common.CodeSuccess {
c.JSON(http.StatusOK, gin.H{
"code": code,
"data": data,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"data": data,
"message": "success",
})
}
// List retrieves chunks for a document.
// @Summary List Chunks
// @Description Retrieve paginated chunks for a document with optional filtering.

View File

@@ -42,6 +42,9 @@ func (m *mockChunkSvc) UpdateChunk(*service.UpdateChunkRequest, string) error {
func (m *mockChunkSvc) RemoveChunks(*service.RemoveChunksRequest, string) (int64, error) {
panic("not implemented")
}
func (m *mockChunkSvc) Parse(string, string, *service.ParseFileRequest) (map[string]interface{}, common.ErrorCode, error) {
panic("not implemented")
}
func setupChunkRetrievalTest(userID string) (*gin.Engine, *mockChunkSvc) {
mock := &mockChunkSvc{}

View File

@@ -290,6 +290,7 @@ func (r *Router) Setup(engine *gin.Engine) {
// Dataset document chunk
datasets.GET("/:dataset_id/documents/:document_id/chunks/:chunk_id", r.chunkHandler.Get)
datasets.POST("/:dataset_id/chunks", r.chunkHandler.Parse)
datasets.POST("/:dataset_id/documents/parse", r.documentHandler.StartIngestionTask)
datasets.GET("/ingestion/tasks", r.documentHandler.ListIngestionTasks)
datasets.PUT("/ingestion/tasks", r.documentHandler.StopIngestionTasks)

View File

@@ -17,26 +17,45 @@
package chunk
import (
"archive/zip"
"bytes"
"context"
"encoding/csv"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"math/rand"
"path/filepath"
"ragflow/internal/common"
"ragflow/internal/entity"
"ragflow/internal/entity/models"
"ragflow/internal/server"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/cespare/xxhash/v2"
"go.uber.org/zap"
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/engine/redis"
"ragflow/internal/engine/types"
"ragflow/internal/service"
"ragflow/internal/service/nlp"
"ragflow/internal/storage"
"ragflow/internal/tokenizer"
"ragflow/internal/utility"
)
const (
maximumPageNumber = 100000
maximumTaskPageNumber = maximumPageNumber * 1000
)
// ChunkService chunk service
type ChunkService struct {
docEngine engine.DocEngine
@@ -46,6 +65,14 @@ type ChunkService struct {
userTenantDAO *dao.UserTenantDAO
documentDAO *dao.DocumentDAO
searchService *service.SearchService
accessibleFunc func(string, string) bool
getKnowledgebaseByIDFunc func(string) (*entity.Knowledgebase, error)
getDocumentsByIDsFunc func([]string) ([]*entity.Document, error)
getDocumentStorageAddressFunc func(*entity.Document) (string, string, error)
queueParseTasksFunc func(*entity.Document, string, string, int64) error
beginParseDocumentFunc func(string) error
deleteTasksByDocIDsFunc func([]string) (int64, error)
}
// NewChunkService creates chunk service
@@ -453,7 +480,6 @@ func hydrateChunkVectors(ctx context.Context, engine engine.DocEngine, chunks []
}
}
// Get retrieves a chunk by ID
func (s *ChunkService) Get(req *service.GetChunkRequest, userID string) (*service.GetChunkResponse, error) {
if s.docEngine == nil {
@@ -545,6 +571,584 @@ func (s *ChunkService) Get(req *service.GetChunkRequest, userID string) (*servic
return &service.GetChunkResponse{Chunk: chunk}, nil
}
func checkDuplicateIDs(documentIDs []string, idTypes string) ([]string, []string) {
idCount := make(map[string]int, len(documentIDs))
duplicateMessages := make([]string, 0)
uniqueDocIDs := make([]string, 0, len(documentIDs))
for _, id := range documentIDs {
idCount[id]++
}
for id, count := range idCount {
if count > 1 {
duplicateMessages = append(duplicateMessages, fmt.Sprintf("Duplicate %s ids: %s ", idTypes, id))
}
uniqueDocIDs = append(uniqueDocIDs, id)
}
return uniqueDocIDs, duplicateMessages
}
func (s *ChunkService) queueParseTasks(doc *entity.Document, bucket, objectName string, priority int64) error {
if s.queueParseTasksFunc != nil {
return s.queueParseTasksFunc(doc, bucket, objectName, priority)
}
tasks, err := s.buildParseTasks(doc, bucket, objectName, priority)
if err != nil {
return err
}
if len(tasks) == 0 {
return nil
}
if err := dao.NewTaskDAO().CreateMany(tasks); err != nil {
return err
}
queueName := s.parseQueueName(doc, priority)
for _, task := range tasks {
if task.Progress >= 1 {
continue
}
message := parseTaskMessage(task)
if ok := redis.Get().QueueProduct(queueName, message); !ok {
if _, err := dao.NewTaskDAO().DeleteByDocIDs([]string{doc.ID}); err != nil {
common.Warn("Failed to clean parse tasks after Redis enqueue failure",
zap.String("docID", doc.ID),
zap.Error(err))
}
return fmt.Errorf("Can't access Redis. Please check the Redis' status.")
}
}
return nil
}
func (s *ChunkService) buildParseTasks(doc *entity.Document, bucket, objectName string, priority int64) ([]*entity.Task, error) {
now := time.Now()
ranges, err := s.parseTaskRanges(doc, bucket, objectName)
if err != nil {
return nil, err
}
tasks := make([]*entity.Task, 0, len(ranges))
for _, pageRange := range ranges {
taskID := common.GenerateUUID()
progressMsg := ""
digest := s.parseTaskDigest(doc, pageRange.from, pageRange.to)
chunkIDs := ""
tasks = append(tasks, &entity.Task{
ID: taskID,
DocID: doc.ID,
FromPage: pageRange.from,
ToPage: pageRange.to,
TaskType: "",
Priority: priority,
BeginAt: &now,
Progress: 0,
ProgressMsg: &progressMsg,
Digest: &digest,
ChunkIDs: &chunkIDs,
})
}
return tasks, nil
}
type parsePageRange struct {
from int64
to int64
}
func (s *ChunkService) parseTaskRanges(doc *entity.Document, bucket, objectName string) ([]parsePageRange, error) {
if doc.Type == "pdf" {
return s.pdfParseTaskRanges(doc, bucket, objectName)
}
if doc.ParserID == string(entity.ParserTypeTable) {
return s.tableParseTaskRanges(doc, bucket, objectName)
}
return []parsePageRange{{from: 0, to: maximumTaskPageNumber}}, nil
}
func (s *ChunkService) pdfParseTaskRanges(doc *entity.Document, bucket, objectName string) ([]parsePageRange, error) {
binary, err := s.getStorageBinary(bucket, objectName)
if err != nil {
return nil, err
}
pages := estimatePDFPageCount(binary)
pageSize := int64(parserConfigInt(doc.ParserConfig, "task_page_size", 12))
if doc.ParserID == string(entity.ParserTypePaper) {
pageSize = int64(parserConfigInt(doc.ParserConfig, "task_page_size", 22))
}
if doc.ParserID == string(entity.ParserTypeOne) ||
doc.ParserID == string(entity.ParserTypeKG) ||
parserConfigString(doc.ParserConfig, "layout_recognize", "DeepDOC") != "DeepDOC" ||
parserConfigBool(doc.ParserConfig, "toc_extraction", false) {
pageSize = maximumTaskPageNumber
}
if pageSize <= 0 {
pageSize = 12
}
pageRanges := parserConfigPageRanges(doc.ParserConfig)
ranges := make([]parsePageRange, 0)
for _, configuredRange := range pageRanges {
start := configuredRange.from - 1
if start < 0 {
start = 0
}
end := configuredRange.to - 1
if pages >= 0 && end > pages {
end = pages
}
for page := start; page < end; page += pageSize {
to := page + pageSize
if to > end {
to = end
}
ranges = append(ranges, parsePageRange{from: page, to: to})
}
}
if len(ranges) == 0 {
ranges = append(ranges, parsePageRange{from: 0, to: maximumTaskPageNumber})
}
return ranges, nil
}
func (s *ChunkService) tableParseTaskRanges(doc *entity.Document, bucket, objectName string) ([]parsePageRange, error) {
binary, err := s.getStorageBinary(bucket, objectName)
if err != nil {
return nil, err
}
rows := estimateTableRowCount(docName(doc), binary)
if rows <= 0 {
return []parsePageRange{{from: 0, to: maximumTaskPageNumber}}, nil
}
ranges := make([]parsePageRange, 0, (rows+2999)/3000)
for row := int64(0); row < int64(rows); row += 3000 {
to := row + 3000
if to > int64(rows) {
to = int64(rows)
}
ranges = append(ranges, parsePageRange{from: row, to: to})
}
return ranges, nil
}
func (s *ChunkService) getStorageBinary(bucket, objectName string) ([]byte, error) {
storageImpl := storage.GetStorageFactory().GetStorage()
if storageImpl == nil {
return nil, fmt.Errorf("storage not initialized")
}
return storageImpl.Get(bucket, objectName)
}
func (s *ChunkService) beginParseDocument(docID string) error {
if s.beginParseDocumentFunc != nil {
return s.beginParseDocumentFunc(docID)
}
now := time.Now()
return dao.GetDB().Model(&entity.Document{}).Where("id = ?", docID).Updates(map[string]interface{}{
"progress_msg": "Task is queued...",
"process_begin_at": now,
"progress": rand.Float64() * 0.01,
"run": string(entity.TaskStatusRunning),
"chunk_num": 0,
"token_num": 0,
}).Error
}
func (s *ChunkService) getDocumentStorageAddress(doc *entity.Document) (string, string, error) {
if s.getDocumentStorageAddressFunc != nil {
return s.getDocumentStorageAddressFunc(doc)
}
return service.NewDocumentService().GetDocumentStorageAddress(doc)
}
func (s *ChunkService) deleteTasksByDocIDs(docIDs []string) (int64, error) {
if s.deleteTasksByDocIDsFunc != nil {
return s.deleteTasksByDocIDsFunc(docIDs)
}
return dao.NewTaskDAO().DeleteByDocIDs(docIDs)
}
func (s *ChunkService) accessible(datasetID, userID string) bool {
if s.accessibleFunc != nil {
return s.accessibleFunc(datasetID, userID)
}
return s.kbDAO.Accessible(datasetID, userID)
}
func (s *ChunkService) getKnowledgebaseByID(datasetID string) (*entity.Knowledgebase, error) {
if s.getKnowledgebaseByIDFunc != nil {
return s.getKnowledgebaseByIDFunc(datasetID)
}
return s.kbDAO.GetByID(datasetID)
}
func (s *ChunkService) getDocumentsByIDs(docIDs []string) ([]*entity.Document, error) {
if s.getDocumentsByIDsFunc != nil {
return s.getDocumentsByIDsFunc(docIDs)
}
return s.documentDAO.GetByIDs(docIDs)
}
func (s *ChunkService) parseQueueName(doc *entity.Document, priority int64) string {
suffix := "common"
if doc.ParserID == string(entity.ParserTypeResume) {
suffix = "resume"
}
return fmt.Sprintf("te.%d.%s", priority, suffix)
}
func (s *ChunkService) parseTaskDigest(doc *entity.Document, fromPage, toPage int64) string {
hasher := xxhash.New()
config := chunkingConfigForDigest(doc)
keys := make([]string, 0, len(config))
for key := range config {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
hasher.WriteString(stableString(config[key]))
}
hasher.WriteString(doc.ID)
hasher.WriteString(strconv.FormatInt(fromPage, 10))
hasher.WriteString(strconv.FormatInt(toPage, 10))
return fmt.Sprintf("%x", hasher.Sum64())
}
func parseTaskMessage(task *entity.Task) map[string]interface{} {
beginAt := ""
if task.BeginAt != nil {
beginAt = task.BeginAt.Format("2006-01-02 15:04:05")
}
digest := ""
if task.Digest != nil {
digest = *task.Digest
}
return map[string]interface{}{
"id": task.ID,
"doc_id": task.DocID,
"from_page": task.FromPage,
"to_page": task.ToPage,
"progress": task.Progress,
"priority": task.Priority,
"begin_at": beginAt,
"digest": digest,
}
}
func chunkingConfigForDigest(doc *entity.Document) map[string]interface{} {
return map[string]interface{}{
"doc_id": doc.ID,
"kb_id": doc.KbID,
"parser_id": doc.ParserID,
"parser_config": copyParserConfigForDigest(doc.ParserConfig),
}
}
func copyParserConfigForDigest(config map[string]interface{}) map[string]interface{} {
copied := make(map[string]interface{}, len(config))
for key, value := range config {
if key == "raptor" || key == "graphrag" {
continue
}
copied[key] = value
}
return copied
}
func stableString(value interface{}) string {
binary, err := json.Marshal(value)
if err != nil {
return fmt.Sprint(value)
}
return string(binary)
}
func parserConfigInt(config map[string]interface{}, key string, fallback int) int {
value, ok := config[key]
if !ok || value == nil {
return fallback
}
switch typedValue := value.(type) {
case int:
return typedValue
case int64:
return int(typedValue)
case float64:
return int(typedValue)
case json.Number:
if intValue, err := typedValue.Int64(); err == nil {
return int(intValue)
}
case string:
if intValue, err := strconv.Atoi(strings.TrimSpace(typedValue)); err == nil {
return intValue
}
}
return fallback
}
func parserConfigString(config map[string]interface{}, key, fallback string) string {
value, ok := config[key]
if !ok || value == nil {
return fallback
}
if stringValue, ok := value.(string); ok {
return stringValue
}
return fmt.Sprint(value)
}
func parserConfigBool(config map[string]interface{}, key string, fallback bool) bool {
value, ok := config[key]
if !ok || value == nil {
return fallback
}
switch typedValue := value.(type) {
case bool:
return typedValue
case string:
switch strings.ToLower(strings.TrimSpace(typedValue)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
}
}
return fallback
}
func parserConfigPageRanges(config map[string]interface{}) []parsePageRange {
defaultRanges := []parsePageRange{{from: 1, to: maximumPageNumber}}
raw, ok := config["pages"]
if !ok || raw == nil {
return defaultRanges
}
rawRanges, ok := raw.([]interface{})
if !ok || len(rawRanges) == 0 {
return defaultRanges
}
ranges := make([]parsePageRange, 0, len(rawRanges))
for _, rawRange := range rawRanges {
rangeValues, ok := rawRange.([]interface{})
if !ok || len(rangeValues) < 2 {
continue
}
from, okFrom := toInt64(rangeValues[0])
to, okTo := toInt64(rangeValues[1])
if okFrom && okTo && to > from {
ranges = append(ranges, parsePageRange{from: from, to: to})
}
}
if len(ranges) == 0 {
return defaultRanges
}
return ranges
}
func toInt64(value interface{}) (int64, bool) {
switch typedValue := value.(type) {
case int:
return int64(typedValue), true
case int64:
return typedValue, true
case float64:
return int64(typedValue), true
case json.Number:
intValue, err := typedValue.Int64()
return intValue, err == nil
case string:
intValue, err := strconv.ParseInt(strings.TrimSpace(typedValue), 10, 64)
return intValue, err == nil
default:
return 0, false
}
}
var pdfPagePattern = regexp.MustCompile(`/Type\s*/Page\b`)
func estimatePDFPageCount(binary []byte) int64 {
if len(binary) == 0 {
return 0
}
return int64(len(pdfPagePattern.FindAll(binary, -1)))
}
func estimateTableRowCount(name string, binary []byte) int {
switch strings.ToLower(filepath.Ext(name)) {
case ".xlsx":
if rows, err := countXLSXRows(binary); err == nil {
return rows
}
case ".csv", ".tsv", ".txt":
return countDelimitedRows(name, binary)
}
return 0
}
func countDelimitedRows(name string, binary []byte) int {
reader := csv.NewReader(bytes.NewReader(binary))
reader.FieldsPerRecord = -1
reader.ReuseRecord = true
if strings.EqualFold(filepath.Ext(name), ".tsv") {
reader.Comma = '\t'
}
rows := 0
for {
_, err := reader.Read()
if err == nil {
rows++
continue
}
if err == io.EOF {
break
}
rows += bytes.Count(binary, []byte{'\n'})
if len(binary) > 0 && binary[len(binary)-1] != '\n' {
rows++
}
break
}
return rows
}
func countXLSXRows(binary []byte) (int, error) {
zipReader, err := zip.NewReader(bytes.NewReader(binary), int64(len(binary)))
if err != nil {
return 0, err
}
maxRows := 0
for _, file := range zipReader.File {
if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(file.Name, ".xml") {
continue
}
rows, err := countWorksheetRows(file)
if err != nil {
return 0, err
}
if rows > maxRows {
maxRows = rows
}
}
return maxRows, nil
}
func countWorksheetRows(file *zip.File) (int, error) {
reader, err := file.Open()
if err != nil {
return 0, err
}
defer reader.Close()
decoder := xml.NewDecoder(reader)
rows := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return 0, err
}
start, ok := token.(xml.StartElement)
if ok && start.Name.Local == "row" {
rows++
}
}
return rows, nil
}
func docName(doc *entity.Document) string {
if doc.Name == nil {
return ""
}
return *doc.Name
}
func (s *ChunkService) Parse(userID, datasetID string, req *service.ParseFileRequest) (map[string]interface{}, common.ErrorCode, error) {
if !s.accessible(datasetID, userID) {
return nil, common.CodeOperatingError, fmt.Errorf("You don't own the dataset %s.", datasetID)
}
if req == nil || len(req.DocumentIDs) == 0 {
return nil, common.CodeDataError, fmt.Errorf("`document_ids` is required")
}
kb, err := s.getKnowledgebaseByID(datasetID)
if err != nil || kb == nil {
return nil, common.CodeDataError, fmt.Errorf("dataset not found")
}
docIDs, duplicateMessages := checkDuplicateIDs(req.DocumentIDs, "document")
notFound := make([]string, 0)
docs, err := s.getDocumentsByIDs(docIDs)
if err != nil {
return nil, common.CodeServerError, err
}
docByID := make(map[string]*entity.Document, len(docs))
for _, doc := range docs {
docByID[doc.ID] = doc
}
for _, docID := range docIDs {
doc := docByID[docID]
if doc == nil || doc.KbID != datasetID {
notFound = append(notFound, docID)
}
}
if len(notFound) > 0 {
return nil, common.CodeDataError, fmt.Errorf("Documents not found: %v", notFound)
}
for _, docID := range docIDs {
doc := docByID[docID]
if doc.Run != nil && *doc.Run == string(entity.TaskStatusRunning) {
return nil, common.CodeDataError, fmt.Errorf("Can't parse document that is currently being processed")
}
}
successCount := 0
for _, docID := range docIDs {
doc := docByID[docID]
if s.docEngine != nil {
indexName := fmt.Sprintf("ragflow_%s", kb.TenantID)
if _, err := s.docEngine.DeleteChunks(context.Background(), map[string]interface{}{"doc_id": docID}, indexName, datasetID); err != nil {
return nil, common.CodeServerError, err
}
}
if _, err := s.deleteTasksByDocIDs([]string{docID}); err != nil {
return nil, common.CodeServerError, err
}
bucket, objectName, err := s.getDocumentStorageAddress(doc)
if err != nil {
return nil, common.CodeServerError, err
}
if err := s.queueParseTasks(doc, bucket, objectName, 0); err != nil {
return nil, common.CodeServerError, err
}
if err := s.beginParseDocument(doc.ID); err != nil {
if _, delErr := s.deleteTasksByDocIDs([]string{doc.ID}); delErr != nil {
common.Warn("Failed to clean parse tasks after document state update failure",
zap.String("docID", doc.ID),
zap.Error(delErr))
}
return nil, common.CodeServerError, err
}
successCount++
}
if len(duplicateMessages) > 0 {
if successCount > 0 {
return map[string]interface{}{
"success_count": successCount,
"errors": duplicateMessages,
}, common.CodeSuccess, fmt.Errorf("Partially parsed %d documents with %d errors", successCount, len(duplicateMessages))
}
return nil, common.CodeDataError, fmt.Errorf("%s", strings.Join(duplicateMessages, ";"))
}
return nil, common.CodeSuccess, nil
}
// List retrieves chunks for a document
func (s *ChunkService) List(req *service.ListChunksRequest, userID string) (*service.ListChunksResponse, error) {
if s.docEngine == nil {
@@ -931,4 +1535,3 @@ func (s *ChunkService) RemoveChunks(req *service.RemoveChunksRequest, userID str
return deletedCount, nil
}

View File

@@ -2,9 +2,18 @@ package chunk
import (
"context"
"errors"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/engine/types"
"ragflow/internal/entity"
"ragflow/internal/service"
"reflect"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestIsZeroVector(t *testing.T) {
@@ -60,3 +69,573 @@ func TestHydrateChunkVectors_NoDim(t *testing.T) {
hydrateChunkVectors(context.Background(), nil, chunks, []string{"kb1"}, []string{"t1"})
// Empty vectors have dim=0 → early return. No crash.
}
func TestParsePrevalidatesDocumentsBeforeMutating(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID := "user-1"
datasetID := "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestKB(t, "kb-2", userID)
insertChunkTestDoc(t, "doc-1", datasetID)
insertChunkTestDoc(t, "doc-2", "kb-2")
insertChunkTestTask(t, "task-1", "doc-1")
svc := &ChunkService{
docEngine: nil,
kbDAO: dao.NewKnowledgebaseDAO(),
documentDAO: dao.NewDocumentDAO(),
}
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{
DocumentIDs: []string{"doc-1", "missing-doc", "doc-2"},
})
if err == nil {
t.Fatal("expected parse to fail")
}
if code != common.CodeDataError {
t.Fatalf("expected CodeDataError, got %v", code)
}
if !strings.Contains(err.Error(), "missing-doc") || !strings.Contains(err.Error(), "doc-2") {
t.Fatalf("expected missing and foreign documents in error, got %q", err.Error())
}
var taskCount int64
if err := dao.DB.Model(&entity.Task{}).Where("doc_id = ?", "doc-1").Count(&taskCount).Error; err != nil {
t.Fatalf("count tasks: %v", err)
}
if taskCount != 1 {
t.Fatalf("expected existing task to remain, got %d tasks", taskCount)
}
doc, err := dao.NewDocumentDAO().GetByID("doc-1")
if err != nil {
t.Fatalf("get doc: %v", err)
}
if doc.Run != nil {
t.Fatalf("expected doc run to remain nil, got %q", *doc.Run)
}
if doc.ChunkNum != 7 {
t.Fatalf("expected chunk_num to remain 7, got %d", doc.ChunkNum)
}
}
func TestParseRejectsInaccessibleDataset(t *testing.T) {
svc := newParseTestService(t)
svc.accessibleFunc = func(string, string) bool { return false }
_, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if err == nil {
t.Fatal("expected parse to fail")
}
if code != common.CodeOperatingError {
t.Fatalf("expected CodeOperatingError, got %v", code)
}
if !strings.Contains(err.Error(), "don't own the dataset") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseRequiresDocumentIDs(t *testing.T) {
svc := newParseTestService(t)
svc.accessibleFunc = func(string, string) bool { return true }
for _, req := range []*service.ParseFileRequest{nil, {DocumentIDs: nil}, {DocumentIDs: []string{}}} {
_, code, err := svc.Parse("user-1", "kb-1", req)
if err == nil {
t.Fatal("expected parse to fail")
}
if code != common.CodeDataError {
t.Fatalf("expected CodeDataError, got %v", code)
}
if !strings.Contains(err.Error(), "document_ids") {
t.Fatalf("unexpected error: %v", err)
}
}
}
func TestParseReturnsDataErrorWhenDatasetMissing(t *testing.T) {
svc := newParseTestService(t)
svc.accessibleFunc = func(string, string) bool { return true }
svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) { return nil, nil }
_, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if err == nil {
t.Fatal("expected parse to fail")
}
if code != common.CodeDataError {
t.Fatalf("expected CodeDataError, got %v", code)
}
if !strings.Contains(err.Error(), "dataset not found") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseReturnsServerErrorWhenDocumentsQueryFails(t *testing.T) {
svc := newParseTestService(t)
queryErr := errors.New("documents query failed")
svc.accessibleFunc = func(string, string) bool { return true }
svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) {
return &entity.Knowledgebase{ID: "kb-1", TenantID: "user-1"}, nil
}
svc.getDocumentsByIDsFunc = func([]string) ([]*entity.Document, error) {
return nil, queryErr
}
_, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if !errors.Is(err, queryErr) {
t.Fatalf("expected query error, got %v", err)
}
if code != common.CodeServerError {
t.Fatalf("expected CodeServerError, got %v", code)
}
}
func TestParseRejectsRunningDocument(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID := "user-1"
datasetID := "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
running := string(entity.TaskStatusRunning)
if err := dao.DB.Model(&entity.Document{}).Where("id = ?", "doc-1").Update("run", running).Error; err != nil {
t.Fatalf("mark doc running: %v", err)
}
svc := newParseTestService(t)
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if err == nil {
t.Fatal("expected parse to fail")
}
if code != common.CodeDataError {
t.Fatalf("expected CodeDataError, got %v", code)
}
if !strings.Contains(err.Error(), "currently being processed") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseReturnsServerErrorWhenDeleteChunksFails(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
deleteErr := errors.New("delete chunks failed")
engine := &parseTestDocEngine{deleteChunksErr: deleteErr}
svc := newParseTestService(t)
svc.docEngine = engine
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if !errors.Is(err, deleteErr) {
t.Fatalf("expected delete chunks error, got %v", err)
}
if code != common.CodeServerError {
t.Fatalf("expected CodeServerError, got %v", code)
}
if engine.deleteChunksCalls != 1 {
t.Fatalf("expected DeleteChunks once, got %d", engine.deleteChunksCalls)
}
}
func TestParseReturnsServerErrorWhenDeleteTasksFails(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
deleteErr := errors.New("delete tasks failed")
svc := newParseTestService(t)
svc.deleteTasksByDocIDsFunc = func([]string) (int64, error) { return 0, deleteErr }
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if !errors.Is(err, deleteErr) {
t.Fatalf("expected delete tasks error, got %v", err)
}
if code != common.CodeServerError {
t.Fatalf("expected CodeServerError, got %v", code)
}
}
func TestParseReturnsServerErrorWhenStorageAddressFails(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
storageErr := errors.New("storage address failed")
svc := newParseTestService(t)
svc.getDocumentStorageAddressFunc = func(*entity.Document) (string, string, error) {
return "", "", storageErr
}
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if !errors.Is(err, storageErr) {
t.Fatalf("expected storage error, got %v", err)
}
if code != common.CodeServerError {
t.Fatalf("expected CodeServerError, got %v", code)
}
}
func TestParseReturnsServerErrorWhenQueueFails(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
queueErr := errors.New("queue failed")
svc := newParseTestService(t)
svc.queueParseTasksFunc = func(*entity.Document, string, string, int64) error {
return queueErr
}
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if !errors.Is(err, queueErr) {
t.Fatalf("expected queue error, got %v", err)
}
if code != common.CodeServerError {
t.Fatalf("expected CodeServerError, got %v", code)
}
}
func TestParseCleansTasksWhenBeginParseFails(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
beginErr := errors.New("begin parse failed")
deleteCalls := 0
svc := newParseTestService(t)
svc.beginParseDocumentFunc = func(string) error { return beginErr }
svc.deleteTasksByDocIDsFunc = func([]string) (int64, error) {
deleteCalls++
return 1, nil
}
_, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if !errors.Is(err, beginErr) {
t.Fatalf("expected begin error, got %v", err)
}
if code != common.CodeServerError {
t.Fatalf("expected CodeServerError, got %v", code)
}
if deleteCalls != 2 {
t.Fatalf("expected initial delete and cleanup delete, got %d calls", deleteCalls)
}
}
func TestParseReturnsPartialSuccessForDuplicateDocumentIDs(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
svc := newParseTestService(t)
result, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{
DocumentIDs: []string{"doc-1", "doc-1"},
})
if err == nil {
t.Fatal("expected duplicate warning error")
}
if code != common.CodeSuccess {
t.Fatalf("expected CodeSuccess, got %v", code)
}
if result["success_count"] != 1 {
t.Fatalf("expected one successful parse, got %v", result["success_count"])
}
errorsValue, ok := result["errors"].([]string)
if !ok || len(errorsValue) != 1 || !strings.Contains(errorsValue[0], "Duplicate document ids: doc-1") {
t.Fatalf("unexpected duplicate errors: %#v", result["errors"])
}
}
func TestParseQueuesAndBeginsDocument(t *testing.T) {
db := setupChunkTestDB(t)
pushChunkTestDB(t, db)
userID, datasetID := "user-1", "kb-1"
insertChunkTestKB(t, datasetID, userID)
insertChunkTestDoc(t, "doc-1", datasetID)
insertChunkTestTask(t, "task-1", "doc-1")
queueCalls := 0
svc := newParseTestService(t)
svc.queueParseTasksFunc = func(doc *entity.Document, bucket, objectName string, priority int64) error {
queueCalls++
if doc.ID != "doc-1" || bucket != datasetID || objectName != "doc-1.txt" || priority != 0 {
t.Fatalf("unexpected queue args: doc=%s bucket=%s object=%s priority=%d", doc.ID, bucket, objectName, priority)
}
return nil
}
result, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}})
if err != nil {
t.Fatalf("expected parse success, got %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("expected CodeSuccess, got %v", code)
}
if result != nil {
t.Fatalf("expected nil result, got %#v", result)
}
if queueCalls != 1 {
t.Fatalf("expected queue once, got %d", queueCalls)
}
var taskCount int64
if err := dao.DB.Model(&entity.Task{}).Where("doc_id = ?", "doc-1").Count(&taskCount).Error; err != nil {
t.Fatalf("count tasks: %v", err)
}
if taskCount != 0 {
t.Fatalf("expected old tasks to be deleted, got %d", taskCount)
}
doc, err := dao.NewDocumentDAO().GetByID("doc-1")
if err != nil {
t.Fatalf("get doc: %v", err)
}
if doc.Run == nil || *doc.Run != string(entity.TaskStatusRunning) {
t.Fatalf("expected document to be running, got %v", doc.Run)
}
if doc.ChunkNum != 0 {
t.Fatalf("expected chunk_num reset to 0, got %d", doc.ChunkNum)
}
}
func setupChunkTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
TranslateError: true,
})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(
&entity.Knowledgebase{},
&entity.Document{},
&entity.Task{},
); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
return db
}
func pushChunkTestDB(t *testing.T, testDB *gorm.DB) {
t.Helper()
orig := dao.DB
dao.DB = testDB
t.Cleanup(func() {
dao.DB = orig
})
}
func newParseTestService(t *testing.T) *ChunkService {
t.Helper()
return &ChunkService{
docEngine: nil,
kbDAO: dao.NewKnowledgebaseDAO(),
documentDAO: dao.NewDocumentDAO(),
getDocumentStorageAddressFunc: func(doc *entity.Document) (string, string, error) {
if doc.Location == nil {
return doc.KbID, "", nil
}
return doc.KbID, *doc.Location, nil
},
queueParseTasksFunc: func(*entity.Document, string, string, int64) error {
return nil
},
}
}
func insertChunkTestKB(t *testing.T, id, tenantID string) {
t.Helper()
status := string(entity.StatusValid)
kb := &entity.Knowledgebase{
ID: id,
TenantID: tenantID,
Name: id,
EmbdID: "embedding-model",
Permission: string(entity.TenantPermissionMe),
CreatedBy: tenantID,
ParserConfig: entity.JSONMap{},
Status: &status,
}
if err := dao.DB.Create(kb).Error; err != nil {
t.Fatalf("insert kb: %v", err)
}
}
func insertChunkTestDoc(t *testing.T, id, kbID string) {
t.Helper()
location := id + ".txt"
name := id + ".txt"
status := string(entity.StatusValid)
doc := &entity.Document{
ID: id,
KbID: kbID,
ParserID: string(entity.ParserTypeNaive),
ParserConfig: entity.JSONMap{},
SourceType: string(entity.FileSourceLocal),
Type: "txt",
CreatedBy: "user-1",
Name: &name,
Location: &location,
ChunkNum: 7,
Suffix: ".txt",
Status: &status,
}
if err := dao.DB.Create(doc).Error; err != nil {
t.Fatalf("insert doc: %v", err)
}
}
func insertChunkTestTask(t *testing.T, id, docID string) {
t.Helper()
digest := "digest"
task := &entity.Task{
ID: id,
DocID: docID,
Digest: &digest,
}
if err := dao.DB.Create(task).Error; err != nil {
t.Fatalf("insert task: %v", err)
}
}
type parseTestDocEngine struct {
deleteChunksErr error
deleteChunksCalls int
}
func (e *parseTestDocEngine) CreateChunkStore(context.Context, string, string, int, string) error {
return nil
}
func (e *parseTestDocEngine) InsertChunks(context.Context, []map[string]interface{}, string, string) ([]string, error) {
return nil, nil
}
func (e *parseTestDocEngine) UpdateChunks(context.Context, map[string]interface{}, map[string]interface{}, string, string) error {
return nil
}
func (e *parseTestDocEngine) DeleteChunks(context.Context, map[string]interface{}, string, string) (int64, error) {
e.deleteChunksCalls++
return 0, e.deleteChunksErr
}
func (e *parseTestDocEngine) Search(context.Context, *types.SearchRequest) (*types.SearchResult, error) {
return nil, nil
}
func (e *parseTestDocEngine) GetChunk(context.Context, string, string, []string) (interface{}, error) {
return nil, nil
}
func (e *parseTestDocEngine) DropChunkStore(context.Context, string, string) error {
return nil
}
func (e *parseTestDocEngine) ChunkStoreExists(context.Context, string, string) (bool, error) {
return false, nil
}
func (e *parseTestDocEngine) CreateMetadataStore(context.Context, string) error {
return nil
}
func (e *parseTestDocEngine) InsertMetadata(context.Context, []map[string]interface{}, string) ([]string, error) {
return nil, nil
}
func (e *parseTestDocEngine) UpdateMetadata(context.Context, string, string, map[string]interface{}, string) error {
return nil
}
func (e *parseTestDocEngine) DeleteMetadata(context.Context, map[string]interface{}, string) (int64, error) {
return 0, nil
}
func (e *parseTestDocEngine) DeleteMetadataKeys(context.Context, string, string, []string, string) error {
return nil
}
func (e *parseTestDocEngine) DropMetadataStore(context.Context, string) error {
return nil
}
func (e *parseTestDocEngine) MetadataStoreExists(context.Context, string) (bool, error) {
return false, nil
}
func (e *parseTestDocEngine) SearchMetadata(context.Context, *types.SearchMetadataRequest) (*types.SearchMetadataResult, error) {
return nil, nil
}
func (e *parseTestDocEngine) IndexDocument(context.Context, string, string, interface{}) error {
return nil
}
func (e *parseTestDocEngine) DeleteDocument(context.Context, string, string) error {
return nil
}
func (e *parseTestDocEngine) BulkIndex(context.Context, string, []interface{}) (interface{}, error) {
return nil, nil
}
func (e *parseTestDocEngine) GetFields([]map[string]interface{}, []string) map[string]map[string]interface{} {
return nil
}
func (e *parseTestDocEngine) GetAggregation([]map[string]interface{}, string) []map[string]interface{} {
return nil
}
func (e *parseTestDocEngine) GetHighlight([]map[string]interface{}, []string, string) map[string]string {
return nil
}
func (e *parseTestDocEngine) GetChunkIDs([]map[string]interface{}) []string {
return nil
}
func (e *parseTestDocEngine) KNNScores(context.Context, []map[string]interface{}, []float64, int) (map[string]interface{}, error) {
return nil, nil
}
func (e *parseTestDocEngine) GetScores(map[string]interface{}) map[string]float64 {
return nil
}
func (e *parseTestDocEngine) Ping(context.Context) error {
return nil
}
func (e *parseTestDocEngine) Close() error {
return nil
}
func (e *parseTestDocEngine) GetType() string {
return "test"
}
func (e *parseTestDocEngine) FilterDocIdsByMetaPushdown(context.Context, []string, []map[string]interface{}, string) []string {
return nil
}

View File

@@ -24,7 +24,6 @@ import (
"ragflow/internal/server"
"strings"
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
@@ -43,10 +42,9 @@ type ChunkService struct {
searchService *SearchService
}
// RetrievalTestRequest retrieval test request
type RetrievalTestRequest struct {
Datasets common.StringSlice `json:"dataset_ids" binding:"required"` // string or []string
Datasets common.StringSlice `json:"dataset_ids" binding:"required"` // string or []string
Question string `json:"question"`
Page *int `json:"page,omitempty"`
Size *int `json:"size,omitempty"`
@@ -81,6 +79,11 @@ type GetChunkResponse struct {
Chunk map[string]interface{} `json:"chunk"`
}
// ParseFileRequest is the request body for reparsing documents in a dataset.
type ParseFileRequest struct {
DocumentIDs []string `json:"document_ids"`
}
// Get retrieves a chunk by ID
func (s *ChunkService) Get(req *GetChunkRequest, userID string) (*GetChunkResponse, error) {
if s.docEngine == nil {