Files
ragflow/internal/service/nlp/retrieval.go
qinling0210 c960dc2a4c Refine handling of POST /api/v1/datasets/search in GO (#15583)
### What problem does this PR solve?

Refine handling of POST /api/v1/datasets/search in GO

### Type of change

- [x] Refactoring
2026-06-08 11:49:37 +08:00

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
}