v3.8-graph: P0-P6 图谱增强
- P1: LLM prompt 升级 — 同时提取实体+事实+5D评分 - P2: NavigateBiDir 重写 — 真双向BFS路径查找+权重打分 - P3: CO_OCCURS/DERIVED_FROM/CONFLICTS_WITH 边创建 - P4: 图谱定期修剪 接入 consolidate 流水线 - P5: PageRank 自动更新 + 节点写回 - P6: ExpandFromResults 修复 — 实体提取+节点ID规整
This commit is contained in:
parent
9a6befed85
commit
d876b963c8
|
|
@ -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 不可用时的降级方案)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 从内容中提取事实和实体(关键词 + 命名实体启发式)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue