mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-10 13:45:44 +08:00
### What problem does this PR solve? Refine handling of POST /api/v1/datasets/search in GO ### Type of change - [x] Refactoring
1080 lines
34 KiB
Go
1080 lines
34 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 nlp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/dao"
|
|
"ragflow/internal/engine"
|
|
"ragflow/internal/engine/types"
|
|
"ragflow/internal/entity/models"
|
|
"sort"
|
|
"strings"
|
|
|
|
"ragflow/internal/tokenizer"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// RetrievalService provides retrieval search functionality
|
|
type RetrievalService struct {
|
|
docEngine engine.DocEngine
|
|
documentDAO *dao.DocumentDAO
|
|
}
|
|
|
|
// NewRetrievalService creates a new RetrievalService with the given doc engine
|
|
func NewRetrievalService(docEngine engine.DocEngine, documentDAO *dao.DocumentDAO) *RetrievalService {
|
|
return &RetrievalService{docEngine: docEngine, documentDAO: documentDAO}
|
|
}
|
|
|
|
// RetrievalRequest request for retrieval search
|
|
type RetrievalRequest struct {
|
|
Question string
|
|
TenantIDs []string
|
|
KbIDs []string
|
|
DocIDs []string
|
|
Page int
|
|
PageSize int
|
|
Top *int
|
|
SimilarityThreshold *float64
|
|
VectorSimilarityWeight *float64
|
|
RankFeature *map[string]float64
|
|
RerankModel *models.RerankModel
|
|
EmbeddingModel *models.EmbeddingModel
|
|
Aggs *bool
|
|
Highlight *bool
|
|
}
|
|
|
|
// RetrievalResult result from retrieval search
|
|
type RetrievalResult struct {
|
|
Chunks []map[string]interface{}
|
|
DocAggs []map[string]interface{} // Aggregated document counts, sorted by count desc
|
|
Total int64 // Post-pagination chunk count (matches Python's len(ranks["chunks"]))
|
|
}
|
|
|
|
// Retrieval performs hybrid search + reranking + pagination
|
|
// - Calculate rerank limit and call Search() to fetch rerankLimit candidates for reranking
|
|
// - Perform reranking via Rerank()
|
|
// - Sort indices by score descending and filter by threshold
|
|
// - Calculate pagination to extract actual page returned from reranked results
|
|
// - Build chunks
|
|
// - Build document aggregation if specified
|
|
func (s *RetrievalService) Retrieval(ctx context.Context, req *RetrievalRequest) (*RetrievalResult, error) {
|
|
common.Info("Retrieval START", zap.String("question", req.Question), zap.Int("page", req.Page), zap.Int("pageSize", req.PageSize))
|
|
if req.Question == "" {
|
|
return &RetrievalResult{Chunks: []map[string]interface{}{}, DocAggs: []map[string]interface{}{}, Total: 0}, nil
|
|
}
|
|
|
|
// Apply default values
|
|
if req.Top == nil {
|
|
req.Top = func() *int { v := 1024; return &v }()
|
|
}
|
|
if req.SimilarityThreshold == nil {
|
|
req.SimilarityThreshold = func() *float64 { v := 0.2; return &v }()
|
|
}
|
|
if req.VectorSimilarityWeight == nil {
|
|
req.VectorSimilarityWeight = func() *float64 { v := 0.3; return &v }()
|
|
}
|
|
if req.RankFeature == nil {
|
|
req.RankFeature = &map[string]float64{"pagerank_fea": 10.0}
|
|
}
|
|
if req.Aggs == nil {
|
|
req.Aggs = func() *bool { v := true; return &v }()
|
|
}
|
|
|
|
if req.Page <= 0 {
|
|
req.Page = 1
|
|
}
|
|
if req.PageSize <= 0 {
|
|
req.PageSize = 1
|
|
}
|
|
|
|
// Calculate rerank limit to ensure we get enough results for proper pagination
|
|
pageSize := req.PageSize
|
|
rerankLimit := pageSize
|
|
if pageSize > 1 {
|
|
rerankLimit = int(math.Ceil(64.0/float64(pageSize))) * pageSize
|
|
} else {
|
|
rerankLimit = 1
|
|
}
|
|
if rerankLimit < 30 {
|
|
rerankLimit = 30
|
|
}
|
|
// Cap rerank limit when external rerank model is used
|
|
if req.RerankModel != nil && *req.Top > 0 {
|
|
if rerankLimit > *req.Top {
|
|
rerankLimit = *req.Top
|
|
}
|
|
if rerankLimit > 64 {
|
|
rerankLimit = 64
|
|
}
|
|
}
|
|
|
|
page := req.Page
|
|
globalOffset := (page - 1) * pageSize
|
|
searchPage := globalOffset/rerankLimit + 1
|
|
common.Debug("Retrieval rerank params", zap.Int("page", req.Page), zap.Int("pageSize", pageSize),
|
|
zap.Int("searchPage", searchPage), zap.Int("rerankLimit", rerankLimit), zap.Int("globalOffset", globalOffset))
|
|
|
|
// Execute search via Search()
|
|
searchReq := &RetrievalSearchRequest{
|
|
TenantIDs: req.TenantIDs,
|
|
Question: req.Question,
|
|
KbIDs: req.KbIDs,
|
|
DocIDs: req.DocIDs,
|
|
Page: searchPage,
|
|
PageSize: rerankLimit,
|
|
Top: *req.Top,
|
|
RankFeature: *req.RankFeature,
|
|
EmbeddingModel: req.EmbeddingModel,
|
|
}
|
|
searchResult, err := s.Search(ctx, searchReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Search failed: %w", err)
|
|
}
|
|
|
|
// Prune deleted chunks
|
|
searchResult, err = s.PruneDeletedChunks(searchResult)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("PruneDeletedChunks failed: %w", err)
|
|
}
|
|
if searchResult.Total == 0 {
|
|
return &RetrievalResult{Chunks: []map[string]interface{}{}, DocAggs: []map[string]interface{}{}, Total: 0}, nil
|
|
}
|
|
|
|
// sim = tkWeight*tsim + vtWeight*vsim
|
|
vtWeightOrig := *req.VectorSimilarityWeight
|
|
tkWeightOrig := 1.0 - vtWeightOrig
|
|
tkWeight := tkWeightOrig
|
|
vtWeight := vtWeightOrig
|
|
qb := GetQueryBuilder()
|
|
useInfinity := engine.GetEngineType() != engine.EngineElasticsearch
|
|
useOceanBase := false // TODO: add OceanBase detection when supported
|
|
|
|
// For ES path: call GetScores() for second-pass KNN to get clean cosine similarity
|
|
// For Infinity path: use _score directly (scores already normalized during fusion)
|
|
// For OceanBase path: extract vectors and compute locally
|
|
var sim []float64
|
|
var term_similarity []float64
|
|
var vector_similarity []float64
|
|
|
|
if req.RerankModel != nil && searchResult.Total > 0 {
|
|
// External rerank model path - use RerankByModel
|
|
sim, term_similarity, vector_similarity = RerankByModel(
|
|
req.RerankModel,
|
|
searchResult.Chunks,
|
|
searchResult.IDs,
|
|
searchResult.Field,
|
|
req.Question,
|
|
tkWeight,
|
|
vtWeight,
|
|
"content_ltks",
|
|
qb,
|
|
*req.RankFeature,
|
|
)
|
|
} else if useInfinity {
|
|
// Infinity: scores already normalized before fusion, just extract _score
|
|
sim = make([]float64, len(searchResult.IDs))
|
|
for i, id := range searchResult.IDs {
|
|
if chunk, ok := searchResult.Field[id]; ok {
|
|
if score, ok := chunk["_score"].(float64); ok {
|
|
sim[i] = score
|
|
} else if score, ok := chunk["SCORE"].(float64); ok {
|
|
sim[i] = score
|
|
} else if score, ok := chunk["SIMILARITY"].(float64); ok {
|
|
sim[i] = score
|
|
} else {
|
|
sim[i] = 0.0
|
|
}
|
|
} else {
|
|
sim[i] = 0.0
|
|
}
|
|
}
|
|
term_similarity = sim
|
|
vector_similarity = sim
|
|
} else if useOceanBase {
|
|
// OceanBase: extract vectors and compute locally (not implemented)
|
|
sim = make([]float64, len(searchResult.IDs))
|
|
for i := range searchResult.IDs {
|
|
sim[i] = 0.0
|
|
}
|
|
term_similarity = sim
|
|
vector_similarity = sim
|
|
} else {
|
|
// ES PATH: Two-pass KNN approach for clean cosine similarity scores
|
|
//
|
|
// Python's equivalent flow (rag/nlp/search.py L656-669):
|
|
// 1. First search returns text+vector matched chunks (hybrid BM25 + KNN fusion)
|
|
// 2. _knn_scores: second KNN-only query filtered by those chunk IDs
|
|
// - ES computes cosine similarity between query vector and stored vectors
|
|
// - Vectors stay in ES index (not shipped to application)
|
|
// - Returns raw KNN result with _id -> _score mappings
|
|
// 3. get_scores: extracts doc_id -> score from the KNN result
|
|
// 4. rerank_with_knn: combines token similarity + vector similarity + rank features
|
|
//
|
|
// Go implementation mirrors this exactly:
|
|
// KNNScores() -> performs second KNN query (ES-specific, on DocEngine interface)
|
|
// GetScores() -> extracts doc_id -> score from result (matches Python's get_scores)
|
|
// RerankWithKNN() -> combines tksim + vtsim + rank_features (matches rerank_with_knn)
|
|
//
|
|
// Why two passes?
|
|
// - First search uses fusion (BM25 * vector_similarity_weight + KNN * (1-vector_similarity_weight))
|
|
// - The fusion score is not a clean cosine similarity - it's a weighted combination
|
|
// - Second KNN-only query gives us the pure cosine similarity for reranking
|
|
// - This keeps vectors in ES (no need to extract them) while getting clean scores
|
|
|
|
// PASS 1: Second KNN query to get clean cosine similarities
|
|
// KNNScores() performs the ES-specific KNN search and returns raw result
|
|
knnResult, err := s.docEngine.KNNScores(ctx, searchResult.Chunks, searchResult.QueryVector, len(searchResult.IDs))
|
|
if err != nil {
|
|
common.Warn("KNNScores failed for ES, falling back to local computation", zap.Error(err))
|
|
// Fallback: RerankStandard computes vector similarity locally (requires shipping vectors)
|
|
sim, term_similarity, vector_similarity = RerankStandard(
|
|
searchResult.Chunks,
|
|
nil, // keywords computed internally
|
|
searchResult.QueryVector,
|
|
req.Question,
|
|
tkWeight,
|
|
vtWeight,
|
|
"content_ltks",
|
|
qb,
|
|
*req.RankFeature,
|
|
)
|
|
} else {
|
|
// PASS 2: Extract scores from KNN result
|
|
// GetScores() mirrors Python's get_scores() - maps doc_id -> _score
|
|
knnScores := s.docEngine.GetScores(knnResult)
|
|
|
|
// RERANK: Combine token + vector + rank feature similarities
|
|
// Matches Python's rerank_with_knn(): sim = tkweight * tksim + vtweight * vtsim + rank_fea
|
|
sim, term_similarity, vector_similarity = RerankWithKNN(
|
|
searchResult.Chunks,
|
|
searchResult.IDs,
|
|
searchResult.Field,
|
|
knnScores,
|
|
req.Question,
|
|
tkWeight,
|
|
vtWeight,
|
|
"content_ltks",
|
|
qb,
|
|
*req.RankFeature,
|
|
)
|
|
}
|
|
}
|
|
if len(sim) == 0 {
|
|
return &RetrievalResult{Chunks: []map[string]interface{}{}, DocAggs: []map[string]interface{}{}, Total: 0}, nil
|
|
}
|
|
|
|
// Sort indices (positions into search results) by score descending
|
|
// After sorting by score descending, we process chunks in relevance order
|
|
type idxScore struct {
|
|
idx int
|
|
score float64
|
|
}
|
|
idxScores := make([]idxScore, 0, len(sim))
|
|
for i, s := range sim {
|
|
idxScores = append(idxScores, idxScore{idx: i, score: s})
|
|
}
|
|
// Use SliceStable for deterministic ordering when scores are tied
|
|
sort.SliceStable(idxScores, func(i, j int) bool {
|
|
return idxScores[i].score > idxScores[j].score
|
|
})
|
|
|
|
// When vector_similarity_weight is 0, similarity_threshold is not meaningful for term-only scores
|
|
postThreshold := *req.SimilarityThreshold
|
|
if vtWeight <= 0 {
|
|
postThreshold = 0.0
|
|
}
|
|
|
|
// Get valid indices where score >= postThreshold
|
|
validIdx := make([]int, 0)
|
|
for _, is := range idxScores {
|
|
if is.score >= postThreshold {
|
|
validIdx = append(validIdx, is.idx)
|
|
}
|
|
}
|
|
if len(validIdx) == 0 {
|
|
return &RetrievalResult{Chunks: []map[string]interface{}{}, DocAggs: []map[string]interface{}{}, Total: 0}, nil
|
|
}
|
|
|
|
// Calculate pagination
|
|
// begin and end define which of validIdx to return as the page
|
|
begin := globalOffset % rerankLimit
|
|
end := begin + pageSize
|
|
|
|
// Get page indices
|
|
var pageIdx []int
|
|
if begin < len(validIdx) {
|
|
if end > len(validIdx) {
|
|
end = len(validIdx)
|
|
}
|
|
pageIdx = validIdx[begin:end]
|
|
}
|
|
common.Info("Pagination result info", zap.Int("totalValid", len(validIdx)), zap.Int("begin", begin),
|
|
zap.Int("end", end), zap.Int("chunkCount", len(pageIdx)), zap.Float64("postThreshold", postThreshold))
|
|
|
|
// Build chunks for pageIdx, transforms raw search results into the API response format
|
|
var filteredChunks []map[string]interface{}
|
|
dim := 0
|
|
if searchResult.QueryVector != nil {
|
|
dim = len(searchResult.QueryVector)
|
|
}
|
|
zeroVector := make([]float64, dim)
|
|
for j := 0; j < dim; j++ {
|
|
zeroVector[j] = 0.0
|
|
}
|
|
|
|
for _, i := range pageIdx {
|
|
if i < 0 || i >= len(searchResult.IDs) {
|
|
continue
|
|
}
|
|
chunkID := searchResult.IDs[i]
|
|
chunk, exists := searchResult.Field[chunkID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
|
|
resultChunk := make(map[string]interface{})
|
|
resultChunk["chunk_id"] = chunkID
|
|
if v, ok := chunk["content_ltks"]; ok {
|
|
resultChunk["content_ltks"] = v
|
|
}
|
|
if v, ok := chunk["content_with_weight"]; ok {
|
|
resultChunk["content_with_weight"] = v
|
|
}
|
|
if v, ok := chunk["doc_id"]; ok {
|
|
resultChunk["doc_id"] = v
|
|
}
|
|
if v, ok := chunk["docnm_kwd"]; ok {
|
|
resultChunk["docnm_kwd"] = v
|
|
}
|
|
if v, ok := chunk["kb_id"]; ok {
|
|
resultChunk["kb_id"] = v
|
|
}
|
|
if v, ok := chunk["important_kwd"]; ok {
|
|
resultChunk["important_kwd"] = v
|
|
}
|
|
if v, ok := chunk["tag_kwd"]; ok {
|
|
resultChunk["tag_kwd"] = v
|
|
}
|
|
if v, ok := chunk["img_id"]; ok {
|
|
resultChunk["image_id"] = v
|
|
} else {
|
|
resultChunk["image_id"] = ""
|
|
}
|
|
if v, ok := chunk["position_int"]; ok && v != nil {
|
|
resultChunk["positions"] = v
|
|
} else {
|
|
resultChunk["positions"] = []interface{}{}
|
|
}
|
|
if v, ok := chunk["doc_type_kwd"]; ok && v != nil {
|
|
if s, ok := v.(string); ok {
|
|
if s == "" {
|
|
// Infinity's whitespace-# analyzer returns empty string as [] in Python SDK
|
|
// but as "" in Go SDK. Both Infinity and Elasticsearch paths normalize
|
|
// to None on the Python side (the test converts [] to None), so use nil
|
|
// here for parity instead of []interface{}{}.
|
|
resultChunk["doc_type_kwd"] = nil
|
|
} else {
|
|
resultChunk["doc_type_kwd"] = s
|
|
}
|
|
} else if sliceVal, ok := v.([]interface{}); ok {
|
|
if len(sliceVal) == 0 {
|
|
resultChunk["doc_type_kwd"] = nil
|
|
} else {
|
|
resultChunk["doc_type_kwd"] = sliceVal
|
|
}
|
|
} else {
|
|
resultChunk["doc_type_kwd"] = nil
|
|
}
|
|
} else {
|
|
resultChunk["doc_type_kwd"] = nil
|
|
}
|
|
// row_id: row identifier (for structured data like tables)
|
|
if v, ok := chunk["row_id()"]; ok {
|
|
resultChunk["row_id"] = v
|
|
}
|
|
resultChunk["similarity"] = sim[i]
|
|
resultChunk["term_similarity"] = term_similarity[i]
|
|
resultChunk["vector_similarity"] = vector_similarity[i]
|
|
|
|
// Always set these fields even if empty, to match Python response format
|
|
if v, ok := chunk["important_kwd"]; ok {
|
|
resultChunk["important_kwd"] = v
|
|
} else {
|
|
resultChunk["important_kwd"] = []string{}
|
|
}
|
|
if v, ok := chunk["mom_id"]; ok {
|
|
resultChunk["mom_id"] = v
|
|
} else {
|
|
resultChunk["mom_id"] = ""
|
|
}
|
|
if v, ok := chunk["row_id()"]; ok {
|
|
resultChunk["row_id"] = v
|
|
} else {
|
|
resultChunk["row_id"] = nil
|
|
}
|
|
if v, ok := chunk["tag_kwd"]; ok {
|
|
resultChunk["tag_kwd"] = v
|
|
} else {
|
|
resultChunk["tag_kwd"] = []string{}
|
|
}
|
|
|
|
vectorColumn := fmt.Sprintf("q_%d_vec", dim)
|
|
if v, ok := chunk[vectorColumn]; ok {
|
|
resultChunk["vector"] = v
|
|
} else {
|
|
resultChunk["vector"] = zeroVector
|
|
}
|
|
|
|
highlightEnabled := false
|
|
if req.Highlight != nil && *req.Highlight {
|
|
highlightEnabled = true
|
|
}
|
|
if highlightEnabled && searchResult.Highlight != nil {
|
|
if highlightText, ok := searchResult.Highlight[chunkID]; ok {
|
|
resultChunk["highlight"] = RemoveRedundantSpaces(highlightText)
|
|
} else if contentWithWeight, ok := chunk["content_with_weight"].(string); ok {
|
|
resultChunk["highlight"] = RemoveRedundantSpaces(contentWithWeight)
|
|
}
|
|
}
|
|
filteredChunks = append(filteredChunks, resultChunk)
|
|
}
|
|
|
|
// Build document aggregation, aggregates document-level statistics across all valid chunks
|
|
// This is useful for showing users which documents are most relevant to their query.
|
|
var docAggs []map[string]interface{}
|
|
if req.Aggs != nil && *req.Aggs {
|
|
docAggsMap := make(map[string]struct {
|
|
docID string
|
|
count int
|
|
})
|
|
for _, i := range validIdx {
|
|
if i < 0 || i >= len(searchResult.IDs) {
|
|
continue
|
|
}
|
|
chunkID := searchResult.IDs[i]
|
|
chunk, exists := searchResult.Field[chunkID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
docName := ""
|
|
docID := ""
|
|
if v, ok := chunk["docnm_kwd"].(string); ok {
|
|
docName = v
|
|
}
|
|
if v, ok := chunk["doc_id"].(string); ok {
|
|
docID = v
|
|
}
|
|
if entry, exists := docAggsMap[docName]; exists {
|
|
entry.count++
|
|
docAggsMap[docName] = entry
|
|
} else {
|
|
docAggsMap[docName] = struct {
|
|
docID string
|
|
count int
|
|
}{docID: docID, count: 1}
|
|
}
|
|
}
|
|
|
|
// Sort by count descending
|
|
type docAggEntry struct {
|
|
docName string
|
|
docID string
|
|
count int
|
|
}
|
|
docAggsList := make([]docAggEntry, 0, len(docAggsMap))
|
|
for docName, entry := range docAggsMap {
|
|
docAggsList = append(docAggsList, docAggEntry{docName: docName, docID: entry.docID, count: entry.count})
|
|
}
|
|
sort.Slice(docAggsList, func(i, j int) bool {
|
|
return docAggsList[i].count > docAggsList[j].count
|
|
})
|
|
|
|
docAggs = make([]map[string]interface{}, 0, len(docAggsList))
|
|
for _, entry := range docAggsList {
|
|
docAggs = append(docAggs, map[string]interface{}{
|
|
"doc_name": entry.docName,
|
|
"doc_id": entry.docID,
|
|
"count": entry.count,
|
|
})
|
|
}
|
|
} else {
|
|
docAggs = []map[string]interface{}{}
|
|
}
|
|
|
|
return &RetrievalResult{
|
|
Chunks: filteredChunks,
|
|
DocAggs: docAggs,
|
|
Total: int64(len(filteredChunks)),
|
|
}, nil
|
|
}
|
|
|
|
// RetrievalSearchRequest is the request struct for RetrievalService.Search()
|
|
type RetrievalSearchRequest struct {
|
|
Question string
|
|
TenantIDs []string
|
|
KbIDs []string
|
|
DocIDs []string
|
|
Top int
|
|
Page int
|
|
PageSize int
|
|
Sort bool
|
|
Highlight *bool
|
|
SimilarityThreshold float64
|
|
RankFeature map[string]float64
|
|
Filter map[string]interface{}
|
|
EmbeddingModel *models.EmbeddingModel
|
|
}
|
|
|
|
type RetrievalSearchResult struct {
|
|
Chunks []map[string]interface{} // Search results
|
|
Total int64 // Total number of matches
|
|
QueryVector []float64 // Query vector (for hybrid search, used in reranking)
|
|
Highlight map[string]string // Highlighted snippets (chunk_id -> highlighted text)
|
|
Field map[string]map[string]interface{} // ID -> chunk mapping
|
|
IDs []string // Ordered list of chunk IDs
|
|
Keywords []string // Keywords from query
|
|
Aggregation []map[string]interface{} // Doc aggregation by field
|
|
Options map[string]interface{} // Engine-specific options (e.g., total from get_total)
|
|
IndexNames []string // Index names for second-pass queries (e.g., KNN scores)
|
|
}
|
|
|
|
// Search performs search based on question and EmbeddingModel:
|
|
// - Empty question: list data matching filters, optionally sorted
|
|
// - Non-empty question, no EmbeddingModel: fulltext search only
|
|
// - Non-empty question, with EmbeddingModel: hybrid search (fulltext + vector + fusion)
|
|
//
|
|
// Hybrid search path retries with lower thresholds if no results found.
|
|
func (s *RetrievalService) Search(ctx context.Context, req *RetrievalSearchRequest) (*RetrievalSearchResult, error) {
|
|
if req.Highlight == nil {
|
|
req.Highlight = func() *bool { v := false; return &v }()
|
|
}
|
|
filters := req.GetFilters()
|
|
if _, ok := filters["available_int"]; !ok {
|
|
filters["available_int"] = 1
|
|
}
|
|
pg := req.Page - 1
|
|
if pg < 0 {
|
|
pg = 0
|
|
}
|
|
topk := req.Top
|
|
if topk <= 0 {
|
|
topk = 1024
|
|
}
|
|
pageSize := req.PageSize
|
|
if pageSize <= 0 {
|
|
pageSize = topk
|
|
}
|
|
limit := pageSize
|
|
|
|
// Build Source field list
|
|
src := []string{
|
|
"docnm_kwd", "content_ltks", "kb_id", "img_id", "title_tks", "important_kwd", "position_int",
|
|
"doc_id", "chunk_order_int", "page_num_int", "top_int", "create_timestamp_flt", "knowledge_graph_kwd",
|
|
"question_kwd", "question_tks", "doc_type_kwd",
|
|
"available_int", "content_with_weight", "mom_id", "pagerank_fea", "tag_feas", "row_id()",
|
|
"_score",
|
|
}
|
|
|
|
kwds := make(map[string]struct{})
|
|
|
|
// Build base engine request with common fields
|
|
// Note: RankFeature is NOT set here, it's set per-call where needed
|
|
searchRequest := &types.SearchRequest{
|
|
IndexNames: buildIndexNames(req.TenantIDs),
|
|
KbIDs: req.KbIDs,
|
|
Offset: pg * pageSize,
|
|
Limit: limit,
|
|
Filter: filters,
|
|
SelectFields: src,
|
|
}
|
|
|
|
// engineResult holds the result from docEngine.Search() (types.SearchResult)
|
|
// queryVector tracks the query vector for reranking
|
|
var engineResult *types.SearchResult
|
|
var queryVector []float64
|
|
var err error
|
|
|
|
if req.Question == "" {
|
|
// Empty question
|
|
if req.Sort {
|
|
searchRequest.OrderBy = &types.OrderByExpr{}
|
|
searchRequest.OrderBy.Asc("chunk_order_int").Asc("page_num_int").Asc("top_int").Desc("create_timestamp_flt")
|
|
}
|
|
searchRequest.MatchExprs = []interface{}{}
|
|
engineResult, err = s.docEngine.Search(ctx, searchRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Search failed: %w", err)
|
|
}
|
|
} else {
|
|
// Non-empty question
|
|
|
|
// Compute keywords via QueryBuilder
|
|
matchText, keywords := GetQueryBuilder().Question(req.Question, "", 0.3)
|
|
for _, k := range keywords {
|
|
kwds[k] = struct{}{}
|
|
}
|
|
|
|
// Check if EmbeddingModel is available
|
|
if req.EmbeddingModel == nil {
|
|
// Keyword-only search
|
|
searchRequestWithRank := *searchRequest
|
|
searchRequestWithRank.MatchExprs = []interface{}{matchText}
|
|
searchRequestWithRank.RankFeature = req.RankFeature
|
|
|
|
engineResult, err = s.docEngine.Search(ctx, &searchRequestWithRank)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Search failed: %w", err)
|
|
}
|
|
queryVector = nil
|
|
} else {
|
|
// Compute question vector via GetVector
|
|
similarityForGetVector := req.SimilarityThreshold
|
|
if similarityForGetVector <= 0 {
|
|
similarityForGetVector = 0.1
|
|
}
|
|
matchDense, err := s.GetVector(req.Question, req.EmbeddingModel, topk, similarityForGetVector)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetVector failed: %w", err)
|
|
}
|
|
|
|
// Execute search with fusion
|
|
fusionExpr := &types.FusionExpr{
|
|
Method: "weighted_sum",
|
|
TopN: topk,
|
|
FusionParams: map[string]interface{}{"weights": "0.05,0.95"},
|
|
}
|
|
|
|
// Build source with vector column for ES
|
|
searchSrc := make([]string, len(searchRequest.SelectFields))
|
|
copy(searchSrc, searchRequest.SelectFields)
|
|
if engine.GetEngineType() == engine.EngineElasticsearch {
|
|
searchSrc = append(searchSrc, matchDense.VectorColumnName)
|
|
}
|
|
|
|
searchRequest.SelectFields = searchSrc
|
|
searchRequest.MatchExprs = []interface{}{matchText, matchDense, fusionExpr}
|
|
searchRequest.RankFeature = req.RankFeature
|
|
|
|
engineResult, err = s.docEngine.Search(ctx, searchRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Search failed: %w", err)
|
|
}
|
|
// If result is empty, retry with relaxed conditions
|
|
if engineResult.Total == 0 {
|
|
_, hasDocIDFilter := filters["doc_id"]
|
|
if hasDocIDFilter {
|
|
// When a doc_id filter is present (e.g. from metadata filter like era=960)
|
|
// and the hybrid search returns no results, fall back to a filter-only
|
|
// search (no text match, no vector match). This ensures that when a
|
|
// metadata filter restricts the search to a specific set of documents
|
|
// that happen to have no relevant content for the query, we still
|
|
// return those documents' chunks (ordered by the request's sort/order).
|
|
//
|
|
// Example: searching "打虎" with metadata filter era≠960 limits the
|
|
// search to Three Kingdoms documents (era=220). Since "打虎" only
|
|
// appears in Water Margin (era=960), the hybrid search returns 0
|
|
// results. This fallback returns all chunks from Three Kingdoms
|
|
// documents instead of returning an empty result.
|
|
searchRequest.SelectFields = src
|
|
searchRequest.MatchExprs = []interface{}{}
|
|
searchRequest.RankFeature = nil
|
|
|
|
engineResult, err = s.docEngine.Search(ctx, searchRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Search retry failed: %w", err)
|
|
}
|
|
} else {
|
|
// No doc_id filter — retry with lower min_match (0.1 vs default 0.3)
|
|
// and lower vector similarity threshold (0.17 vs default 0.1-0.2).
|
|
// This provides a second chance for queries that were too strict
|
|
// on the first attempt.
|
|
matchText, _ := GetQueryBuilder().Question(req.Question, "qa", 0.1)
|
|
matchDense.ExtraOptions["similarity"] = 0.17
|
|
searchRequest.MatchExprs = []interface{}{matchText, matchDense, fusionExpr}
|
|
searchRequest.RankFeature = req.RankFeature
|
|
|
|
engineResult, err = s.docEngine.Search(ctx, searchRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Search retry failed: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
queryVector = matchDense.EmbeddingData
|
|
}
|
|
|
|
// Build kwds from keywords with fine-grained tokenization
|
|
for _, k := range keywords {
|
|
kwds[k] = struct{}{}
|
|
fgToken, _ := tokenizer.FineGrainedTokenize(k)
|
|
for _, kk := range strings.Fields(fgToken) {
|
|
if len(kk) < 2 {
|
|
continue
|
|
}
|
|
if _, ok := kwds[kk]; ok {
|
|
continue
|
|
}
|
|
kwds[kk] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
searchResult := engineResult
|
|
ids := s.docEngine.GetChunkIDs(searchResult.Chunks)
|
|
common.Info("GetChunkIDs result", zap.Int("count", len(ids)), zap.Strings("ids", ids))
|
|
|
|
// Build Keywords list from kwds set
|
|
keywordsList := make([]string, 0, len(kwds))
|
|
for k := range kwds {
|
|
keywordsList = append(keywordsList, k)
|
|
}
|
|
|
|
fieldMap := s.docEngine.GetFields(searchResult.Chunks, src)
|
|
common.Info("GetFields result", zap.Int("count", len(fieldMap)), zap.Strings("keys", func() []string {
|
|
keys := make([]string, 0, len(fieldMap))
|
|
for k := range fieldMap {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}()), zap.Strings("ids_from_GetDocIDs", ids))
|
|
|
|
// Build Aggregation
|
|
aggregation := s.docEngine.GetAggregation(searchResult.Chunks, "docnm_kwd")
|
|
|
|
// Build Highlight using GetHighlight
|
|
var highlight map[string]string
|
|
if len(keywordsList) > 0 {
|
|
highlight = s.docEngine.GetHighlight(searchResult.Chunks, keywordsList, "content_with_weight")
|
|
}
|
|
|
|
return &RetrievalSearchResult{
|
|
Chunks: searchResult.Chunks,
|
|
Total: searchResult.Total,
|
|
QueryVector: queryVector,
|
|
Highlight: highlight,
|
|
Field: fieldMap,
|
|
IDs: ids,
|
|
Keywords: keywordsList,
|
|
Aggregation: aggregation,
|
|
IndexNames: searchRequest.IndexNames,
|
|
}, nil
|
|
}
|
|
|
|
// GetVector computes query vector and returns MatchDenseExpr for hybrid search
|
|
func (s *RetrievalService) GetVector(txt string, embModel *models.EmbeddingModel, topk int, similarity float64) (*types.MatchDenseExpr, error) {
|
|
embeddingConfig := &models.EmbeddingConfig{
|
|
Dimension: 0,
|
|
}
|
|
embeddings, err := embModel.ModelDriver.Embed(embModel.ModelName, []string{txt}, embModel.APIConfig, embeddingConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vector := embeddings[0].Embedding
|
|
vectorSize := len(vector)
|
|
vectorColumnName := fmt.Sprintf("q_%d_vec", vectorSize)
|
|
|
|
return &types.MatchDenseExpr{
|
|
VectorColumnName: vectorColumnName,
|
|
EmbeddingData: vector,
|
|
EmbeddingDataType: "float",
|
|
DistanceType: "cosine",
|
|
TopN: topk,
|
|
ExtraOptions: map[string]interface{}{"similarity": similarity},
|
|
}, nil
|
|
}
|
|
|
|
// GetFilters builds metadata filter map from RetrievalSearchRequest
|
|
func (r *RetrievalSearchRequest) GetFilters() map[string]interface{} {
|
|
filters := make(map[string]interface{})
|
|
|
|
if len(r.KbIDs) > 0 {
|
|
filters["kb_id"] = r.KbIDs
|
|
}
|
|
if len(r.DocIDs) > 0 {
|
|
filters["doc_id"] = r.DocIDs
|
|
}
|
|
for _, key := range []string{"knowledge_graph_kwd", "available_int", "entity_kwd", "from_entity_kwd", "to_entity_kwd", "removed_kwd"} {
|
|
if val, ok := r.Filter[key]; ok && val != nil {
|
|
filters[key] = val
|
|
}
|
|
}
|
|
for key, val := range r.Filter {
|
|
if _, exists := filters[key]; !exists && val != nil {
|
|
filters[key] = val
|
|
}
|
|
}
|
|
return filters
|
|
}
|
|
|
|
// RetrievalByChildren aggregates child chunks into parent chunks
|
|
func RetrievalByChildren(chunks []map[string]interface{}, tenantIDs []string, docEngine engine.DocEngine, ctx context.Context) []map[string]interface{} {
|
|
common.Info("RetrievalByChildren started", zap.Int("chunks", len(chunks)), zap.Strings("tenantIDs", tenantIDs))
|
|
|
|
indexNames := buildIndexNames(tenantIDs)
|
|
if len(chunks) == 0 || len(indexNames) == 0 {
|
|
return chunks
|
|
}
|
|
|
|
// Group child chunks by mom_id
|
|
type childChunk struct {
|
|
chunk map[string]interface{}
|
|
kbID string
|
|
}
|
|
momChunks := make(map[string][]childChunk)
|
|
remainingChunks := make([]map[string]interface{}, 0, len(chunks))
|
|
|
|
for _, ck := range chunks {
|
|
momID, ok := ck["mom_id"].(string)
|
|
if !ok || momID == "" {
|
|
remainingChunks = append(remainingChunks, ck)
|
|
continue
|
|
}
|
|
kbID, _ := ck["kb_id"].(string)
|
|
momChunks[momID] = append(momChunks[momID], childChunk{chunk: ck, kbID: kbID})
|
|
}
|
|
|
|
if len(momChunks) == 0 {
|
|
common.Info("RetrievalByChildren finished", zap.Int("momChunks", len(momChunks)), zap.Int("resultChunks", len(chunks)))
|
|
return chunks
|
|
}
|
|
|
|
// Fetch parent chunks and aggregate
|
|
vectorSize := 1024
|
|
for momID, childList := range momChunks {
|
|
kbIDs := make([]string, 0, len(childList))
|
|
for _, c := range childList {
|
|
if c.kbID != "" {
|
|
kbIDs = append(kbIDs, c.kbID)
|
|
}
|
|
}
|
|
if len(kbIDs) == 0 {
|
|
kbIDs = append(kbIDs, "")
|
|
}
|
|
|
|
parent, err := docEngine.GetChunk(ctx, indexNames[0], momID, kbIDs)
|
|
if err != nil {
|
|
common.Warn("Failed to get parent chunk", zap.String("momID", momID), zap.Error(err))
|
|
continue
|
|
}
|
|
parentMap, ok := parent.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Calculate average similarity
|
|
simBuf := make([]float64, 0, len(childList))
|
|
for _, c := range childList {
|
|
if sim, ok := c.chunk["similarity"].(float64); ok {
|
|
simBuf = append(simBuf, sim)
|
|
}
|
|
}
|
|
totalSim := common.PairwiseSum(simBuf)
|
|
avgSim := totalSim / float64(len(childList))
|
|
|
|
// Collect content_ltks from children
|
|
var contentParts []string
|
|
for _, c := range childList {
|
|
if ltks, ok := c.chunk["content_ltks"].(string); ok {
|
|
contentParts = append(contentParts, ltks)
|
|
}
|
|
}
|
|
contentLTKS := strings.Join(contentParts, " ")
|
|
|
|
// Collect important_kwd from children
|
|
allImportantKwd := []string{}
|
|
for _, c := range childList {
|
|
if kwd, ok := c.chunk["important_kwd"].([]interface{}); ok {
|
|
for _, k := range kwd {
|
|
if ks, ok := k.(string); ok {
|
|
allImportantKwd = append(allImportantKwd, ks)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build aggregated chunk
|
|
docTypeKwd := ""
|
|
if v, ok := parentMap["doc_type_kwd"].(string); ok {
|
|
docTypeKwd = v
|
|
}
|
|
imgID := parentMap["img_id"]
|
|
if imgID == nil || imgID == "" {
|
|
imgID = ""
|
|
}
|
|
aggregated := map[string]interface{}{
|
|
"chunk_id": momID,
|
|
"content_ltks": contentLTKS,
|
|
"content_with_weight": parentMap["content_with_weight"],
|
|
"doc_id": parentMap["doc_id"],
|
|
"docnm_kwd": parentMap["docnm_kwd"],
|
|
"kb_id": parentMap["kb_id"],
|
|
"important_kwd": allImportantKwd,
|
|
"image_id": imgID,
|
|
"similarity": avgSim,
|
|
"vector_similarity": avgSim,
|
|
"term_similarity": avgSim,
|
|
"vector": make([]float64, vectorSize),
|
|
"positions": parentMap["position_int"],
|
|
"doc_type_kwd": docTypeKwd,
|
|
}
|
|
|
|
// Get vector from first child if available
|
|
childVecLoop:
|
|
for _, c := range childList {
|
|
for k := range c.chunk {
|
|
if strings.HasSuffix(k, "_vec") {
|
|
if vec, ok := c.chunk[k].([]float64); ok {
|
|
aggregated["vector"] = vec
|
|
vectorSize = len(vec)
|
|
break childVecLoop
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
remainingChunks = append(remainingChunks, aggregated)
|
|
}
|
|
|
|
// Sort by similarity descending
|
|
for i := 0; i < len(remainingChunks); i++ {
|
|
for j := i + 1; j < len(remainingChunks); j++ {
|
|
simI, _ := remainingChunks[i]["similarity"].(float64)
|
|
simJ, _ := remainingChunks[j]["similarity"].(float64)
|
|
if simJ > simI {
|
|
remainingChunks[i], remainingChunks[j] = remainingChunks[j], remainingChunks[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
common.Info("RetrievalByChildren finished", zap.Int("momChunks", len(momChunks)), zap.Int("resultChunks", len(remainingChunks)))
|
|
return remainingChunks
|
|
}
|
|
|
|
// PruneDeletedChunks removes chunks whose documents no longer exist
|
|
func (s *RetrievalService) PruneDeletedChunks(result *RetrievalSearchResult) (*RetrievalSearchResult, error) {
|
|
if s.documentDAO == nil {
|
|
return nil, fmt.Errorf("documentDAO is not initialized")
|
|
}
|
|
// Collect all doc_ids from chunks
|
|
chunkDocIDs := make([]string, 0, len(result.Field))
|
|
for _, chunk := range result.Field {
|
|
if docID, ok := chunk["doc_id"].(string); ok && docID != "" {
|
|
chunkDocIDs = append(chunkDocIDs, docID)
|
|
}
|
|
}
|
|
|
|
if len(chunkDocIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
// Deduplicate chunkDocIDs for correct comparison with existingDocIDs
|
|
uniqueDocIDs := make([]string, 0, len(chunkDocIDs))
|
|
seen := make(map[string]struct{}, len(chunkDocIDs))
|
|
for _, id := range chunkDocIDs {
|
|
if _, exists := seen[id]; !exists {
|
|
seen[id] = struct{}{}
|
|
uniqueDocIDs = append(uniqueDocIDs, id)
|
|
}
|
|
}
|
|
|
|
// Get existing document IDs
|
|
docs, err := s.documentDAO.GetByIDs(uniqueDocIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetByIDs failed: %w", err)
|
|
}
|
|
|
|
existingDocIDs := make(map[string]struct{}, len(docs))
|
|
for _, doc := range docs {
|
|
existingDocIDs[doc.ID] = struct{}{}
|
|
}
|
|
|
|
// Early return if all docs exist
|
|
if len(existingDocIDs) == len(uniqueDocIDs) {
|
|
return result, nil
|
|
}
|
|
|
|
// Filter out chunks with deleted documents
|
|
filteredIDs := make([]string, 0, len(result.IDs))
|
|
filteredChunks := make([]map[string]interface{}, 0, len(result.IDs))
|
|
filteredField := make(map[string]map[string]interface{}, len(result.IDs))
|
|
filteredHighlight := make(map[string]string)
|
|
removed := 0
|
|
|
|
for _, chunkID := range result.IDs {
|
|
chunk, exists := result.Field[chunkID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
docID, ok := chunk["doc_id"].(string)
|
|
if !ok || docID == "" {
|
|
// Keep chunks without doc_id
|
|
filteredIDs = append(filteredIDs, chunkID)
|
|
filteredChunks = append(filteredChunks, chunk)
|
|
filteredField[chunkID] = chunk
|
|
if result.Highlight != nil {
|
|
if hl, ok := result.Highlight[chunkID]; ok {
|
|
filteredHighlight[chunkID] = hl
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if _, docExists := existingDocIDs[docID]; !docExists {
|
|
removed++
|
|
continue
|
|
}
|
|
filteredIDs = append(filteredIDs, chunkID)
|
|
filteredChunks = append(filteredChunks, chunk)
|
|
filteredField[chunkID] = chunk
|
|
if result.Highlight != nil {
|
|
if hl, ok := result.Highlight[chunkID]; ok {
|
|
filteredHighlight[chunkID] = hl
|
|
}
|
|
}
|
|
}
|
|
|
|
if removed > 0 {
|
|
common.Warn("Pruned stale chunks whose documents no longer exist", zap.Int("removed", removed))
|
|
}
|
|
|
|
return &RetrievalSearchResult{
|
|
Chunks: filteredChunks,
|
|
Total: int64(len(filteredIDs)),
|
|
QueryVector: result.QueryVector,
|
|
Highlight: filteredHighlight,
|
|
Field: filteredField,
|
|
IDs: filteredIDs,
|
|
Keywords: result.Keywords,
|
|
Aggregation: result.Aggregation,
|
|
Options: result.Options,
|
|
}, nil
|
|
}
|
|
|
|
// buildIndexNames creates index names for the given tenant IDs
|
|
func buildIndexNames(tenantIDs []string) []string {
|
|
indexNames := make([]string, len(tenantIDs))
|
|
for i, tenantID := range tenantIDs {
|
|
indexNames[i] = fmt.Sprintf("ragflow_%s", tenantID)
|
|
}
|
|
return indexNames
|
|
}
|