internal/index/fts.go
@@ -12,27 +12,50 @@
   Title   string
   Section string
   Rank    float64
   Aliases []string // 别名(JSON 解析)
   Status  string   // 状态(草稿/待确认/跟进中 等参与降权)
}
// CreateFTS 创建 FTS5 虚拟表
// CreateFTS 创建 FTS5 虚拟表(external-content 模式,由触发器增量维护)
// 注意:external-content 模式下 FTS 列名必须与 content 表列名一致,
// 故 content 列对应 nodes 表的 content_fts 列
func (s *Store) CreateFTS() error {
   _, err := s.db.Exec(`
      CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
         title,
         content,
         tags,
         entities
      )
   `)
   return err
         title, content_fts, tags, aliases,
         content='nodes', content_rowid='id'
      )`)
   if err != nil {
      return err
   }
   // 触发器:nodes 的增删改同步维护 FTS
   triggers := []string{
      `CREATE TRIGGER IF NOT EXISTS nodes_fts_ai AFTER INSERT ON nodes BEGIN
         INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
         VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
      END`,
      `CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
         INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
         VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
      END`,
      `CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
         INSERT INTO nodes_fts(nodes_fts, rowid, title, content_fts, tags, aliases)
         VALUES ('delete', old.id, old.title, old.content_fts, old.tags, old.aliases);
         INSERT INTO nodes_fts(rowid, title, content_fts, tags, aliases)
         VALUES (new.id, new.title, new.content_fts, new.tags, new.aliases);
      END`,
   }
   for _, t := range triggers {
      if _, err := s.db.Exec(t); err != nil {
         return err
      }
   }
   return nil
}
// PopulateFTS 填充 FTS 索引
// PopulateFTS 全量重灌 FTS(external-content 模式专用语法)
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
   `)
   _, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
   return err
}
@@ -49,7 +72,7 @@
   for _, kw := range keywords {
      rows, err := s.db.Query(`
         SELECT n.id, n.path, n.title, n.section, fts.rank
         SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
         FROM nodes_fts fts
         JOIN nodes n ON n.id = fts.rowid
         WHERE nodes_fts MATCH ?
@@ -63,10 +86,14 @@
      for rows.Next() {
         var r FTSResult
         if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
         var aliasesJSON string
         if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
            rows.Close()
            continue
         }
         if aliasesJSON != "" {
            _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
         }
         // 去重
         if !seen[r.ID] {
            seen[r.ID] = true
@@ -89,6 +116,99 @@
   return allResults, nil
}
// containsCJK 是否含 CJK 统一表意文字
func containsCJK(s string) bool {
   for _, r := range s {
      if r >= 0x4E00 && r <= 0x9FFF {
         return true
      }
   }
   return false
}
// KeywordSearch 双通道关键词检索:ASCII 词走 FTS5 MATCH,CJK 词走 LIKE(title/aliases/content/tags)。
// FTS5 unicode61 把连续中文当整串单 token,多字符 CJK 词 MATCH 匹配不到,必须走 LIKE。
// 结果按"首命中顺序"排列(FTS 通道按 rank,LIKE 通道补在尾部),engine 再按双信号重排。
func (s *Store) KeywordSearch(keywords []string, limit int) ([]FTSResult, error) {
   if len(keywords) == 0 {
      return nil, nil
   }
   seen := make(map[int64]*FTSResult)
   var order []int64
   // 通道 1: FTS(ASCII 词)
   var ascii []string
   for _, kw := range keywords {
      if !containsCJK(kw) {
         ascii = append(ascii, kw)
      }
   }
   for _, kw := range ascii {
      rows, err := s.db.Query(`
         SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
         FROM nodes_fts fts JOIN nodes n ON n.id = fts.rowid
         WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, kw, limit)
      if err != nil {
         continue // 语法异常词跳过
      }
      for rows.Next() {
         var r FTSResult
         var aliasesJSON string
         if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &r.Status); err != nil {
            continue
         }
         if aliasesJSON != "" {
            _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
         }
         if _, ok := seen[r.ID]; !ok {
            seen[r.ID] = &r
            order = append(order, r.ID)
         }
      }
      rows.Close()
   }
   // 通道 2: LIKE(CJK 词)
   for _, kw := range keywords {
      if !containsCJK(kw) {
         continue
      }
      pat := "%" + kw + "%"
      rows, err := s.db.Query(`
         SELECT id, path, title, section, aliases, status FROM nodes
         WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
         pat, pat, pat, pat)
      if err != nil {
         continue
      }
      for rows.Next() {
         var r FTSResult
         var aliasesJSON string
         if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &r.Status); err != nil {
            continue
         }
         if aliasesJSON != "" {
            _ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
         }
         r.Rank = 0 // LIKE 无 rank
         if _, ok := seen[r.ID]; !ok {
            seen[r.ID] = &r
            order = append(order, r.ID)
         }
      }
      rows.Close()
   }
   results := make([]FTSResult, 0, len(order))
   for _, id := range order {
      results = append(results, *seen[id])
   }
   if limit > 0 && len(results) > limit {
      results = results[:limit]
   }
   return results, nil
}
// GetNodeContent 获取节点内容
func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) {
   var content, tagsJSON, entitiesJSON string