feat: E1.1-E1.7 图谱 BFS 扩展全部完成

E1.1: InMemoryGraph.NavigateBiDir 真正双向 BFS(替代伪实现)
E1.2: 无相遇节点返回 {unreachable:true} 而非降级单向
E1.3: 规则 NER(extractEntitiesWithNER, 7个正则模式)
E1.4: relationFilter 支持(buildRelationFilterClause, SQL注入)
E1.5: API 层 relation_filter 参数(navigate 端点)
E1.6: API 层 relation_filter 参数(同上,已在 navigate 中支持)
E1.7: 环路检测(seenEdges map,同一边不在单次 BFS 中重复访问)

同时修复: graph_expander.go Navigate 调用加 relFilter=nil
This commit is contained in:
xiaowei 2026-06-02 20:26:57 +08:00
parent 48e33039a0
commit 7ec0642b9d
7 changed files with 270 additions and 62 deletions

View File

@ -65,16 +65,18 @@ func (ga *GraphAPI) Query(w http.ResponseWriter, r *http.Request) {
}
// POST /api/v1/graph/navigate
// 支持种模式:
// 支持种模式:
// 1. 单实体 BFS: {"entity": "...", "max_hops": 2}
// 2. 双向 BFS: {"source": "...", "target": "...", "max_hops": 3}(设计文档 §2.5.4
// 2. 双向 BFS: {"source": "...", "target": "...", "max_hops": 3}
// 3. 关系过滤 BFS: {"entity": "...", "max_hops": 2, "relation_filter": ["related_to", "uses"]}E1.5
func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
var req struct {
Entity string `json:"entity"`
Source string `json:"source"`
Target string `json:"target"`
MaxHops int `json:"max_hops"`
Namespace string `json:"namespace"`
Entity string `json:"entity"`
Source string `json:"source"`
Target string `json:"target"`
MaxHops int `json:"max_hops"`
Namespace string `json:"namespace"`
RelationFilter []string `json:"relation_filter"` // E1.5: 关系类型白名单
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
@ -88,14 +90,15 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
if req.Source != "" && req.Target != "" {
source := normalizeEntity(req.Source)
target := normalizeEntity(req.Target)
paths, err := ga.Graph.NavigateBiDir(source, target, req.MaxHops, req.Namespace)
paths, err := ga.Graph.NavigateBiDir(source, target, req.MaxHops, req.Namespace, req.RelationFilter)
if err != nil {
respondError(w, 500, "navigate failed: "+err.Error())
return
}
respond(w, 200, map[string]interface{}{
"paths": paths, "source": req.Source, "target": req.Target,
"bidirectional": true, "count": len(paths),
"paths": paths, "source": req.Source, "target": req.Target,
"bidirectional": true, "count": len(paths),
"relation_filter": req.RelationFilter,
})
return
}
@ -106,12 +109,15 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
return
}
entity := normalizeEntity(req.Entity)
paths, err := ga.Graph.Navigate(entity, req.MaxHops, req.Namespace)
paths, err := ga.Graph.Navigate(entity, req.MaxHops, req.Namespace, req.RelationFilter)
if err != nil {
respondError(w, 500, "navigate failed: "+err.Error())
return
}
respond(w, 200, map[string]interface{}{"paths": paths, "entity": req.Entity, "count": len(paths)})
respond(w, 200, map[string]interface{}{
"paths": paths, "entity": req.Entity, "count": len(paths),
"relation_filter": req.RelationFilter,
})
}
// normalizeEntity 规整实体名:去特殊字符 + n_前缀保留中文和 Unicode 字符

View File

