package index
|
|
import (
|
"database/sql"
|
"encoding/json"
|
"sort"
|
)
|
// FTSResult 全文搜索结果
|
type FTSResult struct {
|
ID int64
|
Path string
|
Title string
|
Section string
|
Rank float64
|
Aliases []string // 别名(JSON 解析)
|
Status string // 状态(草稿/待确认/跟进中 等参与降权)
|
}
|
|
// 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_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(external-content 模式专用语法)
|
func (s *Store) PopulateFTS() error {
|
_, err := s.db.Exec(`INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')`)
|
return err
|
}
|
|
// FTSSearch 全文搜索
|
func (s *Store) FTSSearch(keywords []string, limit int) ([]FTSResult, error) {
|
if len(keywords) == 0 {
|
return nil, nil
|
}
|
|
// 对每个关键词单独搜索,然后合并结果(去重)
|
// 这样即使某个关键词匹配不到,其他关键词也能找到结果
|
seen := make(map[int64]bool)
|
var allResults []FTSResult
|
|
for _, kw := range keywords {
|
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 {
|
rows.Close()
|
continue
|
}
|
if aliasesJSON != "" {
|
_ = json.Unmarshal([]byte(aliasesJSON), &r.Aliases)
|
}
|
// 去重
|
if !seen[r.ID] {
|
seen[r.ID] = true
|
allResults = append(allResults, r)
|
}
|
}
|
rows.Close()
|
}
|
|
// 按 rank 排序(FTS5 的 rank 越小越好)
|
sort.Slice(allResults, func(i, j int) bool {
|
return allResults[i].Rank < allResults[j].Rank
|
})
|
|
// 限制返回数量
|
if limit > 0 && len(allResults) > limit {
|
allResults = allResults[:limit]
|
}
|
|
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
|
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
|
}
|