internal/index/fts.go
@@ -4,6 +4,7 @@
   "database/sql"
   "encoding/json"
   "sort"
   "strings"
)
// FTSResult 全文搜索结果
type FTSResult struct {
@@ -13,6 +14,7 @@
   Section string
   Rank    float64
   Aliases []string // 别名(JSON 解析)
   Tags    []string // 标签(JSON 解析)
   Status  string   // 状态(草稿/待确认/跟进中 等参与降权)
}
@@ -134,7 +136,17 @@
      return nil, nil
   }
   seen := make(map[int64]*FTSResult)
   hitCount := make(map[int64]int) // 每个节点被多少个关键词命中(截断排序用)
   firstIdx := make(map[int64]int) // 首命中位置(截断平手时的次序依据)
   var order []int64
   addCandidate := func(r *FTSResult) {
      if _, ok := seen[r.ID]; !ok {
         seen[r.ID] = r
         firstIdx[r.ID] = len(order)
         order = append(order, r.ID)
      }
      hitCount[r.ID]++
   }
   // 通道 1: FTS(ASCII 词)
   var ascii []string
@@ -144,26 +156,30 @@
      }
   }
   for _, kw := range ascii {
      // FTS5 查询语法里 "-" 是 MINUS 运算符、"(" 等是语法符,
      // 不引号包裹会报 "no such column" 被静默跳过(如 tag "fill-curve" 搜不到)。
      // 用双引号包裹成字符串字面量,内部双引号转义。
      quoted := `"` + strings.ReplaceAll(kw, `"`, `""`) + `"`
      rows, err := s.db.Query(`
         SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.status
         SELECT n.id, n.path, n.title, n.section, fts.rank, n.aliases, n.tags, 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)
         WHERE nodes_fts MATCH ? ORDER BY fts.rank LIMIT ?`, quoted, 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 {
         var aliasesJSON, tagsJSON string
         if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank, &aliasesJSON, &tagsJSON, &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)
         if tagsJSON != "" {
            _ = json.Unmarshal([]byte(tagsJSON), &r.Tags)
         }
         addCandidate(&r)
      }
      rows.Close()
   }
@@ -175,7 +191,7 @@
      }
      pat := "%" + kw + "%"
      rows, err := s.db.Query(`
         SELECT id, path, title, section, aliases, status FROM nodes
         SELECT id, path, title, section, aliases, tags, status FROM nodes
         WHERE title LIKE ? OR aliases LIKE ? OR content_fts LIKE ? OR tags LIKE ?`,
         pat, pat, pat, pat)
      if err != nil {
@@ -183,24 +199,37 @@
      }
      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 {
         var aliasesJSON, tagsJSON string
         if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &aliasesJSON, &tagsJSON, &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)
         if tagsJSON != "" {
            _ = json.Unmarshal([]byte(tagsJSON), &r.Tags)
         }
         r.Rank = 0 // LIKE 无 rank
         addCandidate(&r)
      }
      rows.Close()
   }
   // 截断排序:命中关键词多的节点优先保留,平手按首命中先后。
   // 旧实现按首命中顺序直接截断,高频泛词(如 bigram "电子" 命中 486 行)
   // 会把排在后面但与多词强相关的文档挤出候选集。
   ranked := make([]int64, len(order))
   copy(ranked, order)
   sort.SliceStable(ranked, func(i, j int) bool {
      a, b := ranked[i], ranked[j]
      if hitCount[a] != hitCount[b] {
         return hitCount[a] > hitCount[b]
      }
      return firstIdx[a] < firstIdx[b]
   })
   results := make([]FTSResult, 0, len(order))
   for _, id := range order {
   for _, id := range ranked {
      results = append(results, *seen[id])
   }
   if limit > 0 && len(results) > limit {