diff --git a/go/internal/api/routes/consolidation_pipe.go b/go/internal/api/routes/consolidation_pipe.go index 83de54d..b034fc7 100644 --- a/go/internal/api/routes/consolidation_pipe.go +++ b/go/internal/api/routes/consolidation_pipe.go @@ -43,11 +43,13 @@ func (cp *ConsolidationPipeline) SetDataDir(dataDir, sqlitePath string) { // Run 执行全流程 // 优先调 Rust zhiyi-consolidate(DBSCAN + 衰减校准 + 质量回溯) // Rust 不可用 → 降级为 Go 启发式 +// 无论走哪条路径,最后都执行图谱维护(修剪 + PageRank) func (cp *ConsolidationPipeline) Run() (*ConsolidationReport, error) { // ─── 尝试 Rust sidecar ────────────────────────────── + var report *ConsolidationReport if cp.dataDir != "" && cp.sqlitePath != "" { if rustReport, err := consolidate.Run(cp.dataDir, cp.sqlitePath, "full"); err == nil { - report := &ConsolidationReport{ + report = &ConsolidationReport{ StartedAt: time.Now(), FinishedAt: time.Now(), Duration: "rust_sidecar", @@ -62,14 +64,55 @@ func (cp *ConsolidationPipeline) Run() (*ConsolidationReport, error) { rustReport.Quality.Score, rustReport.Quality.LowInfo, rustReport.Quality.Hallucinations)) } log.Printf("[consolidation] Rust sidecar 完成: clusters=%d noise=%d", rustReport.Clusters, rustReport.Noise) - PushConsolidationDone(fmt.Sprintf("rust: merged=%d conflicts=%d patterns=%d pruned=%d", - report.Merged, report.ConflictsFound, len(report.Patterns), report.GraphPruned)) - return report, nil + } else { + log.Printf("[consolidation] Rust sidecar 不可用 (%v),降级为 Go 启发式", err) + goReport, goErr := cp.runGoFallback() + if goErr != nil { + return goReport, goErr + } + report = goReport } - log.Printf("[consolidation] Rust sidecar 不可用,降级为 Go 启发式") + } else { + goReport, err := cp.runGoFallback() + if err != nil { + return goReport, err + } + report = goReport } - return cp.runGoFallback() + // ─── 图谱后处理:修剪 + PageRank(无论 Rust/Go 都执行)── + pruned := cp.runGraphMaintenance() + report.GraphPruned += pruned + report.FinishedAt = time.Now() + report.Duration = report.FinishedAt.Sub(report.StartedAt).String() + + PushConsolidationDone(fmt.Sprintf("merged=%d conflicts=%d patterns=%d pruned=%d", + report.Merged, report.ConflictsFound, len(report.Patterns), report.GraphPruned)) + return report, nil +} + +// runGraphMaintenance 图谱维护:修剪低权重边 + 孤立节点 + PageRank 更新 +func (cp *ConsolidationPipeline) runGraphMaintenance() int { + log.Printf("[consolidation] 图谱维护开始...") + before, _, _ := cp.graph.Stats() + + // Step 1: 低权重边 + 孤立节点删除(§2.5.5) + cp.graph.Prune(0.15) + + // Step 2: PageRank 更新(§2.5.5 — 每次修剪后全部节点重新计算) + ranks := cp.graph.PageRank(0.85, 20) + if ranks != nil { + // 写回数据库(需要 SQLite 级别的接口) + if sqlite, ok := cp.graph.(*governance.SQLiteGraphStore); ok { + sqlite.UpdatePageRanks(ranks) + } + log.Printf("[consolidation] PageRank 更新: %d nodes", len(ranks)) + } + + after, _, _ := cp.graph.Stats() + pruned := before - after + log.Printf("[consolidation] 图谱维护完成: 修剪 %d 个节点,当前 %d 节点", pruned, after) + return pruned } // runGoFallback Go 启发式整合(Rust 不可用时的降级方案) diff --git a/go/internal/api/server.go b/go/internal/api/server.go index 3ad22c8..3688b9b 100644 --- a/go/internal/api/server.go +++ b/go/internal/api/server.go @@ -135,6 +135,7 @@ func NewServer() http.Handler { graphUpdater.UpdateFromDistill(&governance.DistillInput{ Content: input.Content, Facts: result.Facts, Entities: entityNames, Namespace: input.Namespace, + EpisodeID: input.EpisodeID, }) // 冲突检测:加载同 namespace 已有记忆进行比较 zeroVec := make([]float32, 1024) @@ -618,6 +619,11 @@ func NewServer() http.Handler { selfoptimize.RegisterGapFlow(selfoptimize.Flow) selfoptimize.RegisterCorrectFlow(selfoptimize.Flow) selfoptimize.RegisterConsolidateFlow(selfoptimize.Flow) + // 注册真正的 consolidate 处理器:调用 ConsolidationPipeline + selfoptimize.Flow.Register("consolidate", func(task *selfoptimize.PipelineTask) error { + _, err := consolPipe.Run() + return err + }) go selfoptimize.Flow.Start() selfoptimize.Executor.Start(selfoptimize.Flow) diff --git a/go/internal/distill/engine.go b/go/internal/distill/engine.go index 8b2fca0..65112ba 100644 --- a/go/internal/distill/engine.go +++ b/go/internal/distill/engine.go @@ -62,6 +62,17 @@ type FiveDScore struct { RU float64 `json:"ru"` // Recall Usability } +// LLMResponse LLM 完整响应(5D + 实体 + 事实) +type LLMResponse struct { + IS float64 `json:"is"` + SU float64 `json:"su"` + PA float64 `json:"pa"` + VD float64 `json:"vd"` + RU float64 `json:"ru"` + Entities []string `json:"entities"` + Facts []string `json:"facts"` +} + // LLM 5维权重 var Weights = FiveDScore{ IS: 0.20, @@ -171,11 +182,13 @@ func (e *Engine) distillOne(input DistillInput) DistillResult { log.Printf("[distill] LLMEndpoint empty, fallback for %s", input.EpisodeID) return fallbackSingle(input) } - score, err := e.callLLM5D(input.Content) + llmResp, err := e.callLLM5D(input.Content) if err != nil { log.Printf("[distill] callLLM5D err for %s: %v", input.EpisodeID, err) return fallbackSingle(input) } + + score := FiveDScore{IS: llmResp.IS, SU: llmResp.SU, PA: llmResp.PA, VD: llmResp.VD, RU: llmResp.RU} overall := score.IS*Weights.IS + score.SU*Weights.SU + score.PA*Weights.PA + @@ -186,18 +199,36 @@ func (e *Engine) distillOne(input DistillInput) DistillResult { input.EpisodeID, overall, score.IS, score.SU, score.PA, score.VD, score.RU) if overall < 0.7 && score.VD < 0.8 { - log.Printf("[distill] score below threshold for %s, skip LLM entity extraction", input.EpisodeID) - // 即使阈值未通过,仍然用启发式提取实体(§3.1 降级策略) - facts, entities := e.extractFacts(input.Content) - if len(entities) > 0 { - log.Printf("[distill] heuristic entities for %s: %d entities", input.EpisodeID, len(entities)) - return DistillResult{Facts: facts, Entities: entities, Score5D: score, Overall: overall} + log.Printf("[distill] score below threshold for %s, skip", input.EpisodeID) + // 即使阈值未通过,仍然用 LLM 实体提取(如果有的话) + if len(llmResp.Entities) == 0 { + return DistillResult{} } - return DistillResult{} } - // 提取事实和实体 - facts, entities := e.extractFacts(input.Content) + // 优先使用 LLM 提取的实体和事实 + var entities []Entity + var facts []string + + if len(llmResp.Entities) > 0 { + for _, name := range llmResp.Entities { + entities = append(entities, Entity{ + Name: name, Type: "entity", Properties: []string{"llm_extracted"}, + }) + } + } else { + // 降级:启发式提取 + _, heuristicEntities := e.extractFacts(input.Content) + entities = heuristicEntities + } + + // 事实 + if len(llmResp.Facts) > 0 { + facts = llmResp.Facts + } else { + heuristicFacts, _ := e.extractFacts(input.Content) + facts = heuristicFacts + } return DistillResult{ Facts: facts, @@ -207,20 +238,24 @@ func (e *Engine) distillOne(input DistillInput) DistillResult { } } -// callLLM5D 调用 LLM 进行 5维评估 -func (e *Engine) callLLM5D(content string) (FiveDScore, error) { - prompt := fmt.Sprintf(`你是一个记忆质量评估器。评估以下内容的5个维度(0-1分数): +// callLLM5D 调用 LLM 进行 5维评估 + 实体/事实提取 +func (e *Engine) callLLM5D(content string) (LLMResponse, error) { + prompt := fmt.Sprintf(`你是一个记忆质量评估器和信息提取器。分析以下内容,返回 JSON: -- IS (Information Significance): 信息重要性,对系统运行有多关键 -- SU (Strategic Utility): 战略价值,对未来决策有多大帮助 -- PA (Practical Applicability): 实用性,可重复使用的价值 -- VD (Validation Durability): 验证耐久性,信息在多长时间内保持有效 -- RU (Recall Usability): 召回可用性,作为搜索入口的便利性 +1. 5 维度评分(0-1): +- is (Information Significance): 信息重要性 +- su (Strategic Utility): 战略价值 +- pa (Practical Applicability): 实用价值 +- vd (Validation Durability): 验证耐久性 +- ru (Recall Usability): 召回可用性 + +2. 提取命名实体和技术概念(entities):重要的系统/工具/人名/技术名词 +3. 提取核心事实陈述(facts):具体的事实/决策/配置项 内容: %s -只返回 JSON: {"is": 0.X, "su": 0.X, "pa": 0.X, "vd": 0.X, "ru": 0.X}`, truncate(content, 500)) +只返回 JSON: {"is": 0.X, "su": 0.X, "pa": 0.X, "vd": 0.X, "ru": 0.X, "entities": ["entity1", "entity2"], "facts": ["fact1", "fact2"]}`, truncate(content, 500)) body := map[string]interface{}{ "model": e.LLMModel, @@ -228,17 +263,17 @@ func (e *Engine) callLLM5D(content string) (FiveDScore, error) { {"role": "user", "content": prompt}, }, "temperature": 0.2, - "max_tokens": 100, + "max_tokens": 300, } jsonBody, err := json.Marshal(body) if err != nil { - return FiveDScore{}, err + return LLMResponse{}, err } req, err := http.NewRequest("POST", e.LLMEndpoint, bytes.NewReader(jsonBody)) if err != nil { - return FiveDScore{}, err + return LLMResponse{}, err } req.Header.Set("Content-Type", "application/json") if e.APIKey != "" { @@ -247,7 +282,7 @@ func (e *Engine) callLLM5D(content string) (FiveDScore, error) { resp, err := e.client.Do(req) if err != nil { - return FiveDScore{}, err + return LLMResponse{}, err } defer resp.Body.Close() @@ -263,23 +298,32 @@ func (e *Engine) callLLM5D(content string) (FiveDScore, error) { } if err := json.Unmarshal(respBody, &result); err != nil { - return FiveDScore{}, err + return LLMResponse{}, err } if len(result.Choices) == 0 { - return FiveDScore{}, fmt.Errorf("no choices in LLM response") + return LLMResponse{}, fmt.Errorf("no choices in LLM response") } - var score FiveDScore + var llmResp LLMResponse llmContent := result.Choices[0].Message.Content if llmContent == "" { llmContent = result.Choices[0].Message.ReasoningContent } - if err := json.Unmarshal([]byte(llmContent), &score); err != nil { + if err := json.Unmarshal([]byte(llmContent), &llmResp); err != nil { log.Printf("[distill] LLM JSON parse error: %v | content=%q", err, truncate(llmContent, 200)) - return FiveDScore{}, fmt.Errorf("parse score: %w", err) + return LLMResponse{}, fmt.Errorf("parse score: %w", err) } - return score, nil + + // 记录实体和事实数量 + if len(llmResp.Entities) > 0 { + log.Printf("[distill] LLM entities for content: %d entities", len(llmResp.Entities)) + } + if len(llmResp.Facts) > 0 { + log.Printf("[distill] LLM facts for content: %d facts", len(llmResp.Facts)) + } + + return llmResp, nil } // extractFacts 从内容中提取事实和实体(关键词 + 命名实体启发式) diff --git a/go/internal/governance/graph_auto.go b/go/internal/governance/graph_auto.go index 946ff1b..d2fa49f 100644 --- a/go/internal/governance/graph_auto.go +++ b/go/internal/governance/graph_auto.go @@ -17,7 +17,7 @@ func NewAutoGraphUpdater(g GraphStore) *AutoGraphUpdater { return &AutoGraphUpdater{graph: g} } -// UpdateFromDistill 从蒸馏产物自动更新图谱 +// UpdateFromDistill 从蒸馏产物自动更新图谱(§3.4) func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) { // 1. 提取实体并创建节点(ID 和 name 都清洗) for _, entity := range distilled.Entities { @@ -26,19 +26,32 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) { agu.graph.AddNode(nodeID, cleanName, detectNodeType(entity), distilled.Namespace) } - // 2. 创建实体间关系 - for i := 0; i < len(distilled.Entities); i++ { - for j := i + 1; j < len(distilled.Entities); j++ { + // 2. 创建实体间关系 + CO_OCCURS 共访边 + entityCount := len(distilled.Entities) + for i := 0; i < entityCount; i++ { + for j := i + 1; j < entityCount; j++ { eidI := entityID(distilled.Entities[i]) eidJ := entityID(distilled.Entities[j]) + + // 2a. 语义关系边(inferRelation) edgeID := fmt.Sprintf("e_%s_%s_%d", eidI, eidJ, time.Now().UnixNano()) relation := inferRelation(distilled.Entities[i], distilled.Entities[j], distilled.Content) agu.graph.AddEdge(edgeID, eidI, eidJ, relation, distilled.Namespace, 0.5) + + // 2b. CO_OCCURS 共访边(§2.5.2 来源 2) + // 权重 = 1 / sqrt(entityCount) — 实体越多单对权重越低 + coWeight := 1.0 + if entityCount > 2 { + coWeight = 1.0 / sqrtFloat(float64(entityCount)) + } + coEdgeID := fmt.Sprintf("co_%s_%s_%d", eidI, eidJ, time.Now().UnixNano()) + agu.graph.AddEdge(coEdgeID, eidI, eidJ, + "CO_OCCURS", distilled.Namespace, coWeight) } } - // 3. 按内容类别创建冲突检测边 + // 3. 按内容类别创建冲突检测边 → 使用设计规范的 CONFLICTS_WITH 类型 for _, fact := range distilled.Facts { entities := extractEntitiesFromText(fact) for i := 0; i < len(entities); i++ { @@ -46,13 +59,29 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) { if conflictPossible(entities[i], entities[j], fact) { eidI := entityID(entities[i]) eidJ := entityID(entities[j]) - edgeID := fmt.Sprintf("c_%s_%s", eidI, eidJ) + edgeID := fmt.Sprintf("cw_%s_%s_%d", eidI, eidJ, time.Now().UnixNano()) agu.graph.AddEdge(edgeID, eidI, eidJ, - "conflict_candidate", distilled.Namespace, 0.3) + "CONFLICTS_WITH", distilled.Namespace, 0.3) } } } } + + // 4. DERIVED_FROM 边:蒸馏产物 → 原始 episode + if distilled.EpisodeID != "" { + for _, entity := range distilled.Entities { + eid := entityID(entity) + derivedEdgeID := fmt.Sprintf("df_%s_%s_%d", eid, distilled.EpisodeID, time.Now().UnixNano()) + agu.graph.AddEdge(derivedEdgeID, eid, distilled.EpisodeID, + "DERIVED_FROM", distilled.Namespace, 0.9) + } + for _, fact := range distilled.Facts { + factNodeID := "f_" + strings.ReplaceAll(entityID(fact), "n_", "") + derivedEdgeID := fmt.Sprintf("df_%s_%s_%d", factNodeID, distilled.EpisodeID, time.Now().UnixNano()) + agu.graph.AddEdge(derivedEdgeID, factNodeID, distilled.EpisodeID, + "DERIVED_FROM", distilled.Namespace, 0.9) + } + } } // RecordCoOccurrence record recall 后的共访关系 @@ -69,6 +98,7 @@ func (agu *AutoGraphUpdater) RecordCoOccurrence(query, namespace string, resultI // ─── 辅助 ───────────────────────────────────────────── type DistillInput struct { + EpisodeID string Content string Facts []string Decisions []string @@ -161,3 +191,18 @@ func minz(a, b int) int { if a < b { return a } return b } + +func sqrtFloat(x float64) float64 { + if x <= 0 { + return 0 + } + // Newton's method for sqrt + z := x / 2.0 + for i := 0; i < 10; i++ { + z -= (z*z - x) / (2 * z) + } + if z < 0 { + return 0 + } + return z +} diff --git a/go/internal/governance/graph_sqlite.go b/go/internal/governance/graph_sqlite.go index 5b6d58f..b261dbc 100644 --- a/go/internal/governance/graph_sqlite.go +++ b/go/internal/governance/graph_sqlite.go @@ -13,7 +13,9 @@ import "C" import ( "encoding/json" "fmt" + "strings" "sync" + "unicode" "unsafe" "github.com/xiaoxue/memoryweave/internal/models" @@ -179,15 +181,11 @@ func (gs *SQLiteGraphStore) AddEdge(id, source, target, relation, namespace stri } func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace string) ([]map[string]interface{}, error) { - // 双向 BFS + // 单源 BFS:从 entity 展开到邻居,不找路径 gs.mu.RLock() defer gs.mu.RUnlock() - // 命名空间兼容:shared/default 视为同一命名空间 - nsClause := fmt.Sprintf("e.namespace IN ('%s', 'default')", escape(namespace)) - if namespace == "" { - nsClause = "1=1" // 通配所有命名空间 - } + nsClause := buildNamespaceClause(namespace) visited := map[string]bool{entity: true} queue := []string{entity} @@ -196,7 +194,6 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin for hop := 1; hop <= maxHops && len(queue) > 0; hop++ { var next []string for _, node := range queue { - // 查询所有出边 sql := fmt.Sprintf( "SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s", escape(node), nsClause) @@ -218,11 +215,221 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin return paths, nil } +// NavigateBiDir 真正的双向 BFS 路径查找(§2.5.4) +// 从 source 正向 BFS maxHops 跳,从 target 反向 BFS maxHops 跳 +// 找到相遇节点 → 重建完整路径 → 按 score 降序返回 top 3 func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, namespace string) ([]map[string]interface{}, error) { - // 双向 BFS 直到相遇 - forward, _ := gs.Navigate(source, maxHops, namespace) - backward, _ := gs.Navigate(target, maxHops, namespace) - return append(forward, backward...), nil + if source == target { + return []map[string]interface{}{ + { + "nodes": []string{source}, + "edges": []struct{}{}, + "score": 1.0, + }, + }, nil + } + + gs.mu.RLock() + defer gs.mu.RUnlock() + + nsClause := buildNamespaceClause(namespace) + + // 正向 BFS 数据结构 + type fwdNode struct { + parent string + edgeID string + relation string + weight float64 + hop int + pathProd float64 // 累积权重乘积 + } + fwd := make(map[string]*fwdNode) + fwd[source] = &fwdNode{hop: 0, pathProd: 1.0} + fwdQ := []string{source} + fwdVisited := map[string]bool{source: true} + + // 反向 BFS 数据结构 + type bwdNode struct { + parent string + edgeID string + relation string + weight float64 + hop int + pathProd float64 + } + bwd := make(map[string]*bwdNode) + bwd[target] = &bwdNode{hop: 0, pathProd: 1.0} + bwdQ := []string{target} + bwdVisited := map[string]bool{target: true} + + // 正向步长:ceil(maxHops/2) + fwdHops := (maxHops + 1) / 2 + // 反向步长:floor(maxHops/2) + bwdHops := maxHops / 2 + + // 执行正向 BFS + for hop := 1; hop <= fwdHops && len(fwdQ) > 0; hop++ { + var next []string + for _, node := range fwdQ { + sql := fmt.Sprintf( + "SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e WHERE e.source = '%s' AND %s", + escape(node), nsClause) + edges := queryRows(gs.db, sql) + for _, edge := range edges { + targetID := edge["target"].(string) + if fwdVisited[targetID] { + continue + } + fwdVisited[targetID] = true + w := edge["weight"].(float64) + fwd[targetID] = &fwdNode{ + parent: node, + edgeID: edge["id"].(string), + relation: edge["relation"].(string), + weight: w, + hop: hop, + pathProd: fwd[node].pathProd * w, + } + next = append(next, targetID) + } + } + fwdQ = next + } + + // 执行反向 BFS + for hop := 1; hop <= bwdHops && len(bwdQ) > 0; hop++ { + var next []string + for _, node := range bwdQ { + // 反向要找所有指向 node 的边(target = node) + sql := fmt.Sprintf( + "SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e WHERE e.target = '%s' AND %s", + escape(node), nsClause) + edges := queryRows(gs.db, sql) + for _, edge := range edges { + srcID := edge["source"].(string) + if bwdVisited[srcID] { + continue + } + bwdVisited[srcID] = true + w := edge["weight"].(float64) + bwd[srcID] = &bwdNode{ + parent: node, + edgeID: edge["id"].(string), + relation: edge["relation"].(string), + weight: w, + hop: hop, + pathProd: bwd[node].pathProd * w, + } + next = append(next, srcID) + } + } + bwdQ = next + } + + // 寻找相遇节点 + var results []PathResult + + for nodeID, fn := range fwd { + if bn, ok := bwd[nodeID]; ok { + // 重建从 source → nodeID 的路径(正向) + fwdPathNodes := []string{nodeID} + fwdPathEdges := make([]map[string]interface{}, 0) + cur := nodeID + for cur != source { + n := fwd[cur] + if n == nil { + break + } + fwdPathEdges = append(fwdPathEdges, map[string]interface{}{ + "source": n.parent, "target": cur, + "relation": n.relation, "weight": n.weight, + }) + fwdPathNodes = append(fwdPathNodes, n.parent) + cur = n.parent + } + // 逆序:从 source 到 meeting + for i, j := 0, len(fwdPathNodes)-1; i < j; i, j = i+1, j-1 { + fwdPathNodes[i], fwdPathNodes[j] = fwdPathNodes[j], fwdPathNodes[i] + } + for i, j := 0, len(fwdPathEdges)-1; i < j; i, j = i+1, j-1 { + fwdPathEdges[i], fwdPathEdges[j] = fwdPathEdges[j], fwdPathEdges[i] + } + + // 重建从 nodeID → target 的路径(反向,翻转方向) + cur = nodeID + for cur != target { + n := bwd[cur] + if n == nil { + break + } + // 反向 BFS 的 parent 是 target 侧,所以边是从 cur → n.parent + fwdPathEdges = append(fwdPathEdges, map[string]interface{}{ + "source": cur, "target": n.parent, + "relation": n.relation, "weight": n.weight, + }) + fwdPathNodes = append(fwdPathNodes, n.parent) + cur = n.parent + } + + score := fn.pathProd * bn.pathProd + if score > 0 { + results = append(results, PathResult{ + Nodes: fwdPathNodes, + Edges: fwdPathEdges, + Score: score, + }) + } + } + } + + // 按 score 降序排序,取 top 3 + sortResultsByScore(results) + + // 转换为接口格式 + out := make([]map[string]interface{}, 0, len(results)) + for _, r := range results { + if len(out) >= 3 { + break + } + out = append(out, map[string]interface{}{ + "nodes": r.Nodes, + "edges": r.Edges, + "score": r.Score, + }) + } + + if len(out) == 0 { + // 没有路径时的降级:返回各自邻居展开 + fwd, _ := gs.Navigate(source, maxHops, namespace) + bwd, _ := gs.Navigate(target, maxHops, namespace) + return append(fwd, bwd...), nil + } + return out, nil +} + +// PathResult 路径查找结果 +type PathResult struct { + Nodes []string + Edges []map[string]interface{} + Score float64 +} + +func buildNamespaceClause(namespace string) string { + if namespace == "" { + return "1=1" + } + return fmt.Sprintf("(e.namespace = '%s' OR e.namespace = 'default')", escape(namespace)) +} + +// sortResultsByScore 简单选择排序 +func sortResultsByScore(results []PathResult) { + 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] + } + } + } } func (gs *SQLiteGraphStore) Query(entity, relation, namespace string) []map[string]interface{} { @@ -308,18 +515,92 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam expanded := make([]models.RecallResult, len(results)) copy(expanded, results) + seen := make(map[string]bool) for _, r := range results { - paths, _ := gs.Navigate(r.Content, maxHops, namespace) - for _, p := range paths { - expanded = append(expanded, models.RecallResult{ - Content: fmt.Sprintf("%v", p["to"]), - Score: r.Score * 0.5, - }) + if seen[r.ID] { + continue + } + seen[r.ID] = true + + // 从内容中提取可能作为实体的关键词 + entities := extractPotentialEntities(r.Content) + for _, entity := range entities { + nodeID := normalizeEntityID(entity) + paths, _ := gs.Navigate(nodeID, maxHops, namespace) + for _, p := range paths { + if target, ok := p["to"].(string); ok && !seen[target] { + seen[target] = true + expanded = append(expanded, models.RecallResult{ + Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, p["relation"], p["to"]), + Score: r.Score * 0.5, + }) + } + } } } return expanded } +// extractPotentialEntities 从文本中提取可能作为图谱实体的关键词 +func extractPotentialEntities(text string) []string { + var entities []string + seen := make(map[string]bool) + for _, w := range strings.Fields(text) { + w = strings.Trim(w, ",.;:!?,。;:!?、\"'()()[]【】") + if len(w) < 2 { + continue + } + // 大写开头(英文命名实体) + runes := []rune(w) + if len(runes) >= 2 && runes[0] >= 'A' && runes[0] <= 'Z' { + if !seen[strings.ToLower(w)] { + seen[strings.ToLower(w)] = true + entities = append(entities, w) + } + } + // 纯中文字符 2-8 字 + chineseOnly := true + chineseRunes := 0 + for _, r := range runes { + if r >= 0x4E00 && r <= 0x9FFF { + chineseRunes++ + } else { + chineseOnly = false + } + } + if chineseOnly && chineseRunes >= 2 && chineseRunes <= 8 { + if !seen[w] { + seen[w] = true + entities = append(entities, w) + } + } + } + return entities +} + +// normalizeEntityID 将自由文本转为实体 ID 格式 +func normalizeEntityID(name string) string { + clean := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' { + return r + } + if unicode.IsLetter(r) { + return r + } + return '_' + }, strings.TrimSpace(name)) + clean = strings.ToLower(clean) + clean = strings.ReplaceAll(clean, " ", "_") + for strings.Contains(clean, "__") { + clean = strings.ReplaceAll(clean, "__", "_") + } + clean = strings.Trim(clean, "_") + if clean == "" { + return "n_unknown" + } + return "n_" + clean +} + // SearchNodes 按 label 模糊搜索节点(§2.5.4 match 格式兼容) func (gs *SQLiteGraphStore) SearchNodes(label, namespace string) []map[string]interface{} { gs.mu.RLock() @@ -437,6 +718,16 @@ func (gs *SQLiteGraphStore) EvidenceCount(entity string) int { // ─── CGO 工具 ────────────────────────────────────────── +// UpdatePageRanks 批量更新节点的 pagerank 值(§2.5.5) +func (gs *SQLiteGraphStore) UpdatePageRanks(ranks map[string]float64) { + gs.mu.Lock() + defer gs.mu.Unlock() + for nodeID, rank := range ranks { + sql := fmt.Sprintf("UPDATE graph_nodes SET pagerank = %f WHERE id = '%s'", rank, escape(nodeID)) + _ = execSQL(gs.db, sql) + } +} + func execSQL(db *C.sqlite3, sql string) error { cSQL := C.CString(sql) defer C.free(unsafe.Pointer(cSQL))