Files
ragflow/internal/service/skill_search.go

1039 lines
33 KiB
Go
Raw Normal View History

//
// 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 service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
"ragflow/internal/entity"
"ragflow/internal/entity/models"
"ragflow/internal/utility"
"strings"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SkillSearchService handles business logic for skill search operations
type SkillSearchService struct {
configDAO *dao.SkillSearchConfigDAO
modelProvider *ModelProviderService
}
// NewSkillSearchService creates a new SkillSearchService instance
func NewSkillSearchService() *SkillSearchService {
return &SkillSearchService{
configDAO: dao.NewSkillSearchConfigDAO(),
modelProvider: NewModelProviderService(),
}
}
// SetModelProvider sets the model provider for embedding generation
func (s *SkillSearchService) SetModelProvider(provider *ModelProviderService) {
s.modelProvider = provider
}
// GetConfigRequest represents the request to get skill search config
type GetConfigRequest struct {
TenantID string `json:"tenant_id" binding:"required"`
SpaceID string `json:"space_id"`
}
// GetConfig retrieves the search configuration for a tenant
func (s *SkillSearchService) GetConfig(tenantID, spaceID, embdID string) (map[string]interface{}, common.ErrorCode, error) {
spaceID = normalizeSpaceID(spaceID)
var config *entity.SkillSearchConfig
var err error
if embdID == "" {
// If embd_id is not provided, get the latest config for the tenant
// Prioritize configs with non-empty embd_id (user-saved configs)
config, err = s.configDAO.GetLatestByTenantID(tenantID, spaceID)
if err != nil {
// No config found, return default config
config = &entity.SkillSearchConfig{
TenantID: tenantID,
SpaceID: spaceID,
EmbdID: "",
VectorSimilarityWeight: 0.3,
SimilarityThreshold: 0.2,
FieldConfig: map[string]interface{}{
"name": map[string]interface{}{"enabled": true, "weight": 3.0},
"tags": map[string]interface{}{"enabled": true, "weight": 2.0},
"description": map[string]interface{}{"enabled": true, "weight": 1.0},
"content": map[string]interface{}{"enabled": false, "weight": 0.5},
},
TopK: 10,
}
}
} else {
config, err = s.configDAO.GetByTenantAndEmbdID(tenantID, spaceID, embdID)
if err != nil {
// Config not found, create default one
config, err = s.configDAO.GetOrCreate(tenantID, spaceID, embdID)
if err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to get or create config: %w", err)
}
}
}
return config.ToMap(), common.CodeSuccess, nil
}
// UpdateConfigRequest represents the request to update skill search config
type UpdateConfigRequest struct {
TenantID string `json:"tenant_id"`
SpaceID string `json:"space_id"`
EmbdID string `json:"embd_id" binding:"required"`
VectorSimilarityWeight float64 `json:"vector_similarity_weight"`
SimilarityThreshold float64 `json:"similarity_threshold"`
FieldConfig entity.FieldConfig `json:"field_config"`
RerankID string `json:"rerank_id"`
TopK int64 `json:"top_k"`
}
// UpdateConfig updates the search configuration for a tenant
func (s *SkillSearchService) UpdateConfig(req *UpdateConfigRequest) (map[string]interface{}, common.ErrorCode, error) {
req.SpaceID = normalizeSpaceID(req.SpaceID)
// Validate vector_similarity_weight
if req.VectorSimilarityWeight < 0 || req.VectorSimilarityWeight > 1 {
return nil, common.CodeDataError, errors.New("vector_similarity_weight must be between 0 and 1")
}
// Validate similarity_threshold
if req.SimilarityThreshold < 0 || req.SimilarityThreshold > 1 {
return nil, common.CodeDataError, errors.New("similarity_threshold must be between 0 and 1")
}
// Validate top_k
if req.TopK <= 0 {
return nil, common.CodeDataError, errors.New("top_k must be positive")
}
// Get or create config for this tenant+space (regardless of embd_id)
// Each tenant+space should have only ONE config, switching embd_id updates the existing config
config, err := s.configDAO.GetLatestByTenantID(req.TenantID, req.SpaceID)
if err != nil {
// No config exists, create a new one
config, err = s.configDAO.CreateWithTenantSpace(req.TenantID, req.SpaceID, req.EmbdID)
if err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to create config: %w", err)
}
} else {
// Config exists, clean up any other active records for this tenant+space
// to ensure only one active config per tenant+space
if err := s.configDAO.DeleteAllByTenantSpaceExceptID(req.TenantID, req.SpaceID, config.ID); err != nil {
common.Warn("Failed to clean up duplicate configs", zap.Error(err))
}
}
fieldConfigMap := entity.JSONMap{
"name": map[string]interface{}{
"enabled": req.FieldConfig.Name.Enabled,
"weight": req.FieldConfig.Name.Weight,
},
"tags": map[string]interface{}{
"enabled": req.FieldConfig.Tags.Enabled,
"weight": req.FieldConfig.Tags.Weight,
},
"description": map[string]interface{}{
"enabled": req.FieldConfig.Description.Enabled,
"weight": req.FieldConfig.Description.Weight,
},
"content": map[string]interface{}{
"enabled": req.FieldConfig.Content.Enabled,
"weight": req.FieldConfig.Content.Weight,
},
}
updates := map[string]interface{}{
"embd_id": req.EmbdID, // Always update embd_id to the new value
"vector_similarity_weight": req.VectorSimilarityWeight,
"similarity_threshold": req.SimilarityThreshold,
"field_config": fieldConfigMap,
"top_k": req.TopK,
}
if req.RerankID != "" {
updates["rerank_id"] = req.RerankID
}
// Update by config ID to ensure we update the correct record
if err := s.configDAO.Update(config.ID, updates); err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to update config: %w", err)
}
// Refresh config
config, err = s.configDAO.GetByID(config.ID)
if err != nil {
return nil, common.CodeOperatingError, fmt.Errorf("failed to refresh config: %w", err)
}
return config.ToMap(), common.CodeSuccess, nil
}
// SearchRequest represents the skill search request
type SearchRequest struct {
TenantID string `json:"tenant_id"` // Set from user context, not from request body
SpaceID string `json:"space_id"`
Query string `json:"query"` // Empty query lists all skills (match_all)
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"` // Sort field: "name", "update_time", "create_time", "relevance"
SortOrder string `json:"sort_order"` // "asc" or "desc", default "desc" for time fields, "asc" for name
}
// SearchResponse represents the skill search response
type SearchResponse struct {
Skills []entity.SkillSearchResult `json:"skills"` // Changed from "results" to match frontend
Total int64 `json:"total"`
Query string `json:"query"`
SearchType string `json:"search_type"` // "keyword", "vector", "hybrid"
}
// Search performs skill search with the configured strategy
func (s *SkillSearchService) Search(ctx context.Context, req *SearchRequest, docEngine engine.DocEngine) (*SearchResponse, common.ErrorCode, error) {
req.SpaceID = normalizeSpaceID(req.SpaceID)
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
// Check if index exists before searching
indexName := getSkillIndexName(req.TenantID, req.SpaceID)
common.Debug("Searching skills", zap.String("indexName", indexName), zap.String("query", req.Query))
indexExists, err := docEngine.ChunkStoreExists(ctx, indexName, "skill")
if err != nil {
common.Error("Failed to check index existence", err)
return nil, common.CodeOperatingError, fmt.Errorf("failed to check index existence: %w", err)
}
common.Debug("Index existence check", zap.String("indexName", indexName), zap.Bool("exists", indexExists))
if !indexExists {
// Return empty result if index doesn't exist (no skills indexed yet)
// This allows listing skills via file system API as fallback
common.Warn("Skill index does not exist, returning empty result", zap.String("indexName", indexName), zap.String("tenantID", req.TenantID), zap.String("spaceID", req.SpaceID))
return &SearchResponse{
Skills: []entity.SkillSearchResult{},
Total: 0,
Query: req.Query,
SearchType: "keyword",
}, common.CodeSuccess, nil
}
// Get config for search strategy
// Use GetLatestByTenantID to prioritize configs with non-empty embd_id
config, err := s.configDAO.GetLatestByTenantID(req.TenantID, req.SpaceID)
if err != nil {
// Use default config if not found
config = &entity.SkillSearchConfig{
SpaceID: req.SpaceID,
VectorSimilarityWeight: 0.3,
SimilarityThreshold: 0.2,
FieldConfig: map[string]interface{}{
"name": map[string]interface{}{"enabled": true, "weight": 3.0},
"tags": map[string]interface{}{"enabled": true, "weight": 2.0},
"description": map[string]interface{}{"enabled": true, "weight": 1.0},
"content": map[string]interface{}{"enabled": false, "weight": 0.5},
},
TopK: 10,
}
}
var results []entity.SkillSearchResult
searchType := "hybrid"
// Check if embedding model is configured
hasEmbdConfig := config.EmbdID != ""
switch {
case config.VectorSimilarityWeight == 0 || !hasEmbdConfig || req.Query == "":
// Pure keyword search (BM25)
// Also fallback to keyword search if no embedding model configured
// Or if query is empty (list all)
searchType = "keyword"
// For empty query (list all), pass threshold=0 to disable score filtering
threshold := config.SimilarityThreshold
if req.Query == "" {
threshold = 0 // Disable threshold for list all
}
results, err = s.keywordSearch(ctx, docEngine, indexName, req.Query, config, threshold, req.SortBy, req.SortOrder)
case config.VectorSimilarityWeight == 1 && req.Query != "":
// Pure vector search (skip if query is empty)
searchType = "vector"
results, err = s.vectorSearch(ctx, docEngine, indexName, req.Query, config, req.TenantID)
if err != nil {
common.Warn("Vector search failed, falling back to keyword search", zap.Error(err))
searchType = "keyword"
results, err = s.keywordSearch(ctx, docEngine, indexName, req.Query, config, config.SimilarityThreshold, req.SortBy, req.SortOrder)
}
default:
// Hybrid search (fallback to keyword if query is empty)
if req.Query == "" {
// Empty query: list all, disable threshold
results, err = s.keywordSearch(ctx, docEngine, indexName, req.Query, config, 0, req.SortBy, req.SortOrder)
} else {
results, err = s.hybridSearch(ctx, docEngine, indexName, req.Query, config, req.TenantID)
}
}
if err != nil {
common.Error("Skill search failed", err)
return nil, common.CodeOperatingError, fmt.Errorf("search failed: %w", err)
}
// Apply pagination
total := int64(len(results))
start := (req.Page - 1) * req.PageSize
end := start + req.PageSize
if start > int(total) {
start = int(total)
}
if end > int(total) {
end = int(total)
}
paginatedResults := results[start:end]
return &SearchResponse{
Skills: paginatedResults,
Total: total,
Query: req.Query,
SearchType: searchType,
}, common.CodeSuccess, nil
}
// keywordSearch performs pure keyword search using BM25
func (s *SkillSearchService) keywordSearch(ctx context.Context, docEngine engine.DocEngine, indexName, query string, config *entity.SkillSearchConfig, threshold float64, sortBy, sortOrder string) ([]entity.SkillSearchResult, error) {
// Build order_by for sorting
orderBy := buildOrderByExpr(sortBy, sortOrder, query == "")
// Build MatchTextExpr for unified engine interface
// Note: MatchingText must be plain text, NOT ES query_string syntax.
// Infinity's MatchText expects plain text and tokenizes internally.
// ES's buildSkillKeywordQuery wraps it in a query_string query.
// Field names: Infinity uses raw names (name, tags, etc.),
// ES uses _tks suffix handled internally by elasticsearch/search.go
matchExpr := &types.MatchTextExpr{
MatchingText: query,
// Skill index uses single tokenizer (rag-coarse) per field, no _sm variants needed.
// Infinity: convertMatchingField maps these to column@index_name format
// (e.g., name→name@ft_name_rag_coarse)
// ES: buildSkillKeywordQuery uses its own field list internally
Fields: []string{
"name^10",
"tags^5",
"description^3",
"content^1",
},
TopN: 100,
}
// Use unified search request with analyzed query
searchReq := &types.SearchRequest{
IndexNames: []string{indexName},
Offset: 0,
Limit: 100,
MatchExprs: []interface{}{matchExpr},
OrderBy: orderBy,
}
searchResult, err := docEngine.Search(ctx, searchReq)
if err != nil {
return nil, err
}
// Convert chunks to SkillSearchResult
return s.convertChunksToResults(searchResult.Chunks, threshold), nil
}
// vectorSearch performs pure vector search
func (s *SkillSearchService) vectorSearch(ctx context.Context, docEngine engine.DocEngine, indexName, query string, config *entity.SkillSearchConfig, tenantID string) ([]entity.SkillSearchResult, error) {
// Get embedding for query
vector, err := s.getEmbedding(ctx, query, config.EmbdID, tenantID)
if err != nil {
common.Warn("Vector search: failed to get embedding, will fallback to keyword search",
zap.String("embdID", config.EmbdID),
zap.Error(err))
return nil, fmt.Errorf("failed to get embedding: %w", err)
}
common.Debug("Vector search: successfully got embedding",
zap.String("embdID", config.EmbdID),
zap.Int("dimension", len(vector)))
// Analyze query for potential keyword filtering
matchExpr := &types.MatchTextExpr{
MatchingText: query,
Fields: []string{
"name^10",
"tags^5",
"description^3",
"content^1",
},
TopN: int(config.TopK),
}
// Build MatchDenseExpr for vector search
vectorColumnName := fmt.Sprintf("q_%d_vec", len(vector))
matchDense := &types.MatchDenseExpr{
VectorColumnName: vectorColumnName,
EmbeddingData: vector,
EmbeddingDataType: "float",
DistanceType: "cosine",
TopN: int(config.TopK),
ExtraOptions: map[string]interface{}{
"similarity": config.SimilarityThreshold,
},
}
// Use unified search request
searchReq := &types.SearchRequest{
IndexNames: []string{indexName},
Offset: 0,
Limit: 100,
MatchExprs: []interface{}{matchExpr, matchDense},
}
searchResult, err := docEngine.Search(ctx, searchReq)
if err != nil {
common.Warn("Vector search: search execution failed",
zap.String("indexName", indexName),
zap.Error(err))
return nil, err
}
results := s.convertChunksToResults(searchResult.Chunks, config.SimilarityThreshold)
common.Debug("Vector search: completed",
zap.Int("totalChunks", len(searchResult.Chunks)),
zap.Int("filteredResults", len(results)))
// If no results, return error to trigger fallback
if len(results) == 0 {
common.Info("Vector search: no results found, will fallback to keyword search",
zap.String("indexName", indexName),
zap.String("query", query))
return nil, fmt.Errorf("vector search returned no results")
}
return results, nil
}
// hybridSearch performs hybrid search combining BM25 and vector search
func (s *SkillSearchService) hybridSearch(ctx context.Context, docEngine engine.DocEngine, indexName, query string, config *entity.SkillSearchConfig, tenantID string) ([]entity.SkillSearchResult, error) {
// Analyze query first: tokenize and extract keywords
matchExpr := &types.MatchTextExpr{
MatchingText: query,
Fields: []string{
"name^10",
"tags^5",
"description^3",
"content^1",
},
TopN: int(config.TopK),
}
// Get embedding for query
vector, err := s.getEmbedding(ctx, query, config.EmbdID, tenantID)
if err != nil {
common.Warn("Hybrid search: failed to get embedding, falling back to keyword search",
zap.String("embdID", config.EmbdID),
zap.Error(err))
// Fallback to keyword search with analyzed query
return s.executeKeywordSearch(ctx, docEngine, indexName, query, matchExpr, config)
}
common.Debug("Hybrid search: successfully got embedding",
zap.String("embdID", config.EmbdID),
zap.Int("dimension", len(vector)))
// Build MatchDenseExpr for hybrid search
vectorColumnName := fmt.Sprintf("q_%d_vec", len(vector))
matchDense := &types.MatchDenseExpr{
VectorColumnName: vectorColumnName,
EmbeddingData: vector,
EmbeddingDataType: "float",
DistanceType: "cosine",
TopN: int(config.TopK),
ExtraOptions: map[string]interface{}{
"similarity": config.SimilarityThreshold,
"text_weight": 1.0 - config.VectorSimilarityWeight,
},
}
// Build FusionExpr for hybrid search (required by Infinity to combine text + vector scores)
textWeight := 1.0 - config.VectorSimilarityWeight
vectorWeight := config.VectorSimilarityWeight
fusionExpr := &types.FusionExpr{
Method: "weighted_sum",
TopN: int(config.TopK),
FusionParams: map[string]interface{}{"weights": fmt.Sprintf("%.2f,%.2f", textWeight, vectorWeight)},
}
// Use unified search request for hybrid search with analyzed query
searchReq := &types.SearchRequest{
IndexNames: []string{indexName},
Offset: 0,
Limit: 100,
MatchExprs: []interface{}{matchExpr, matchDense, fusionExpr},
}
searchResult, err := docEngine.Search(ctx, searchReq)
if err != nil {
common.Warn("Hybrid search: search execution failed, falling back to keyword search",
zap.String("indexName", indexName),
zap.Error(err))
return s.executeKeywordSearch(ctx, docEngine, indexName, query, matchExpr, config)
}
results := s.convertChunksToResults(searchResult.Chunks, config.SimilarityThreshold)
common.Debug("Hybrid search completed",
zap.Int("totalChunks", len(searchResult.Chunks)),
zap.Int("filteredResults", len(results)))
// If no results, fallback to keyword search
if len(results) == 0 {
common.Info("Hybrid search: no results found, falling back to keyword search",
zap.String("indexName", indexName),
zap.String("query", query))
return s.executeKeywordSearch(ctx, docEngine, indexName, query, matchExpr, config)
}
return results, nil
}
// executeKeywordSearch executes a keyword search (used for fallback)
func (s *SkillSearchService) executeKeywordSearch(ctx context.Context, docEngine engine.DocEngine, indexName, query string, matchExpr *types.MatchTextExpr, config *entity.SkillSearchConfig) ([]entity.SkillSearchResult, error) {
common.Debug("Executing fallback keyword search",
zap.String("indexName", indexName),
zap.String("query", query))
searchReq := &types.SearchRequest{
IndexNames: []string{indexName},
Offset: 0,
Limit: 100,
MatchExprs: []interface{}{matchExpr},
}
searchResult, err := docEngine.Search(ctx, searchReq)
if err != nil {
common.Error("Keyword search fallback failed", err)
return nil, err
}
results := s.convertChunksToResults(searchResult.Chunks, config.SimilarityThreshold)
common.Debug("Keyword search fallback completed",
zap.Int("totalChunks", len(searchResult.Chunks)),
zap.Int("results", len(results)))
return results, nil
}
// convertChunksToResults converts search chunks to SkillSearchResult
// Deduplicates by skill name, keeping only the highest scored result for each skill
func (s *SkillSearchService) convertChunksToResults(chunks []map[string]interface{}, threshold float64) []entity.SkillSearchResult {
// Use a map to deduplicate by skill name, keeping the highest scored version
skillMap := make(map[string]entity.SkillSearchResult)
for _, chunk := range chunks {
// Get score
score := 0.0
if scoreVal, ok := chunk["_score"].(float64); ok {
score = scoreVal
}
// Extract BM25 and vector scores from Infinity columns
// Infinity returns "SCORE" for fulltext match and "SIMILARITY" for vector match
// Note: SCORE/SIMILARITY may be float32 or float64 depending on Infinity version
bm25Score := 0.0
if scoreVal, ok := chunk["SCORE"]; ok {
if f, ok := utility.ToFloat64(scoreVal); ok {
bm25Score = f
}
}
vectorScore := 0.0
if simVal, ok := chunk["SIMILARITY"]; ok {
if f, ok := utility.ToFloat64(simVal); ok {
vectorScore = f
}
}
// If _score is set but individual scores are 0, _score IS the BM25 score
if score > 0 && bm25Score == 0 && vectorScore == 0 {
bm25Score = score
}
// Filter by threshold
if score < threshold {
continue
}
// Extract fields
skillID := getString(chunk, "skill_id")
folderID := getString(chunk, "folder_id")
name := getString(chunk, "name")
description := getString(chunk, "description")
// Extract tags (Infinity stores as comma-separated string, ES may return as string too)
var tags []string
if tagsVal, ok := chunk["tags"].([]interface{}); ok {
for _, tag := range tagsVal {
if tagStr, ok := tag.(string); ok {
tags = append(tags, tagStr)
}
}
} else if tagsStr, ok := chunk["tags"].(string); ok && tagsStr != "" {
for _, tag := range strings.Split(tagsStr, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
}
// Use skill name as the deduplication key (skillID may contain version suffix)
skillKey := name
if skillKey == "" {
skillKey = skillID
}
// Extract create_time
var createTime int64
if ctVal, ok := chunk["create_time"].(float64); ok {
createTime = int64(ctVal)
} else if ctVal, ok := chunk["create_time"].(int64); ok {
createTime = ctVal
}
// Extract version
version := getString(chunk, "version")
result := entity.SkillSearchResult{
SkillID: skillID,
FolderID: folderID,
Name: name,
Description: description,
Tags: tags,
Score: score,
BM25Score: bm25Score,
VectorScore: vectorScore,
CreateTime: createTime,
Version: version,
}
// Keep only the highest scored result for each skill
if existing, ok := skillMap[skillKey]; !ok || score > existing.Score {
skillMap[skillKey] = result
}
}
// Convert map to slice
var results []entity.SkillSearchResult
for _, result := range skillMap {
results = append(results, result)
}
// Sort by score descending
sortResults(results)
return results
}
// getEmbedding generates embedding for text using the specified model
func (s *SkillSearchService) getEmbedding(ctx context.Context, text, embdID, tenantID string) ([]float64, error) {
if s.modelProvider == nil {
return nil, fmt.Errorf("model provider not set")
}
if embdID == "" {
return nil, fmt.Errorf("embedding model ID not configured")
}
embeddingModel, err := s.modelProvider.GetEmbeddingModel(tenantID, embdID)
if err != nil {
return nil, fmt.Errorf("failed to get embedding model: %w", err)
}
// Truncate text to prevent exceeding model's max input length
maxLen := embeddingModel.MaxTokens
if maxLen <= 0 {
maxLen = defaultMaxLength
}
truncatedText := truncate(text, maxLen-10)
var response []models.EmbeddingData
response, err = embeddingModel.ModelDriver.Embed(embeddingModel.ModelName, []string{truncatedText}, embeddingModel.APIConfig, nil)
if err != nil {
return nil, fmt.Errorf("failed to encode query: %w", err)
}
if len(response) == 0 {
return nil, fmt.Errorf("embedding returned empty result")
}
return response[0].Embedding, nil
}
// Helper functions
func getSkillIndexName(tenantID, spaceID string) string {
spaceID = normalizeSpaceID(spaceID)
spaceID = strings.ToLower(spaceID)
replacer := strings.NewReplacer("-", "_", "/", "_", "\\", "_", " ", "_", ".", "_", ":", "_")
sanitizedSpaceID := replacer.Replace(spaceID)
// Generate unique, deterministic suffix from full IDs to avoid collisions
// Use SHA-256 hash of the combined tenantID and sanitizedSpaceID
hash := sha256.Sum256([]byte(tenantID + "_" + sanitizedSpaceID))
hashStr := hex.EncodeToString(hash[:])[:16] // Take first 16 hex chars (64-bit entropy)
// Use full IDs if they fit within reasonable length, otherwise use hash to ensure uniqueness
const maxIDLen = 32 // Maximum length for each ID component
uniqueTenant := tenantID
if len(tenantID) > maxIDLen {
uniqueTenant = tenantID[:maxIDLen] + "_" + hashStr[:8]
}
uniqueSpace := sanitizedSpaceID
if len(sanitizedSpaceID) > maxIDLen {
uniqueSpace = sanitizedSpaceID[:maxIDLen] + "_" + hashStr[8:16]
}
return fmt.Sprintf("skill_%s_%s", uniqueTenant, uniqueSpace)
}
func normalizeSpaceID(spaceID string) string {
spaceID = strings.TrimSpace(spaceID)
if spaceID == "" {
return "default"
}
return spaceID
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func sortResults(results []entity.SkillSearchResult) {
// Simple bubble sort for now, could use sort.Slice
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].Score > results[i].Score {
results[i], results[j] = results[j], results[i]
}
}
}
}
// GenerateID generates a unique ID
func generateID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")[:32]
}
// CalculateContentHash calculates SHA256 hash of skill content
func CalculateContentHash(name, description string, tags []string, content string) string {
h := sha256.New()
h.Write([]byte(name))
h.Write([]byte(description))
for _, tag := range tags {
h.Write([]byte(tag))
}
h.Write([]byte(content))
return hex.EncodeToString(h.Sum(nil))
}
// BuildVectorText builds the text for vector generation
func BuildVectorText(name, description string, tags []string, content string, fieldConfig entity.FieldConfig) string {
var parts []string
if fieldConfig.Name.Enabled && name != "" {
parts = append(parts, name)
}
if fieldConfig.Tags.Enabled && len(tags) > 0 {
parts = append(parts, strings.Join(tags, " "))
}
if fieldConfig.Description.Enabled && description != "" {
parts = append(parts, description)
}
if fieldConfig.Content.Enabled && content != "" {
parts = append(parts, content)
}
return strings.Join(parts, "\n\n")
}
// analyzeQuery analyzes the search query and extracts keywords
// Similar to Python's FulltextQueryer.question method
func (s *SkillSearchService) analyzeQuery(query string) (matchText string, keywords []string) {
if query == "" {
return "", nil
}
// Clean and normalize query
cleaned := s.cleanQueryText(query)
// Extract keywords by tokenizing
keywords = s.tokenize(cleaned)
// Build match text for ES query_string
// Similar to Python's query building logic
matchText = s.buildMatchText(cleaned, keywords)
return matchText, keywords
}
// cleanQueryText cleans and normalizes query text
func (s *SkillSearchService) cleanQueryText(text string) string {
// Convert to lowercase
text = strings.ToLower(text)
// Replace special characters with spaces
// Similar to Python: re.sub(r"[ :|\r\n\t,,。??/`!&^%%()\[\]{}<>]+", " ", text)
specialChars := []string{
":", "|", "\r", "\n", "\t", ",", "", "。", "", "?", "/", "`",
"!", "", "&", "^", "%", "(", ")", "[", "]", "{", "}", "<", ">",
}
for _, char := range specialChars {
text = strings.ReplaceAll(text, char, " ")
}
// Remove extra spaces
fields := strings.Fields(text)
return strings.Join(fields, " ")
}
// tokenize splits text into tokens/keywords
func (s *SkillSearchService) tokenize(text string) []string {
if text == "" {
return nil
}
// Simple tokenization by splitting on whitespace
// For Chinese text, this keeps characters together
fields := strings.Fields(text)
// Remove duplicates and empty strings
seen := make(map[string]bool)
var keywords []string
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" || seen[field] {
continue
}
seen[field] = true
keywords = append(keywords, field)
// For longer tokens, also add sub-tokens (for Chinese fine-grained tokenization)
if len([]rune(field)) > 2 {
runes := []rune(field)
for i := 0; i < len(runes)-1; i++ {
bigram := string(runes[i : i+2])
if !seen[bigram] {
seen[bigram] = true
keywords = append(keywords, bigram)
}
}
}
}
// Limit keywords to avoid too many
if len(keywords) > 32 {
keywords = keywords[:32]
}
return keywords
}
// buildMatchText builds the match text for ES query_string
// Similar to Python's FulltextQueryer.question output
func (s *SkillSearchService) buildMatchText(originalText string, keywords []string) string {
if len(keywords) == 0 {
return originalText
}
// Build boosted query for keywords
// Similar to Python: "(keyword1^weight1 keyword2^weight2 ...)"
var parts []string
// Add the original text with high boost
if originalText != "" {
parts = append(parts, fmt.Sprintf("(\"%s\")^2.0", originalText))
}
// Add individual keywords with decreasing weights
for i, keyword := range keywords {
if keyword == "" {
continue
}
// First few keywords get higher weight
weight := 1.0
if i < 3 {
weight = 1.5
} else if i < 6 {
weight = 1.2
}
// Escape special characters in keyword
escaped := s.escapeQueryString(keyword)
parts = append(parts, fmt.Sprintf("(%s)^%.1f", escaped, weight))
}
// Join with OR operator
return strings.Join(parts, " OR ")
}
// escapeQueryString escapes special characters for ES query_string
func (s *SkillSearchService) escapeQueryString(text string) string {
specialChars := []string{"\\", "+", "-", "=", "&&", "||", ">", "<", "!", "(", ")", "{", "}", "[", "]", "^", "\"", "~", "*", "?", ":", "/"}
result := text
for _, char := range specialChars {
result = strings.ReplaceAll(result, char, "\\"+char)
}
return result
}
// SkillInfo represents skill information for indexing
type SkillInfo struct {
ID string `json:"id"`
FolderID string `json:"folder_id"` // File system folder ID for retrieving files
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
Content string `json:"content"`
Version string `json:"version"` // Skill version (e.g., "1.0.0")
}
// IndexSkillsRequest represents the request to index skills
type IndexSkillsRequest struct {
TenantID string `json:"tenant_id" binding:"required"`
Skills []SkillInfo `json:"skills" binding:"required"`
}
// ReindexRequest represents the request to reindex all skills
type ReindexRequest struct {
TenantID string `json:"tenant_id" binding:"required"`
SpaceID string `json:"space_id" binding:"required"`
EmbdID string `json:"embd_id"` // Optional, will use config's embd_id if empty
}
// buildOrderBy builds the order_by string for sorting
// For empty queries (list all), default sort is by update_time desc
// For search queries, default sort is by relevance (score)
func (s *SkillSearchService) buildOrderBy(sortBy, sortOrder string, isEmptyQuery bool) string {
// Normalize sort_by
if sortBy == "" {
if isEmptyQuery {
sortBy = "update_time"
} else {
return "" // Use default relevance sorting for search
}
}
// Normalize sort_order
order := strings.ToLower(sortOrder)
if order != "asc" && order != "desc" {
// Default order: desc for time fields, asc for name
if sortBy == "name" {
order = "asc"
} else {
order = "desc"
}
}
// Map frontend field names to backend field names
fieldMapping := map[string]string{
"name": "name",
"update_time": "update_time",
"create_time": "create_time",
"updateTime": "update_time",
"createTime": "create_time",
"relevance": "", // Empty means sort by score/relevance
"updated_at": "update_time",
"created_at": "create_time",
}
backendField, ok := fieldMapping[sortBy]
if !ok {
backendField = sortBy
}
if backendField == "" {
return "" // Relevance sorting
}
return backendField + " " + order
}
// buildOrderByExpr converts sort parameters to types.OrderByExpr for the unified engine interface
func buildOrderByExpr(sortBy, sortOrder string, isEmptyQuery bool) *types.OrderByExpr {
// Normalize sort_by
if sortBy == "" {
if isEmptyQuery {
sortBy = "update_time"
} else {
return nil // Use default relevance sorting for search
}
}
// Normalize sort_order
order := strings.ToLower(sortOrder)
if order != "asc" && order != "desc" {
if sortBy == "name" {
order = "asc"
} else {
order = "desc"
}
}
// Map frontend field names to backend field names
fieldMapping := map[string]string{
"name": "name",
"update_time": "update_time",
"create_time": "create_time",
"updateTime": "update_time",
"createTime": "create_time",
"relevance": "",
"updated_at": "update_time",
"created_at": "create_time",
}
backendField, ok := fieldMapping[sortBy]
if !ok {
backendField = sortBy
}
if backendField == "" {
return nil // Relevance sorting
}
orderType := types.SortAsc
if order == "desc" {
orderType = types.SortDesc
}
return &types.OrderByExpr{
Fields: []types.OrderByField{
{Field: backendField, Type: orderType},
},
}
}