Files
ragflow/internal/handler/skill_search.go
Yingfeng 4ee0702aed Feat: add skills space to context engine (#13908)
### What problem does this PR solve?

issue #13714

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2026-04-30 12:36:03 +08:00

574 lines
17 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package handler
import (
"fmt"
"net/http"
"ragflow/internal/common"
"ragflow/internal/engine"
"ragflow/internal/logger"
"ragflow/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// SkillSearchHandler handles skill search HTTP requests
type SkillSearchHandler struct {
searchService *service.SkillSearchService
indexerService *service.SkillIndexerService
spaceService *service.SkillSpaceService
docEngine engine.DocEngine
}
// NewSkillSearchHandler creates a new skill search handler
func NewSkillSearchHandler(docEngine engine.DocEngine) *SkillSearchHandler {
return &SkillSearchHandler{
searchService: service.NewSkillSearchService(),
indexerService: service.NewSkillIndexerService(),
spaceService: service.NewSkillSpaceService(),
docEngine: docEngine,
}
}
// GetConfig handles the get skill search config request
// @Summary Get Skill Search Config
// @Description Get the search configuration for skills
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param embd_id query string true "Embedding Model ID"
// @Param space_id query string false "Skill Space ID"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skills/config [get]
func (h *SkillSearchHandler) GetConfig(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
embdID := c.Query("embd_id")
spaceID := c.Query("space_id")
result, code, err := h.searchService.GetConfig(user.ID, spaceID, embdID)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// UpdateConfig handles the update skill search config request
// @Summary Update Skill Search Config
// @Description Update the search configuration for skills
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body service.UpdateConfigRequest true "config info"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skills/config [post]
func (h *SkillSearchHandler) UpdateConfig(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req service.UpdateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeDataError, err.Error())
return
}
req.TenantID = user.ID
result, code, err := h.searchService.UpdateConfig(&req)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// Search handles the skill search request
// @Summary Search Skills
// @Description Search skills using configured search strategy
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body service.SearchRequest true "search query"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skills/search [post]
func (h *SkillSearchHandler) Search(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req service.SearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeDataError, err.Error())
return
}
req.TenantID = user.ID
result, code, err := h.searchService.Search(c.Request.Context(), &req, h.docEngine)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// IndexSkillsRequest represents the request to index skills
type IndexSkillsRequest struct {
Skills []service.SkillInfo `json:"skills" binding:"required"`
SpaceID string `json:"space_id"`
EmbdID string `json:"embd_id"` // Optional, will use config's embd_id if empty
}
// IndexSkills handles the index skills request
// @Summary Index Skills
// @Description Index skills for search. If embd_id is not provided, will use the one from skill search config.
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body IndexSkillsRequest true "skills to index"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skills/index [post]
func (h *SkillSearchHandler) IndexSkills(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req IndexSkillsRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeDataError, err.Error())
return
}
// If embd_id not provided, get from skill search config
embdID := req.EmbdID
if embdID == "" {
config, code, err := h.searchService.GetConfig(user.ID, req.SpaceID, "")
if err != nil {
jsonError(c, code, "failed to get skill search config: "+err.Error())
return
}
val, ok := config["embd_id"].(string)
if !ok || val == "" {
jsonError(c, common.CodeDataError, "no embedding model configured in skill search config")
return
}
embdID = val
}
// Ensure index exists before indexing (for both ES and Infinity)
logger.Info("Ensuring skill index exists before indexing",
zap.String("tenantID", user.ID),
zap.String("spaceID", req.SpaceID),
zap.String("engineType", h.docEngine.GetType()),
zap.Int("skillCount", len(req.Skills)))
if h.docEngine.GetType() == "elasticsearch" {
if err := h.indexerService.EnsureIndex(c.Request.Context(), user.ID, req.SpaceID, h.docEngine, embdID); err != nil {
jsonError(c, common.CodeOperatingError, err.Error())
return
}
}
if err := h.indexerService.BatchIndexSkills(c.Request.Context(), user.ID, req.SpaceID, req.Skills, h.docEngine, embdID); err != nil {
logger.Error(fmt.Sprintf("Failed to batch index skills: tenantID=%s, spaceID=%s, error=%v", user.ID, req.SpaceID, err), err)
jsonError(c, common.CodeOperatingError, err.Error())
return
}
logger.Info("Successfully indexed skills",
zap.String("tenantID", user.ID),
zap.String("spaceID", req.SpaceID),
zap.Int("indexedCount", len(req.Skills)))
jsonResponse(c, common.CodeSuccess, gin.H{
"indexed_count": len(req.Skills),
}, "success")
}
// ReindexRequest represents the request to reindex skills
type ReindexRequest struct {
SpaceID string `json:"space_id" binding:"required"`
EmbdID string `json:"embd_id"` // Optional, will use config's embd_id if empty
}
// Reindex handles the reindex all skills request
// @Summary Reindex All Skills
// @Description Reindex all skills for a tenant. If embd_id is not provided, will use the one from skill search config.
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body ReindexRequest true "skills to reindex"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skills/reindex [post]
func (h *SkillSearchHandler) Reindex(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req ReindexRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeDataError, err.Error())
return
}
// If embd_id not provided, get from skill search config
embdID := req.EmbdID
if embdID == "" {
config, code, err := h.searchService.GetConfig(user.ID, req.SpaceID, "")
if err != nil {
jsonError(c, code, "failed to get skill search config: "+err.Error())
return
}
val, ok := config["embd_id"].(string)
if !ok || val == "" {
jsonError(c, common.CodeDataError, "no embedding model configured in skill search config")
return
}
embdID = val
}
result, err := h.indexerService.ReindexAll(c.Request.Context(), user.ID, req.SpaceID, h.docEngine, embdID)
if err != nil {
jsonError(c, common.CodeOperatingError, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// DeleteSkillIndex handles the delete skill index request
// @Summary Delete Skill Index
// @Description Delete a skill's search index
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param skill_id query string true "Skill ID (skill name)"
// @Param space_id query string true "Space ID"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skills/index [delete]
func (h *SkillSearchHandler) DeleteSkillIndex(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
skillID := c.Query("skill_id")
spaceID := c.Query("space_id")
if skillID == "" {
jsonError(c, common.CodeDataError, "skill_id is required")
return
}
err := h.indexerService.DeleteSkillIndex(c.Request.Context(), user.ID, spaceID, skillID, h.docEngine)
if err != nil {
jsonError(c, common.CodeOperatingError, "failed to delete skill index")
return
}
jsonResponse(c, common.CodeSuccess, true, "success")
}
// InitializeIndex handles the initialize skill search index request
// @Summary Initialize Skill Search Index
// @Description Initialize the skill search index for a tenant
// @Tags skill-search
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param embd_id query string true "Embedding Model ID"
// @Param space_id query string false "Skill Space ID"
// @Success 200 {object} map[string]interface{}
// @Router /v1/skill/search/init [post]
func (h *SkillSearchHandler) InitializeIndex(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
embdID := c.Query("embd_id")
spaceID := c.Query("space_id")
if embdID == "" {
jsonError(c, common.CodeDataError, "embd_id is required")
return
}
if err := h.indexerService.InitializeIndex(c.Request.Context(), user.ID, spaceID, h.docEngine, embdID); err != nil {
jsonError(c, common.CodeOperatingError, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, gin.H{"initialized": true}, "success")
}
// ==================== Skill Space Management ====================
// ListSpaces handles the list skill spaces request
// @Summary List Skill Spaces
// @Description List all skill spaces for the current tenant
// @Tags skill-space
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/skills/spaces [get]
func (h *SkillSearchHandler) ListSpaces(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
result, code, err := h.spaceService.ListSpaces(user.ID)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// CreateSpaceRequest represents the request to create a skill space
type CreateSpaceRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
EmbdID string `json:"embd_id"`
RerankID string `json:"rerank_id"`
}
// CreateSpace handles the create skill space request
// @Summary Create Skill Space
// @Description Create a new skill space with associated folder
// @Tags skill-space
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body CreateSpaceRequest true "space info"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/skills/spaces [post]
func (h *SkillSearchHandler) CreateSpace(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req CreateSpaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeDataError, err.Error())
return
}
result, code, err := h.spaceService.CreateSpace(&service.CreateSpaceRequest{
TenantID: user.ID,
Name: req.Name,
Description: req.Description,
EmbdID: req.EmbdID,
RerankID: req.RerankID,
})
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// GetSpace handles the get skill space request
// @Summary Get Skill Space
// @Description Get a skill space by ID
// @Tags skill-space
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param space_id path string true "Space ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/skills/spaces/{space_id} [get]
func (h *SkillSearchHandler) GetSpace(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
spaceID := c.Param("space_id")
if spaceID == "" {
jsonError(c, common.CodeDataError, "space_id is required")
return
}
result, code, err := h.spaceService.GetSpace(spaceID, user.ID)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// UpdateSpaceRequest represents the request to update a skill space
type UpdateSpaceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
EmbdID string `json:"embd_id"`
RerankID string `json:"rerank_id"`
TopK int `json:"top_k"`
}
// UpdateSpace handles the update skill space request
// @Summary Update Skill Space
// @Description Update a skill space
// @Tags skill-space
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param space_id path string true "Space ID"
// @Param request body UpdateSpaceRequest true "space updates"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/skills/spaces/{space_id} [put]
func (h *SkillSearchHandler) UpdateSpace(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
spaceID := c.Param("space_id")
if spaceID == "" {
jsonError(c, common.CodeDataError, "space_id is required")
return
}
var req UpdateSpaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeDataError, err.Error())
return
}
result, code, err := h.spaceService.UpdateSpace(spaceID, user.ID, &service.UpdateSpaceRequest{
Name: req.Name,
Description: req.Description,
EmbdID: req.EmbdID,
RerankID: req.RerankID,
TopK: req.TopK,
})
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}
// DeleteSpace handles the delete skill space request
// @Summary Delete Skill Space
// @Description Delete a skill space and its associated folder
// @Tags skill-space
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param space_id path string true "Space ID"
// @Success 202 {object} map[string]interface{}
// @Router /api/v1/skills/spaces/{space_id} [delete]
func (h *SkillSearchHandler) DeleteSpace(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
spaceID := c.Param("space_id")
if spaceID == "" {
jsonError(c, common.CodeDataError, "space_id is required")
return
}
// Get Authorization header for Python API calls
authHeader := c.GetHeader("Authorization")
code, err := h.spaceService.DeleteSpace(spaceID, user.ID, h.docEngine, authHeader)
if err != nil {
jsonError(c, code, err.Error())
return
}
// Return 202 Accepted since deletion is async
c.JSON(http.StatusAccepted, gin.H{
"code": 0,
"data": gin.H{"deleting": true, "space_id": spaceID},
"message": "success",
})
}
// GetSpaceByFolder handles the get skill space by folder ID request
// @Summary Get Skill Space by Folder
// @Description Get a skill space by its folder ID
// @Tags skill-space
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param folder_id query string true "Folder ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/skills/space/by-folder [get]
func (h *SkillSearchHandler) GetSpaceByFolder(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Query("folder_id")
if folderID == "" {
jsonError(c, common.CodeDataError, "folder_id is required")
return
}
result, code, err := h.spaceService.GetSpaceByFolderID(folderID, user.ID)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, common.CodeSuccess, result, "success")
}