From 6b58a8fd1d10191d5720b037af1137d2a747cfeb Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 10:33:57 +0800
Subject: [PATCH] feat: add graph command for knowledge graph queries

---
 cmd/graph.go             |  240 ++++++++++++++++++++++++++
 internal/index/sqlite.go |  281 +++++++++++++++++++++++++++++++
 2 files changed, 521 insertions(+), 0 deletions(-)

diff --git a/cmd/graph.go b/cmd/graph.go
new file mode 100644
index 0000000..5f5d66d
--- /dev/null
+++ b/cmd/graph.go
@@ -0,0 +1,240 @@
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/aisim/kb-cli/internal/graph"
+	"github.com/aisim/kb-cli/internal/index"
+	"github.com/spf13/cobra"
+)
+
+var graphCmd = &cobra.Command{
+	Use:   "graph",
+	Short: "知识图谱查询与管理",
+}
+
+var graphStatsCmd = &cobra.Command{
+	Use:   "stats",
+	Short: "查看知识图谱统计信息",
+	Long:  `kb-cli graph stats [--json] # 查看知识图谱统计信息(节点数、边数、关系类型分布)`,
+	RunE:  runGraphStats,
+}
+
+var graphQueryCmd = &cobra.Command{
+	Use:   "query <关键词>",
+	Short: "查询节点的关联关系",
+	Long:  `kb-cli graph query <关键词> [--relation=<类型>] [--depth=<深度>] [--json] # 查询节点的关联关系`,
+	Args:  cobra.ExactArgs(1),
+	RunE:  runGraphQuery,
+}
+
+var graphRelatedCmd = &cobra.Command{
+	Use:   "related <关键词>",
+	Short: "查找与关键词相关的所有节点",
+	Long:  `kb-cli graph related <关键词> [--top N] [--json] # 查找与关键词相关的所有节点`,
+	Args:  cobra.ExactArgs(1),
+	RunE:  runGraphRelated,
+}
+
+var (
+	graphRelation string
+	graphDepth    int
+	graphJSON     bool
+	graphTopN     int
+)
+
+func init() {
+	rootCmd.AddCommand(graphCmd)
+	graphCmd.AddCommand(graphStatsCmd)
+	graphCmd.AddCommand(graphQueryCmd)
+	graphCmd.AddCommand(graphRelatedCmd)
+
+	// graph stats flags
+	graphStatsCmd.Flags().BoolVar(&graphJSON, "json", false, "JSON 格式输出")
+
+	// graph query flags
+	graphQueryCmd.Flags().StringVar(&graphRelation, "relation", "", "关系类型(tag/entity/wikilink)")
+	graphQueryCmd.Flags().IntVar(&graphDepth, "depth", 1, "查询深度(1-3)")
+	graphQueryCmd.Flags().BoolVar(&graphJSON, "json", false, "JSON 格式输出")
+
+	// graph related flags
+	graphRelatedCmd.Flags().IntVar(&graphTopN, "top", 10, "返回前 N 条结果")
+	graphRelatedCmd.Flags().BoolVar(&graphJSON, "json", false, "JSON 格式输出")
+}
+
+func runGraphStats(cmd *cobra.Command, args []string) error {
+	// 打开索引
+	store, err := index.Open(dbPath)
+	if err != nil {
+		return fmt.Errorf("打开索引失败: %w", err)
+	}
+	defer store.Close()
+
+	// 获取节点数和边数
+	nodeCount, err := store.NodeCount()
+	if err != nil {
+		return fmt.Errorf("获取节点数失败: %w", err)
+	}
+
+	edgeCount, err := store.EdgeCount()
+	if err != nil {
+		return fmt.Errorf("获取边数失败: %w", err)
+	}
+
+	// 获取关系类型分布
+	relationStats, err := store.GetRelationStats()
+	if err != nil {
+		return fmt.Errorf("获取关系统计失败: %w", err)
+	}
+
+	if graphJSON {
+		stats := map[string]interface{}{
+			"node_count": nodeCount,
+			"edge_count": edgeCount,
+			"relations":  relationStats,
+		}
+		data, err := json.MarshalIndent(stats, "", "  ")
+		if err != nil {
+			return err
+		}
+		fmt.Println(string(data))
+	} else {
+		fmt.Printf("知识图谱统计:\n")
+		fmt.Printf("  节点数: %d\n", nodeCount)
+		fmt.Printf("  边数: %d\n", edgeCount)
+		fmt.Printf("\n关系类型分布:\n")
+		for rel, count := range relationStats {
+			fmt.Printf("  %-12s %d\n", rel, count)
+		}
+	}
+
+	return nil
+}
+
+func runGraphQuery(cmd *cobra.Command, args []string) error {
+	keyword := args[0]
+
+	// 打开索引
+	store, err := index.Open(dbPath)
+	if err != nil {
+		return fmt.Errorf("打开索引失败: %w", err)
+	}
+	defer store.Close()
+
+	// 查找匹配关键词的节点
+	nodes, err := store.FindNodesByKeyword(keyword)
+	if err != nil {
+		return fmt.Errorf("查找节点失败: %w", err)
+	}
+
+	if len(nodes) == 0 {
+		fmt.Println("未找到匹配的节点")
+		return nil
+	}
+
+	// 查询每个节点的关联关系
+	var results []QueryResult
+	for _, node := range nodes {
+		edges, err := store.GetNodeEdges(node.ID, graphRelation, graphDepth)
+		if err != nil {
+			return fmt.Errorf("查询关联失败: %w", err)
+		}
+
+		results = append(results, QueryResult{
+			Node:  node,
+			Edges: edges,
+		})
+	}
+
+	if graphJSON {
+		data, err := json.MarshalIndent(results, "", "  ")
+		if err != nil {
+			return err
+		}
+		fmt.Println(string(data))
+	} else {
+		for _, r := range results {
+			fmt.Printf("\n节点: %s (%s)\n", r.Node.Title, r.Node.Path)
+			if len(r.Edges) == 0 {
+				fmt.Println("  无关联关系")
+				continue
+			}
+
+			// 按关系类型分组
+			grouped := make(map[string][]string)
+			for _, edge := range r.Edges {
+				grouped[edge.Relation] = append(grouped[edge.Relation], edge.Label)
+			}
+
+			for rel, labels := range grouped {
+				fmt.Printf("  [%s]\n", rel)
+				for _, label := range labels {
+					fmt.Printf("    - %s\n", label)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func runGraphRelated(cmd *cobra.Command, args []string) error {
+	keyword := args[0]
+
+	// 打开索引
+	store, err := index.Open(dbPath)
+	if err != nil {
+		return fmt.Errorf("打开索引失败: %w", err)
+	}
+	defer store.Close()
+
+	// 查找相关节点
+	related, err := store.FindRelatedNodes(keyword, graphTopN)
+	if err != nil {
+		return fmt.Errorf("查找相关节点失败: %w", err)
+	}
+
+	if len(related) == 0 {
+		fmt.Println("未找到相关节点")
+		return nil
+	}
+
+	if graphJSON {
+		data, err := json.MarshalIndent(related, "", "  ")
+		if err != nil {
+			return err
+		}
+		fmt.Println(string(data))
+	} else {
+		fmt.Printf("与 '%s' 相关的节点:\n\n", keyword)
+		for i, r := range related {
+			fmt.Printf("%d. %s\n", i+1, r.Title)
+			fmt.Printf("   路径: %s\n", r.Path)
+			fmt.Printf("   板块: %s\n", r.Section)
+			fmt.Printf("   关联度: %d\n", r.Relevance)
+			if len(r.Tags) > 0 {
+				fmt.Printf("   标签: %v\n", r.Tags)
+			}
+			fmt.Println()
+		}
+	}
+
+	return nil
+}
+
+// QueryResult 查询结果
+type QueryResult struct {
+	Node  *graph.Node  `json:"node"`
+	Edges []*graph.Edge `json:"edges"`
+}
+
+// RelatedNode 相关节点
+type RelatedNode struct {
+	ID        int64    `json:"id"`
+	Path      string   `json:"path"`
+	Title     string   `json:"title"`
+	Section   string   `json:"section"`
+	Tags      []string `json:"tags"`
+	Relevance int      `json:"relevance"`
+}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index db194fe..1501020 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -6,6 +6,7 @@
 	"fmt"
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
 
 	_ "github.com/mattn/go-sqlite3"
@@ -258,3 +259,283 @@
 	affected, _ := result.RowsAffected()
 	return int(affected), nil
 }
+
+// GetRelationStats 获取关系类型分布统计
+func (s *Store) GetRelationStats() (map[string]int, error) {
+	rows, err := s.db.Query(`
+		SELECT relation, COUNT(*) as count
+		FROM edges
+		GROUP BY relation
+		ORDER BY count DESC
+	`)
+	if err != nil {
+		return nil, fmt.Errorf("查询关系统计失败: %w", err)
+	}
+	defer rows.Close()
+
+	stats := make(map[string]int)
+	for rows.Next() {
+		var relation string
+		var count int
+		if err := rows.Scan(&relation, &count); err != nil {
+			return nil, fmt.Errorf("扫描关系统计失败: %w", err)
+		}
+		stats[relation] = count
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("遍历关系统计失败: %w", err)
+	}
+
+	return stats, nil
+}
+
+// FindNodesByKeyword 根据关键词查找节点(模糊匹配标题、路径、标签、实体)
+func (s *Store) FindNodesByKeyword(keyword string) ([]*graph.Node, error) {
+	keyword = "%" + keyword + "%"
+	query := `
+		SELECT id, path, title, section, tags, entities, wikilinks, content_fts
+		FROM nodes
+		WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
+		LIMIT 20
+	`
+	rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
+	if err != nil {
+		return nil, fmt.Errorf("查询节点失败: %w", err)
+	}
+	defer rows.Close()
+
+	var nodes []*graph.Node
+	for rows.Next() {
+		var n graph.Node
+		var tagsJSON, entitiesJSON, wikilinksJSON string
+		var content sql.NullString
+
+		err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section,
+			&tagsJSON, &entitiesJSON, &wikilinksJSON, &content)
+		if err != nil {
+			return nil, fmt.Errorf("扫描节点失败: %w", err)
+		}
+
+		json.Unmarshal([]byte(tagsJSON), &n.Tags)
+		json.Unmarshal([]byte(entitiesJSON), &n.Entities)
+		json.Unmarshal([]byte(wikilinksJSON), &n.Wikilinks)
+		if content.Valid {
+			n.Content = content.String
+		}
+
+		nodes = append(nodes, &n)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("遍历节点失败: %w", err)
+	}
+
+	return nodes, nil
+}
+
+// GetNodeEdges 获取节点的关联边(支持关系类型过滤和深度查询)
+func (s *Store) GetNodeEdges(nodeID int64, relation string, depth int) ([]*graph.Edge, error) {
+	if depth < 1 {
+		depth = 1
+	}
+	if depth > 3 {
+		depth = 3
+	}
+
+	var edges []*graph.Edge
+	visited := make(map[int64]bool)
+	currentLevel := []int64{nodeID}
+
+	for d := 0; d < depth; d++ {
+		var nextLevel []int64
+
+		for _, id := range currentLevel {
+			if visited[id] && d > 0 {
+				continue
+			}
+			visited[id] = true
+
+			query := `
+				SELECT id, from_node, to_node, relation, label
+				FROM edges
+				WHERE from_node = ?
+			`
+			args := []interface{}{id}
+
+			if relation != "" {
+				query += " AND relation = ?"
+				args = append(args, relation)
+			}
+
+			rows, err := s.db.Query(query, args...)
+			if err != nil {
+				return nil, fmt.Errorf("查询边失败: %w", err)
+			}
+
+			for rows.Next() {
+				var e graph.Edge
+				var edgeID int64
+				if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label); err != nil {
+					rows.Close()
+					return nil, fmt.Errorf("扫描边失败: %w", err)
+				}
+				edges = append(edges, &e)
+				nextLevel = append(nextLevel, e.ToNode)
+			}
+			rows.Close()
+		}
+
+		currentLevel = nextLevel
+	}
+
+	return edges, nil
+}
+
+// FindRelatedNodes 查找与关键词相关的所有节点(通过共同标签、实体、wikilink)
+func (s *Store) FindRelatedNodes(keyword string, topN int) ([]RelatedNode, error) {
+	// 先找到匹配关键词的节点
+	keyword = "%" + keyword + "%"
+	query := `
+		SELECT id, path, title, section, tags, entities
+		FROM nodes
+		WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
+		LIMIT 5
+	`
+	rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
+	if err != nil {
+		return nil, fmt.Errorf("查询节点失败: %w", err)
+	}
+	defer rows.Close()
+
+	var seedNodes []*graph.Node
+	for rows.Next() {
+		var n graph.Node
+		var tagsJSON, entitiesJSON string
+		err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section, &tagsJSON, &entitiesJSON)
+		if err != nil {
+			return nil, fmt.Errorf("扫描节点失败: %w", err)
+		}
+		json.Unmarshal([]byte(tagsJSON), &n.Tags)
+		json.Unmarshal([]byte(entitiesJSON), &n.Entities)
+		seedNodes = append(seedNodes, &n)
+	}
+
+	if len(seedNodes) == 0 {
+		return []RelatedNode{}, nil
+	}
+
+	// 收集种子节点的标签和实体
+	tagSet := make(map[string]bool)
+	entitySet := make(map[string]bool)
+	for _, n := range seedNodes {
+		for _, tag := range n.Tags {
+			tagSet[tag] = true
+		}
+		for _, entity := range n.Entities {
+			entitySet[entity] = true
+		}
+	}
+
+	// 查找共享标签或实体的节点
+	relevanceMap := make(map[int64]int)
+	pathMap := make(map[int64]string)
+	titleMap := make(map[int64]string)
+	sectionMap := make(map[int64]string)
+	tagsMap := make(map[int64][]string)
+
+	// 通过标签查找
+	for tag := range tagSet {
+		tagPattern := "%" + tag + "%"
+		rows, err := s.db.Query(`
+			SELECT id, path, title, section, tags
+			FROM nodes
+			WHERE tags LIKE ?
+		`, tagPattern)
+		if err != nil {
+			continue
+		}
+
+		for rows.Next() {
+			var id int64
+			var path, title, section, tagsJSON string
+			if err := rows.Scan(&id, &path, &title, &section, &tagsJSON); err != nil {
+				rows.Close()
+				continue
+			}
+			relevanceMap[id]++
+			pathMap[id] = path
+			titleMap[id] = title
+			sectionMap[id] = section
+			var tags []string
+			json.Unmarshal([]byte(tagsJSON), &tags)
+			tagsMap[id] = tags
+		}
+		rows.Close()
+	}
+
+	// 通过实体查找
+	for entity := range entitySet {
+		entityPattern := "%" + entity + "%"
+		rows, err := s.db.Query(`
+			SELECT id, path, title, section, tags
+			FROM nodes
+			WHERE entities LIKE ?
+		`, entityPattern)
+		if err != nil {
+			continue
+		}
+
+		for rows.Next() {
+			var id int64
+			var path, title, section, tagsJSON string
+			if err := rows.Scan(&id, &path, &title, &section, &tagsJSON); err != nil {
+				rows.Close()
+				continue
+			}
+			relevanceMap[id]++
+			pathMap[id] = path
+			titleMap[id] = title
+			sectionMap[id] = section
+			var tags []string
+			json.Unmarshal([]byte(tagsJSON), &tags)
+			tagsMap[id] = tags
+		}
+		rows.Close()
+	}
+
+	// 转换为结果列表
+	var results []RelatedNode
+	for id, relevance := range relevanceMap {
+		results = append(results, RelatedNode{
+			ID:        id,
+			Path:      pathMap[id],
+			Title:     titleMap[id],
+			Section:   sectionMap[id],
+			Tags:      tagsMap[id],
+			Relevance: relevance,
+		})
+	}
+
+	// 按相关度排序
+	sort.Slice(results, func(i, j int) bool {
+		return results[i].Relevance > results[j].Relevance
+	})
+
+	// 限制返回数量
+	if topN > 0 && len(results) > topN {
+		results = results[:topN]
+	}
+
+	return results, nil
+}
+
+// RelatedNode 相关节点
+type RelatedNode struct {
+	ID        int64    `json:"id"`
+	Path      string   `json:"path"`
+	Title     string   `json:"title"`
+	Section   string   `json:"section"`
+	Tags      []string `json:"tags"`
+	Relevance int      `json:"relevance"`
+}

--
Gitblit v1.9.1