From 7445005745523d430a3cd22c3208cce2f383f1ee Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sat, 25 Jul 2026 23:45:49 +0800
Subject: [PATCH] feat: 实现 SQLite 存储层与 FTS5 全文索引
---
internal/index/fts_test.go | 54 +++++++
internal/index/fts.go | 101 ++++++++++++++
kb | 0
internal/index/sqlite_test.go | 66 +++++++++
internal/index/sqlite.go | 149 +++++++++++++++++++++
5 files changed, 370 insertions(+), 0 deletions(-)
diff --git a/internal/index/fts.go b/internal/index/fts.go
new file mode 100644
index 0000000..5e67985
--- /dev/null
+++ b/internal/index/fts.go
@@ -0,0 +1,101 @@
+package index
+
+import (
+ "database/sql"
+ "encoding/json"
+ "strings"
+)
+
+// FTSResult 全文搜索结果
+type FTSResult struct {
+ ID int64
+ Path string
+ Title string
+ Section string
+ Rank float64
+}
+
+// CreateFTS 创建 FTS5 虚拟表
+func (s *Store) CreateFTS() error {
+ _, err := s.db.Exec(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
+ title,
+ content,
+ tags,
+ entities,
+ content='nodes',
+ content_rowid='id'
+ )
+ `)
+ return err
+}
+
+// PopulateFTS 填充 FTS 索引
+func (s *Store) PopulateFTS() error {
+ _, err := s.db.Exec(`
+ INSERT INTO nodes_fts(rowid, title, content, tags, entities)
+ SELECT id, title, content_fts, tags, entities FROM nodes
+ `)
+ return err
+}
+
+// FTSSearch 全文搜索
+func (s *Store) FTSSearch(keywords []string, limit int) ([]FTSResult, error) {
+ if len(keywords) == 0 {
+ return nil, nil
+ }
+
+ // 构建 FTS5 查询
+ query := strings.Join(keywords, " OR ")
+
+ rows, err := s.db.Query(`
+ SELECT n.id, n.path, n.title, n.section, fts.rank
+ FROM nodes_fts fts
+ JOIN nodes n ON n.id = fts.rowid
+ WHERE nodes_fts MATCH ?
+ ORDER BY fts.rank
+ LIMIT ?
+ `, query, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []FTSResult
+ for rows.Next() {
+ var r FTSResult
+ if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
+ return nil, err
+ }
+ results = append(results, r)
+ }
+ return results, nil
+}
+
+// GetNodeContent 获取节点内容
+func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) {
+ var content, tagsJSON, entitiesJSON string
+ err := s.db.QueryRow("SELECT content_fts, tags, entities FROM nodes WHERE id = ?", id).
+ Scan(&content, &tagsJSON, &entitiesJSON)
+ if err == sql.ErrNoRows {
+ return "", nil, nil, nil
+ }
+ if err != nil {
+ return "", nil, nil, err
+ }
+
+ // 解析 JSON 数组
+ var tags, entities []string
+ if tagsJSON != "" {
+ if err := json.Unmarshal([]byte(tagsJSON), &tags); err != nil {
+ tags = nil
+ }
+ }
+ if entitiesJSON != "" {
+ if err := json.Unmarshal([]byte(entitiesJSON), &entities); err != nil {
+ entities = nil
+ }
+ }
+
+ return content, tags, entities, nil
+}
diff --git a/internal/index/fts_test.go b/internal/index/fts_test.go
new file mode 100644
index 0000000..1a42430
--- /dev/null
+++ b/internal/index/fts_test.go
@@ -0,0 +1,54 @@
+package index
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/aisim/kb-cli/internal/graph"
+)
+
+func TestFTSSearch(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+
+ store, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ // 插入测试数据
+ store.InsertNode(&graph.Node{
+ ID: 1,
+ Path: "FAQ/充装类/001-test.md",
+ Title: "充装问题排查",
+ Section: "FAQ",
+ Tags: []string{"充装"},
+ Content: "关于智能枪充装问题的排查方法",
+ })
+ store.InsertNode(&graph.Node{
+ ID: 2,
+ Path: "知识/002-config.md",
+ Title: "充装规格配置",
+ Section: "知识",
+ Tags: []string{"配置"},
+ Content: "充装规格配置说明",
+ })
+
+ // 创建 FTS
+ if err := store.CreateFTS(); err != nil {
+ t.Fatalf("CreateFTS failed: %v", err)
+ }
+ if err := store.PopulateFTS(); err != nil {
+ t.Fatalf("PopulateFTS failed: %v", err)
+ }
+
+ // 搜索 - FTS5 对中文按字符 tokenize,"充装" 两个节点都包含
+ results, err := store.FTSSearch([]string{"充装"}, 10)
+ if err != nil {
+ t.Fatalf("FTSSearch failed: %v", err)
+ }
+ if len(results) < 1 {
+ t.Errorf("result count = %d, want >= 1", len(results))
+ }
+}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
new file mode 100644
index 0000000..e1086df
--- /dev/null
+++ b/internal/index/sqlite.go
@@ -0,0 +1,149 @@
+package index
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/aisim/kb-cli/internal/graph"
+)
+
+// Store SQLite 存储层
+type Store struct {
+ db *sql.DB
+}
+
+// Open 打开或创建数据库
+func Open(dbPath string) (*Store, error) {
+ // 确保目录存在
+ dir := filepath.Dir(dbPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("创建目录失败: %w", err)
+ }
+
+ db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
+ if err != nil {
+ return nil, fmt.Errorf("打开数据库失败: %w", err)
+ }
+
+ s := &Store{db: db}
+ if err := s.initTables(); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+// Close 关闭数据库
+func (s *Store) Close() error {
+ return s.db.Close()
+}
+
+// initTables 创建表结构
+func (s *Store) initTables() error {
+ schema := `
+ CREATE TABLE IF NOT EXISTS meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS nodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ path TEXT NOT NULL UNIQUE,
+ title TEXT,
+ section TEXT,
+ tags TEXT,
+ entities TEXT,
+ wikilinks TEXT,
+ content_fts TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS edges (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_node INTEGER NOT NULL,
+ to_node INTEGER NOT NULL,
+ relation TEXT NOT NULL,
+ label TEXT,
+ UNIQUE(from_node, to_node, relation, label)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_nodes_section ON nodes(section);
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node);
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node);
+ CREATE INDEX IF NOT EXISTS idx_edges_label ON edges(label);
+ `
+ _, err := s.db.Exec(schema)
+ if err != nil {
+ return fmt.Errorf("创建表失败: %w", err)
+ }
+ return nil
+}
+
+// ClearData 清空数据(重建前调用)
+func (s *Store) ClearData() error {
+ _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;")
+ return err
+}
+
+// InsertNode 插入节点
+func (s *Store) InsertNode(n *graph.Node) (int64, error) {
+ tagsJSON, _ := json.Marshal(n.Tags)
+ entitiesJSON, _ := json.Marshal(n.Entities)
+ wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+
+ result, err := s.db.Exec(`
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, content_fts)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+ string(wikilinksJSON), n.Content)
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+// InsertEdge 插入边
+func (s *Store) InsertEdge(e *graph.Edge) error {
+ _, err := s.db.Exec(`
+ INSERT OR IGNORE INTO edges (from_node, to_node, relation, label)
+ VALUES (?, ?, ?, ?)
+ `, e.FromNode, e.ToNode, e.Relation, e.Label)
+ return err
+}
+
+// SetMeta 设置元信息
+func (s *Store) SetMeta(key, value string) error {
+ _, err := s.db.Exec(`
+ INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)
+ `, key, value)
+ return err
+}
+
+// GetMeta 获取元信息
+func (s *Store) GetMeta(key string) (string, error) {
+ var value string
+ err := s.db.QueryRow("SELECT value FROM meta WHERE key = ?", key).Scan(&value)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return value, err
+}
+
+// NodeCount 返回节点数量
+func (s *Store) NodeCount() (int, error) {
+ var count int
+ err := s.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count)
+ return count, err
+}
+
+// EdgeCount 返回边数量
+func (s *Store) EdgeCount() (int, error) {
+ var count int
+ err := s.db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&count)
+ return count, err
+}
diff --git a/internal/index/sqlite_test.go b/internal/index/sqlite_test.go
new file mode 100644
index 0000000..f83b4bb
--- /dev/null
+++ b/internal/index/sqlite_test.go
@@ -0,0 +1,66 @@
+package index
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/aisim/kb-cli/internal/graph"
+)
+
+func TestStore(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+
+ store, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer store.Close()
+
+ // 测试插入节点
+ node := &graph.Node{
+ Path: "FAQ/充装类/001-test.md",
+ Title: "测试FAQ",
+ Section: "FAQ",
+ Tags: []string{"充装", "智能枪"},
+ }
+ id, err := store.InsertNode(node)
+ if err != nil {
+ t.Fatalf("InsertNode failed: %v", err)
+ }
+ if id == 0 {
+ t.Error("InsertNode returned 0 id")
+ }
+
+ // 测试插入边
+ edge := &graph.Edge{
+ FromNode: id,
+ ToNode: 1000000,
+ Relation: "tag",
+ Label: "充装",
+ }
+ if err := store.InsertEdge(edge); err != nil {
+ t.Fatalf("InsertEdge failed: %v", err)
+ }
+
+ // 测试元信息
+ if err := store.SetMeta("git_commit", "abc123"); err != nil {
+ t.Fatalf("SetMeta failed: %v", err)
+ }
+ commit, err := store.GetMeta("git_commit")
+ if err != nil {
+ t.Fatalf("GetMeta failed: %v", err)
+ }
+ if commit != "abc123" {
+ t.Errorf("commit = %q, want %q", commit, "abc123")
+ }
+
+ // 测试计数
+ count, err := store.NodeCount()
+ if err != nil {
+ t.Fatalf("NodeCount failed: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("node count = %d, want 1", count)
+ }
+}
diff --git a/kb b/kb
index 97f3ce2..6db78e1 100755
--- a/kb
+++ b/kb
Binary files differ
--
Gitblit v1.9.1