Files
ragflow/internal/service/graph/pipeline.go
Jin Hai 1880e65e99 Go: refactor (#16602)
### Summary

1. update doc
2. refactor route code

---------

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-07-03 17:00:43 +08:00

287 lines
8.9 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 graph
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"go.uber.org/zap"
"ragflow/internal/common"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
modelModule "ragflow/internal/entity/models"
)
// Pipeline encapsulates the knowledge graph retrieval pipeline.
// Matches Python: rag/graphrag/search.py::KGSearch
type Pipeline struct {
docEngine engine.DocEngine
chatModel *modelModule.ChatModel
embModel *modelModule.EmbeddingModel
kbIDs []string
idxnms []string
question string
// Configurable parameters (defaults match Python)
entSimThreshold float64
relSimThreshold float64
denseTopK int
entTopN int
relTopN int
commTopN int
maxToken int
}
// Option configures a Pipeline.
type Option func(*Pipeline)
// WithSimThreshold sets the similarity threshold for entity and relation search.
// Default: 0.3 (matches Python ent_sim_threshold, rel_sim_threshold).
func WithSimThreshold(v float64) Option {
return func(p *Pipeline) { p.entSimThreshold = v; p.relSimThreshold = v }
}
// WithDenseTopK sets the TopK for dense vector search.
// Default: 1024 (matches Python get_vector topk).
func WithDenseTopK(v int) Option {
return func(p *Pipeline) { p.denseTopK = v }
}
// NewPipeline creates a KG search pipeline with the given dependencies.
//
// docEngine: search engine backend
// kbIDs: knowledge base IDs to search
// tenantIDs: tenant IDs (converted to index names internally)
// question: user query string
// opts: optional configuration (WithSimThreshold, WithDenseTopK)
//
// chatModel and embModel should be set via WithChatModel/WithEmbModel setters
// or passed directly after construction.
func NewPipeline(
docEngine engine.DocEngine,
kbIDs []string,
tenantIDs []string,
question string,
opts ...Option,
) *Pipeline {
idxnms := make([]string, len(tenantIDs))
for i, tid := range tenantIDs {
idxnms[i] = indexName(tid)
}
p := &Pipeline{
docEngine: docEngine,
kbIDs: kbIDs,
idxnms: idxnms,
question: question,
entSimThreshold: defaultSimThreshold,
relSimThreshold: defaultSimThreshold,
denseTopK: defaultDenseTopK,
entTopN: 6,
relTopN: 6,
commTopN: 1,
maxToken: 8196,
}
for _, opt := range opts {
opt(p)
}
return p
}
// SetChatModel sets the chat model for LLM-based query rewrite.
func (p *Pipeline) SetChatModel(chatModel *modelModule.ChatModel) {
p.chatModel = chatModel
}
// SetEmbModel sets the embedding model for dense/hybrid search.
func (p *Pipeline) SetEmbModel(embModel *modelModule.EmbeddingModel) {
p.embModel = embModel
}
// Retrieval runs the full KG retrieval pipeline and returns a synthetic chunk.
func (p *Pipeline) Retrieval(ctx context.Context) (map[string]interface{}, error) {
// 1. Query rewrite via LLM, or fall back to raw question
ty2entsJSON := ""
if p.chatModel != nil {
typeSamples, err := searchTypeSamples(ctx, p.docEngine, p.idxnms, p.kbIDs)
if err != nil {
common.Warn("KG type samples search failed", zap.String("kbIDs", fmt.Sprint(p.kbIDs)))
}
if typeSamples == nil {
typeSamples = make(map[string][]string)
}
data, _ := json.Marshal(typeSamples)
ty2entsJSON = string(data)
}
typeKeywords, entities := queryRewrite(p.chatModel, p.question, ty2entsJSON)
// 2-4. Search entities, types, and relations in parallel (mutually independent)
var (
entsFromQuery map[string]*KGEntity
entsFromTypes map[string]struct{}
relsFromText map[Edge]*KGRelation
entsErr error
)
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
entsFromQuery, entsErr = p.searchEntities(ctx, entities)
}()
go func() {
defer wg.Done()
entsFromTypes = p.searchEntityTypes(ctx, typeKeywords)
}()
go func() {
defer wg.Done()
relsFromText = p.searchRelations(ctx, entities)
}()
wg.Wait()
if entsErr != nil {
return nil, entsErr
} // 5. N-hop analysis + score fusion
nhopPathes := AnalyzeNHopPaths(entsFromQuery)
DoubleHitBoost(entsFromQuery, entsFromTypes)
FuseRelationScores(relsFromText, entsFromTypes, nhopPathes)
// 6. Sort and trim
scoredEnts := SortAndTrimEntities(entsFromQuery, p.entTopN)
scoredRels := SortAndTrimRelations(relsFromText, p.relTopN)
// 7. Build KG content with token budget
entsRelsContent := BuildContent(scoredEnts, scoredRels, p.maxToken)
used := NumTokensFromString(entsRelsContent)
remaining := p.maxToken - used
// 8. Search community reports with remaining token budget
communityContent := searchCommunityContent(ctx, p.docEngine, p.idxnms, p.kbIDs, scoredEnts, p.commTopN, &remaining)
// 9. Build synthetic chunk
return map[string]interface{}{
"chunk_id": "",
"content_ltks": "",
"content_with_weight": entsRelsContent + communityContent,
"doc_id": "",
"docnm_kwd": "Related content in Knowledge Graph",
"kb_id": p.kbIDs,
"important_kwd": []string{},
"image_id": "",
"similarity": 1.0,
"vector_similarity": 1.0,
"term_similarity": 0,
"vector": []float64{},
"positions": []interface{}{},
}, nil
}
// searchEntities searches KG entities by keyword text and optional dense vector.
func (p *Pipeline) searchEntities(ctx context.Context, entities []string) (map[string]*KGEntity, error) {
entsReq := &types.SearchRequest{
IndexNames: p.idxnms,
KbIDs: p.kbIDs,
SelectFields: []string{"entity_kwd", "entity_type_kwd", "rank_flt", "content_with_weight", "n_hop_with_weight"},
Limit: 50,
Filter: map[string]interface{}{"knowledge_graph_kwd": "entity"},
}
if len(entities) > 0 {
entsReq.MatchExprs = buildSearchExprs(p.embModel, &types.MatchTextExpr{
Fields: []string{"entity_kwd^10", "content_ltks^2"},
MatchingText: strings.Join(entities, " "),
TopN: 50,
}, p.entSimThreshold, p.denseTopK)
}
entsResult, err := p.docEngine.Search(ctx, entsReq)
if err != nil {
return nil, fmt.Errorf("KG entity search failed: %w", err)
}
result := make(map[string]*KGEntity)
for _, chunk := range FilterChunksByScore(entsResult.Chunks, p.entSimThreshold) {
name, _ := chunk["entity_kwd"].(string)
if name == "" {
continue
}
e := entityFromChunk(name, chunk)
result[name] = &e
}
return result, nil
}
// searchEntityTypes searches KG entities by type keywords.
func (p *Pipeline) searchEntityTypes(ctx context.Context, typeKeywords []string) map[string]struct{} {
typesReq := &types.SearchRequest{
IndexNames: p.idxnms,
KbIDs: p.kbIDs,
SelectFields: []string{"entity_kwd", "entity_type_kwd"},
Limit: 10000,
Filter: map[string]interface{}{"knowledge_graph_kwd": "entity"},
}
if len(typeKeywords) > 0 {
typeFilters := make([]interface{}, len(typeKeywords))
for i, t := range typeKeywords {
typeFilters[i] = t
}
typesReq.Filter["entity_type_kwd"] = typeFilters
}
typesResult, err := p.docEngine.Search(ctx, typesReq)
result := make(map[string]struct{})
if err != nil {
common.Warn("KG types search failed", zap.String("kbIDs", fmt.Sprint(p.kbIDs)))
} else {
for _, chunk := range typesResult.Chunks {
if name, ok := chunk["entity_kwd"].(string); ok {
result[name] = struct{}{}
}
}
}
return result
}
// searchRelations searches KG relations by entity text and optional dense vector.
func (p *Pipeline) searchRelations(ctx context.Context, entities []string) map[Edge]*KGRelation {
relsReq := &types.SearchRequest{
IndexNames: p.idxnms,
KbIDs: p.kbIDs,
SelectFields: []string{"from_entity_kwd", "to_entity_kwd", "weight_int", "content_with_weight"},
Limit: 50,
Filter: map[string]interface{}{"knowledge_graph_kwd": "relation"},
}
if len(entities) > 0 {
relsReq.MatchExprs = buildSearchExprs(p.embModel, &types.MatchTextExpr{
Fields: []string{"content_ltks", "from_entity_kwd", "to_entity_kwd"},
MatchingText: strings.Join(entities, " "),
TopN: 50,
}, p.relSimThreshold, p.denseTopK)
}
relsResult, err := p.docEngine.Search(ctx, relsReq)
result := make(map[Edge]*KGRelation)
if err != nil {
common.Warn("KG relations search failed", zap.String("kbIDs", fmt.Sprint(p.kbIDs)))
} else {
for _, chunk := range FilterChunksByScore(relsResult.Chunks, p.relSimThreshold) {
edge, rel := relationFromChunk(chunk)
if edge.From == "" || edge.To == "" {
continue
}
result[edge] = &rel
}
}
return result
}