// // 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 ( "bytes" "encoding/csv" "encoding/json" "fmt" "sort" "strings" "ragflow/internal/tokenizer" ) // AnalyzeNHopPaths decomposes N-hop paths into edges with distance-decayed scores. // Python equivalent: rag/graphrag/search.py lines 172-187 func AnalyzeNHopPaths(entsFromQuery map[string]*KGEntity) map[Edge]EdgeScore { nhopPathes := make(map[Edge]EdgeScore) for _, ent := range entsFromQuery { for _, nbr := range ent.NhopEnts { path := nbr.Path weights := nbr.Weights for i := 0; i < len(path)-1; i++ { f, t := path[i], path[i+1] edge := Edge{From: f, To: t} es := nhopPathes[edge] es.Sim += ent.Similarity / (2.0 + float64(i)) if i < len(weights) { es.PageRank = weights[i] } nhopPathes[edge] = es } } } return nhopPathes } // DoubleHitBoost doubles the similarity of entities found in both // keyword search and type search. Python equivalent: lines 194-198 func DoubleHitBoost(entsFromQuery map[string]*KGEntity, entsFromTypes map[string]struct{}) { for ent := range entsFromQuery { if _, ok := entsFromTypes[ent]; ok { entsFromQuery[ent].Similarity *= 2 } } } // FuseRelationScores integrates N-hop contributions and type boosts // into relation scores. New edges from N-hop are added as relations. // Python equivalent: lines 200-222 func FuseRelationScores( relsFromText map[Edge]*KGRelation, entsFromTypes map[string]struct{}, nhopPathes map[Edge]EdgeScore, ) { // Boost existing relations with N-hop and type scores for edge, rel := range relsFromText { s := 0.0 if np, ok := nhopPathes[edge]; ok { s += np.Sim delete(nhopPathes, edge) } if _, ok := entsFromTypes[edge.From]; ok { s += 1 } if _, ok := entsFromTypes[edge.To]; ok { s += 1 } rel.Sim *= s + 1 } // N-hop discovered edges become new relations for edge, np := range nhopPathes { s := 0.0 if _, ok := entsFromTypes[edge.From]; ok { s += 1 } if _, ok := entsFromTypes[edge.To]; ok { s += 1 } relsFromText[edge] = &KGRelation{ Sim: np.Sim * (s + 1), PageRank: np.PageRank, } } } // SortAndTrimEntities sorts entities by sim*pagerank and takes top N. // Python equivalent: lines 224-225 func SortAndTrimEntities(entsFromQuery map[string]*KGEntity, topN int) []ScoredEntity { if topN <= 0 { topN = 6 } var scored []ScoredEntity for name, ent := range entsFromQuery { scored = append(scored, ScoredEntity{ Entity: name, Score: ent.Similarity * ent.PageRank, Description: ent.Description, }) } sort.Slice(scored, func(i, j int) bool { return scored[i].Score > scored[j].Score }) if len(scored) > topN { scored = scored[:topN] } return scored } // SortAndTrimRelations sorts relations by sim*pagerank and takes top N. // Python equivalent: lines 226-227 func SortAndTrimRelations(relsFromText map[Edge]*KGRelation, topN int) []ScoredRelation { if topN <= 0 { topN = 6 } var scored []ScoredRelation for edge, rel := range relsFromText { scored = append(scored, ScoredRelation{ From: edge.From, To: edge.To, Score: rel.Sim * rel.PageRank, Description: rel.Description, }) } sort.Slice(scored, func(i, j int) bool { return scored[i].Score > scored[j].Score }) if len(scored) > topN { scored = scored[:topN] } return scored } // NumTokensFromString estimates the number of tokens in a string. // Delegates to the shared implementation in the parent service package. func NumTokensFromString(s string) int { return tokenizer.NumTokensFromString(s) } // TrimContentToTokenLimit truncates s to at most limit tokens. // Delegates to the shared implementation in the tokenizer package. func TrimContentToTokenLimit(s string, limit int) string { return tokenizer.TrimContentToTokenLimit(s, limit) } // formatCSVLine formats fields as a single CSV record with trailing newline. // Handles commas, quotes, and newlines in field values correctly — unlike fmt.Sprintf. // Matches Python: pd.DataFrame(...).to_csv() quoting behavior. func formatCSVLine(fields ...string) string { var buf bytes.Buffer w := csv.NewWriter(&buf) _ = w.Write(fields) w.Flush() return buf.String() } // FilterChunksByScore filters chunks where _score >= threshold. // Chunks missing _score are treated as score=0. // Pure function — no I/O, no external dependencies. // Matches Python: _ent_info_from_ and _relation_info_from_ sim_thr filtering. func FilterChunksByScore(chunks []map[string]interface{}, threshold float64) []map[string]interface{} { if threshold <= 0 || len(chunks) == 0 { return chunks } result := make([]map[string]interface{}, 0, len(chunks)) for _, chunk := range chunks { score := 0.0 if v, ok := chunk["_score"].(float64); ok { score = v } else if v, ok := chunk["score"].(float64); ok { score = v } if score >= threshold { result = append(result, chunk) } } return result } // FormatEntitiesToCSV formats scored entities as a CSV string and tracks token count. func FormatEntitiesToCSV(entities []ScoredEntity, maxToken int) (csv string, remainingToken int) { if len(entities) == 0 { return "", maxToken } var b strings.Builder b.WriteString("---- Entities ----\n") b.WriteString("Entity,Score,Description\n") for _, ent := range entities { desc := extractDescription(ent.Description) line := formatCSVLine(ent.Entity, fmt.Sprintf("%.2f", ent.Score), desc) tokens := NumTokensFromString(line) if maxToken-tokens <= 0 { break } b.WriteString(line) maxToken -= tokens } return b.String(), maxToken } // FormatRelationsToCSV formats scored relations as a CSV string and tracks token count. func FormatRelationsToCSV(relations []ScoredRelation, maxToken int) (csv string, remainingToken int) { if len(relations) == 0 { return "", maxToken } var b strings.Builder b.WriteString("---- Relations ----\n") b.WriteString("From Entity,To Entity,Score,Description\n") for _, rel := range relations { desc := extractDescription(rel.Description) line := formatCSVLine(rel.From, rel.To, fmt.Sprintf("%.2f", rel.Score), desc) tokens := NumTokensFromString(line) if maxToken-tokens <= 0 { break } b.WriteString(line) maxToken -= tokens } return b.String(), maxToken } // BuildContent assembles the final knowledge graph content string. // Python equivalent: lines 267-291 func BuildContent( entities []ScoredEntity, relations []ScoredRelation, maxToken int, ) string { entityCSV, remaining := FormatEntitiesToCSV(entities, maxToken) relCSV, _ := FormatRelationsToCSV(relations, remaining) return entityCSV + relCSV } // extractDescription tries to parse a description from a JSON-like string. // Python equivalent: json.loads(desc).get("description", "") func extractDescription(desc string) string { if desc == "" { return "" } // Try to parse as JSON and extract the "description" field. var data map[string]interface{} if err := json.Unmarshal([]byte(desc), &data); err == nil { if v, ok := data["description"]; ok { if s, ok := v.(string); ok { return s } } } return desc }