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:
xiaowei 2026-05-30 05:23:15 +08:00
parent 9a6befed85
commit d876b963c8
5 changed files with 488 additions and 59 deletions

View File

@ -43,11 +43,13 @@ func (cp *ConsolidationPipeline) SetDataDir(dataDir, sqlitePath string) {
// Run 执行全流程
// 优先调 Rust zhiyi-consolidateDBSCAN + 衰减校准 + 质量回溯)
// 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 不可用时的降级方案)

View File

@ -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)

View File

@ -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 从内容中提取事实和实体(关键词 + 命名实体启发式)

View File

@ -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
}

View File

@ -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))