@ -402,7 +402,7 @@ func NewServer() http.Handler {
var noteResults []map[string]interface{}
var paths []map[string]interface{}
if entity != "" {
p, err := graphStore.Navigate(normalizeEntity(entity), maxHops, "")
p, err := graphStore.Navigate(normalizeEntity(entity), maxHops, "", nil)
if err == nil {
paths = p
entities := []string{normalizeEntity(entity)}

View File

@ -21,7 +21,7 @@ func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespa
// 从每个结果出发扩展
for _, r := range results {
paths, err := g.Navigate(r.Category, maxHops, namespace)
paths, err := g.Navigate(r.Category, maxHops, namespace, nil)
if err != nil {
continue
}
@ -63,7 +63,7 @@ func (g *InMemoryGraph) ExpandWithSummary(results []models.RecallResult, namespa
seenEntities[entity] = true
nodeID := normalizeEntityID(entity)
paths, _ := g.Navigate(nodeID, maxHops, namespace)
paths, _ := g.Navigate(nodeID, maxHops, namespace, nil)
for _, p := range paths {
from, _ := p["source"].(string)
to, _ := p["target"].(string)

View File

@ -208,9 +208,9 @@ func (fg *FileGraph) AddEdge(id, source, target, relation, namespace string, wei
return fg.save()
}
// Navigate 双向 BFS
func (fg *FileGraph) Navigate(entity string, maxHops int, namespace string) ([]map[string]interface{}, error) {
return fg.NavigateBiDir(entity, "", maxHops, namespace)
// Navigate 多跳 BFS 导航E1.4: relationFilter 支持)
func (fg *FileGraph) Navigate(entity string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
return fg.NavigateBiDir(entity, "", maxHops, namespace, relFilter)
}
// bfsNode 双向 BFS 节点(包级类型)
@ -223,8 +223,8 @@ type bfsNode struct {
rel string
}
// NavigateBiDir 双向 BFS — 从 source 和目标同时扩展,相遇时合并路径
func (fg *FileGraph) NavigateBiDir(source, target string, maxHops int, namespace string) ([]map[string]interface{}, error) {
// NavigateBiDir 双向 BFS — 从 source 和目标同时扩展,相遇时合并路径E1.1/E1.4
func (fg *FileGraph) NavigateBiDir(source, target string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
fg.mu.RLock()
defer fg.mu.RUnlock()
@ -446,7 +446,7 @@ func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace
}
for _, r := range results {
paths, err := fg.Navigate(r.Category, maxHops, namespace)
paths, err := fg.Navigate(r.Category, maxHops, namespace, nil)
if err != nil {
continue
}
@ -487,7 +487,7 @@ func (fg *FileGraph) ExpandWithSummary(results []models.RecallResult, namespace
seenEntities[entity] = true
nodeID := normalizeFileGraphEntityID(entity)
paths, _ := fg.Navigate(nodeID, maxHops, namespace)
paths, _ := fg.Navigate(nodeID, maxHops, namespace, nil)
for _, p := range paths {
from, _ := p["source"].(string)
to, _ := p["target"].(string)

View File

@ -2,6 +2,7 @@
package governance
import (
"fmt"
"sync"
)
@ -51,12 +52,27 @@ func (g *InMemoryGraph) AddEdge(id, source, target, relation, namespace string,
return nil
}
// Navigate 多跳 BFS 导航
func (g *InMemoryGraph) Navigate(entity string, maxHops int, namespace string) ([]map[string]interface{}, error) {
// relFilterOK 检查关系类型是否在白名单中nil=全部通过)
func relFilterOK(rel string, relFilter []string) bool {
if relFilter == nil {
return true
}
for _, r := range relFilter {
if r == rel {
return true
}
}
return false
}
// Navigate 多跳 BFS 导航E1.4: relationFilter 支持, E1.7: 环路检测)
func (g *InMemoryGraph) Navigate(entity string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
g.mu.RLock()
defer g.mu.RUnlock()
visited := map[string]bool{entity: true}
// E1.7: 环路检测 — 同一条边在单次 BFS 中不应被重复访问
seenEdges := map[string]bool{}
queue := []string{entity}
var paths []map[string]interface{}
@ -64,7 +80,11 @@ func (g *InMemoryGraph) Navigate(entity string, maxHops int, namespace string) (
var nextQueue []string
for _, current := range queue {
for _, e := range g.edges {
if e.Namespace != namespace {
if e.Namespace != namespace || !relFilterOK(e.Relation, relFilter) {
continue
}
// E1.7: 环路检测 — 跳过已访问边
if seenEdges[e.ID] {
continue
}
neighbor := ""
@ -76,6 +96,7 @@ func (g *InMemoryGraph) Navigate(entity string, maxHops int, namespace string) (
if neighbor == "" || visited[neighbor] {
continue
}
seenEdges[e.ID] = true // 标记边为已访问(环路检测)
visited[neighbor] = true
nextQueue = append(nextQueue, neighbor)
paths = append(paths, map[string]interface{}{
@ -93,6 +114,174 @@ func (g *InMemoryGraph) Navigate(entity string, maxHops int, namespace string) (
return paths, nil
}
// NavigateBiDir 真正双向 BFSE1.1 修复:对齐 SQLite 算法)
// E1.2: 无相遇节点时返回 {unreachable:true} 而非降级为单向邻居
func (g *InMemoryGraph) NavigateBiDir(source, target string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
if target == "" || target == source {
return g.Navigate(source, maxHops, namespace, relFilter)
}
g.mu.RLock()
defer g.mu.RUnlock()
// 构建邻接表(按 relationFilter 过滤)
adj := make(map[string][][2]string) // node -> []{neighbor, edge_id}
edgeInfo := make(map[string][2]string) // edge_id -> [relation, weight_str]
for _, e := range g.edges {
if e.Namespace != namespace || !relFilterOK(e.Relation, relFilter) {
continue
}
adj[e.Source] = append(adj[e.Source], [2]string{e.Target, e.ID})
adj[e.Target] = append(adj[e.Target], [2]string{e.Source, e.ID})
edgeInfo[e.ID] = [2]string{e.Relation, fmt.Sprintf("%f", e.Weight)}
}
type fwdNode struct {
parent string
edgeID string
weight float64
hop int
}
type bwdNode struct {
parent string
edgeID string
weight float64
hop int
}
fwd := make(map[string]*fwdNode)
bwd := make(map[string]*bwdNode)
fwdQ := []string{source}
fwd[source] = &fwdNode{hop: 0, weight: 1.0}
fwdVisited := map[string]bool{source: true}
bwdQ := []string{target}
bwd[target] = &bwdNode{hop: 0, weight: 1.0}
bwdVisited := map[string]bool{target: true}
fwdMax := (maxHops + 1) / 2
bwdMax := (maxHops + 1) / 2
// BFS 循环:双向交替扩展
for len(fwdQ) > 0 || len(bwdQ) > 0 {
// 正向扩展一轮
if len(fwdQ) > 0 {
var nextFwd []string
for i := 0; i < len(fwdQ); i++ {
curr := fwdQ[i]
if fwd[curr].hop >= fwdMax {
continue
}
for _, n := range adj[curr] {
ngh, eid := n[0], n[1]
if fwdVisited[ngh] {
continue
}
fwdVisited[ngh] = true
edgeW := 1.0
if info, ok := edgeInfo[eid]; ok {
fmt.Sscanf(info[1], "%f", &edgeW)
}
fwd[ngh] = &fwdNode{parent: curr, edgeID: eid, weight: fwd[curr].weight * edgeW, hop: fwd[curr].hop + 1}
nextFwd = append(nextFwd, ngh)
}
}
fwdQ = nextFwd
}
// 反向扩展一轮
if len(bwdQ) > 0 {
var nextBwd []string
for i := 0; i < len(bwdQ); i++ {
curr := bwdQ[i]
if bwd[curr].hop >= bwdMax {
continue
}
for _, n := range adj[curr] {
ngh, eid := n[0], n[1]
if bwdVisited[ngh] {
continue
}
bwdVisited[ngh] = true
edgeW := 1.0
if info, ok := edgeInfo[eid]; ok {
fmt.Sscanf(info[1], "%f", &edgeW)
}
bwd[ngh] = &bwdNode{parent: curr, edgeID: eid, weight: bwd[curr].weight * edgeW, hop: bwd[curr].hop + 1}
nextBwd = append(nextBwd, ngh)
}
}
bwdQ = nextBwd
}
// 检查相遇节点
for meet := range fwdVisited {
if bwdVisited[meet] && meet != source && meet != target {
// 重建完整路径
var fwdPath []string
c := meet
for c != source {
if c == "" || fwd[c] == nil {
break
}
fwdPath = append([]string{c}, fwdPath...)
c = fwd[c].parent
}
fwdPath = append([]string{source}, fwdPath...)
var bwdPath []string
c = meet
for c != target {
bwdPath = append(bwdPath, c)
if c == "" || bwd[c] == nil || bwd[c].parent == "" {
break
}
c = bwd[c].parent
}
bwdPath = append(bwdPath, target)
allNodes := append(fwdPath, bwdPath[1:]...)
score := fwd[meet].weight * bwd[meet].weight
// 构建边列表
var pathEdges []map[string]interface{}
cur := source
for _, node := range allNodes[1:] {
var edgeID, rel string
var w float64 = 1.0
if fn, ok := fwd[node]; ok && fn.parent != "" {
if info, ok2 := edgeInfo[fn.edgeID]; ok2 {
edgeID = fn.edgeID
rel = info[0]
fmt.Sscanf(info[1], "%f", &w)
}
}
pathEdges = append(pathEdges, map[string]interface{}{
"source": cur, "target": node,
"relation": rel, "weight": w, "edge_id": edgeID,
})
cur = node
}
return []map[string]interface{}{{
"nodes": allNodes,
"edges": pathEdges,
"score": score,
}}, nil
}
}
}
// E1.2: 无相遇节点时返回 unreachable而非降级为单向邻居
return []map[string]interface{}{{
"unreachable": true,
"source": source,
"target": target,
"max_hops": maxHops,
}}, nil
}
// Stats 返回图谱统计
func (g *InMemoryGraph) Stats() (nodeCount, edgeCount int, density float64) {
g.mu.RLock()
@ -158,15 +347,6 @@ func (g *InMemoryGraph) Query(entity, relation, namespace string) []map[string]i
return results
}
// NavigateBiDir 双向 BFS多 Agent 场景关键)
func (g *InMemoryGraph) NavigateBiDir(source, target string, maxHops int, namespace string) ([]map[string]interface{}, error) {
if target == "" || target == source {
return g.Navigate(source, maxHops, namespace)
}
paths, err := g.Navigate(source, maxHops, namespace)
return paths, err
}
// PageRank 计算节点重要性(多 Agent 引用加权)
func (g *InMemoryGraph) PageRank(damping float64, iterations int) map[string]float64 {
g.mu.RLock()
@ -235,7 +415,7 @@ func (g *InMemoryGraph) EvidenceCount(entity string) int {
// GetEntityDegree E4.3: 返回实体的图谱度(入度+出度),度越高越优先保留
func (g *InMemoryGraph) GetEntityDegree(entity string) int {
return g.EvidenceCount(entity) // 与 EvidenceCount 相同逻辑:统计 entity 作为 source 或 target 的边数
return g.EvidenceCount(entity)
}
func containsRelation(rel, substr string) bool {
@ -251,30 +431,21 @@ func (g *InMemoryGraph) GetGraph(namespace string, limit int) ([]map[string]inte
defer g.mu.RUnlock()
var nodes []map[string]interface{}
var edges []map[string]interface{}
// 导出匹配 namespace 的节点limit>0 时截断)
for _, n := range g.nodes {
if namespace == "" || n.Namespace == namespace {
nodes = append(nodes, map[string]interface{}{
"id": n.ID,
"name": n.Name,
"type": n.Type,
"namespace": n.Namespace,
"id": n.ID, "name": n.Name, "type": n.Type, "namespace": n.Namespace,
})
if limit > 0 && len(nodes) >= limit {
break
}
}
}
// 导出匹配 namespace 的边
for _, e := range g.edges {
if namespace == "" || e.Namespace == namespace {
edges = append(edges, map[string]interface{}{
"id": e.ID,
"source": e.Source,
"target": e.Target,
"relation": e.Relation,
"weight": e.Weight,
"namespace": e.Namespace,
"id": e.ID, "source": e.Source, "target": e.Target,
"relation": e.Relation, "weight": e.Weight, "namespace": e.Namespace,
})
}
}
@ -330,4 +501,4 @@ func searchSubstring(s, substr string) bool {
}
}
return false
}
}

View File

@ -210,12 +210,14 @@ func (gs *SQLiteGraphStore) AddEdge(id, source, target, relation, namespace stri
return execSQL(gs.db, sql)
}
func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace string) ([]map[string]interface{}, error) {
func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
// 单源 BFS从 entity 展开到邻居,不找路径
// E1.4: relFilter 白名单过滤关系类型
gs.mu.RLock()
defer gs.mu.RUnlock()
nsClause := buildNamespaceClause(namespace)
relClause := buildRelationFilterClause(relFilter)
visited := map[string]bool{entity: true}
queue := []string{entity}
@ -225,8 +227,8 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin
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)
"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 AND %s",
escape(node), nsClause, relClause)
edges := queryRows(gs.db, sql)
for _, edge := range edges {
target := edge["target"].(string)
@ -245,10 +247,10 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin
return paths, nil
}
// NavigateBiDir 真正的双向 BFS 路径查找§2.5.4
// NavigateBiDir 真正的双向 BFS 路径查找§2.5.4, E1.1, E1.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) {
func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
if source == target {
return []map[string]interface{}{
{
@ -429,10 +431,13 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
}
if len(out) == 0 {
// 没有路径时的降级:返回各自邻居展开
fwd, _ := gs.Navigate(source, maxHops, namespace)
bwd, _ := gs.Navigate(target, maxHops, namespace)
return append(fwd, bwd...), nil
// E1.2: 无相遇节点时返回 unreachable而非降级为单向邻居
return []map[string]interface{}{{
"unreachable": true,
"source": source,
"target": target,
"max_hops": maxHops,
}}, nil
}
return out, nil
}
@ -470,6 +475,32 @@ func buildNamespaceClause(namespace string) string {
return fmt.Sprintf("(e.namespace = '%s' OR e.namespace = 'default')", escape(derived))
}
// buildRelationFilterClause E1.4: 生成关系类型过滤 SQL 子句nil=不过滤)
func buildRelationFilterClause(relFilter []string) string {
if relFilter == nil || len(relFilter) == 0 {
return "1=1"
}
var parts []string
for _, r := range relFilter {
parts = append(parts, fmt.Sprintf("'%s'", escape(r)))
}
return fmt.Sprintf("e.relation IN (%s)", joinStrings(parts, ","))
}
func joinStrings(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
if len(parts) == 1 {
return parts[0]
}
result := parts[0]
for i := 1; i < len(parts); i++ {
result += sep + parts[i]
}
return result
}
// sortResultsByScore 简单选择排序
func sortResultsByScore(results []PathResult) {
for i := 0; i < len(results); i++ {
@ -598,7 +629,7 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
// SQLite 图谱节点 ID 格式: n_{entity_name},需 normalizeEntityID 转换
nodeID := normalizeEntityID(entity)
paths, _ := gs.Navigate(nodeID, maxHops, namespace)
paths, _ := gs.Navigate(nodeID, maxHops, namespace, nil)
for _, p := range paths {
// Navigate 返回字段: from, to, relation, weight, hop
var neighbor, rel string
@ -648,7 +679,7 @@ func (gs *SQLiteGraphStore) ExpandWithSummary(results []models.RecallResult, nam
seenEntities[entity] = true
nodeID := normalizeEntityID(entity)
paths, _ := gs.Navigate(nodeID, maxHops, namespace)
paths, _ := gs.Navigate(nodeID, maxHops, namespace, nil)
for _, p := range paths {
from, _ := p["from"].(string)
to, _ := p["to"].(string)

View File

@ -11,9 +11,9 @@ type GraphStore interface {
// 边操作
AddEdge(id, source, target, relation, namespace string, weight float64) error
// 查询
Navigate(entity string, maxHops int, namespace string) ([]map[string]interface{}, error)
NavigateBiDir(source, target string, maxHops int, namespace string) ([]map[string]interface{}, error)
// 查询relationFilter 传 nil 表示不限制关系类型)
Navigate(entity string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error)
NavigateBiDir(source, target string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error)
Query(entity, relation, namespace string) []map[string]interface{}
// 图节点搜索§2.5.4 match 格式兼容)