From 5a2be87f658d01bc5b3b7ecdba92ac69bf09dcf7 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Thu, 03 Sep 2026 15:15:37 +0800
Subject: [PATCH] fix(search): 搜索准确性四连修——FTS5 MATCH 引号包裹(连字符不再被解析成 MINUS);KeywordSearch 截断按命中关键词数排序(高频 bigram 不再挤出强相关文档);原词奖励档(完整短语压过泛化 bigram);tags 字段真实参与评分(此前误用 Section)

---
 internal/index/fts.go |   59 ++++++++++++++++++++++++++++++++++++++++++++---------------
 1 files changed, 44 insertions(+), 15 deletions(-)

diff --git a/internal/index/fts.go b/internal/index/fts.go
index 552b428..83e7838 100644
--- a/internal/index/fts.go
+++ b/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 {

--
Gitblit v1.10.0