Files
HomeAgent/internal/knowledge/knowledge.go
JianFeeeee a68d6a428c feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:18:24 +08:00

1360 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package knowledge
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
type Knowledge struct {
Name string `json:"name"`
Content string `json:"content"`
Path string `json:"path"`
Category string `json:"category,omitempty"` // 父路径,如 "tech/go"
Tags []string `json:"tags"`
UpdatedAt time.Time `json:"updated_at"`
Meta map[string]string `json:"meta,omitempty"`
// Dense 是该条目在**多模态统一空间**(image ⊕ text 共享坐标系)里的向量。
// nil 表示未嵌入或嵌入失败——检索侧会被长度守卫跳过,退回流沙两路。
// 不落 content.md:它是可重算的派生数据,落盘只会多个会失效的副本。
Dense []float64 `json:"-"`
// DenseFP 是产生 Dense 的模型空间标识(≠ 当前 fingerprint 时视为过期)。
DenseFP string `json:"-"`
// Media 是本条目携带的媒体块(媒体是**一等节点**:由自己的向量参与
// 召回,不依赖任何生成的描述文本)。与 Dense 一同内存持有。
Media []MediaRef `json:"-"`
}
// MediaRef 是媒体在知识条目里的一等引用。Digest 是内容派生的媒体身份
// (与记忆块的 `MediaBlockID` 同构:同内容⇒同 Digest),但**字节不在别处**:
// 字节作为条目自带数据落在条目目录的 `File` 上(与 content.md 并列,随条目生灭)。
// 向量不入侧车——它随条目在内存/稠密缓存里按需算。
//
// 命名为 KnowledgeMediaRef 而非 MediaRef,是为了不与别的包的类型撞名。
type KnowledgeMediaRef struct {
Digest string `json:"digest"`
MIME string `json:"mime"`
Kind string `json:"kind,omitempty"`
// File 是媒体字节在**条目目录内**的相对文件名(随条目生灭)。
//
// ★ 2026-10-11 CAS 淘汰:媒体字节不再寄存于全局内容寻址库,而是作为
// 条目自带数据落在 entry 目录里(与 content.md、.media.json 并列)。
// Remove 的 os.RemoveAll 天然把它清掉,不留孤儿。
File string `json:"file,omitempty"`
// Data 是写入期的瞬态字节:putMediaFile → Write 之间搬运,Write 落盘后清空。
// json:"-" 保证它不进 .media.json 侧车(侧车只记引用,不记字节)。
Data []byte `json:"-"`
}
// KnowledgeMediaRef 的别名,照顾内部可读性。
type MediaRef = KnowledgeMediaRef
// IndexItem — 索引条目,包含向量特征和内容摘要
type IndexItem struct {
Name string `json:"name"`
Preview string `json:"preview"` // 前 200 字摘要
Tags []string `json:"tags"`
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
Size int `json:"size"` // 内容总字节数
}
// TreeIndex — 树状索引节点
type TreeIndex struct {
Name string `json:"name"`
Children map[string]*TreeIndex `json:"children,omitempty"`
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
}
func newTreeIndex(name string) *TreeIndex {
return &TreeIndex{Name: name, Children: make(map[string]*TreeIndex)}
}
// compressVector 压缩向量:保留 topN 个权重最高的特征
func compressVector(v vector.Vector, topN int) map[string]float64 {
if len(v) <= topN {
out := make(map[string]float64, len(v))
for k, w := range v {
out[k] = w
}
return out
}
type kv struct {
k string
v float64
}
sorted := make([]kv, 0, len(v))
for k, w := range v {
sorted = append(sorted, kv{k, w})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].v > sorted[j].v
})
if topN > len(sorted) {
topN = len(sorted)
}
sorted = sorted[:topN]
out := make(map[string]float64, topN)
for _, kv := range sorted {
out[kv.k] = kv.v
}
return out
}
type Store struct {
root string
vec *vector.Store
veczer *vector.TFIDFVectorizer
// lex 是**词法路**索引(TF-IDF),与 vec(稀疏语义路)同为
// **退化路径**的组成部分。
//
// ★ 主路径已改为纯稠密向量检索(2026-10-08):注入多模态空间时
// SearchIn 直接用 denseHits,**不碰这两路**。它们只在未注入空间
// 的部署里生效(见 SearchIn 的退化路径注释)。
//
// 为何退化路径还要两路:那个组合(词向量/TF-IDF 语义 + TF-IDF 词法)
// 在 33 条小 KB + 稀疏 fastText 上实测比单路好(MRR 0.271→0.376)。
// 该结论**不适用于主路径** —— 换成 chineseclip 后稠密路全库排名 1/192,
// 融合反而把它拖垮(实测 top1 6/10 → 1/10)。
lex *vector.Store
mu sync.RWMutex
items map[string]*Knowledge
indexPath string
denseCachePath string
denseDirty bool
// indexDirty 标记 .index.json 过期。批量导入时置位但**不立即写**,
// 由 flushIndex 收口:实测 writeIndexLocked 是 Add 的主开销
// (N=400 时 6.7ms/次,占单条 Add 的绝大部分)。
indexDirty bool
// batchDepth > 0 表示处于批量写入期(ImportDir)。此时逐条写出的
// flushDenseLocked 只标脏不落盘,由 endBatch 收口一次 —— 否则批量导入
// 的派生数据写入量是 O(N²)(见 import.go 的说明)。
batchDepth int
// denseCacheLoaded 保证缓存只尝试恢复一次;scanned 表示 items 已扫盘就绪。
// 两个状态位缺一不可:接线(SetDenseSpace)与扫盘(Start)的先后顺序
// 在调用方是自由的,缓存恢复必须等**两者都就绪**才可能成功,
// 因此不能在任一单点里无条件做,只能在每次都试一下。
denseCacheLoaded bool
scanned bool
vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF
// dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**:
// vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供**退化路径**使用;
// dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」
// 「按文搜含图知识」成立。文档记忆(docStore)走的就是后者。
//
// 为何不把稠密向量塞进 s.vec:vector.Store 是稀疏倒排结构
// (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶,
// 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。
// 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。
dense vector.MultimodalEmbedder
}
// mediaSidecarName 是每条知识目录下存放媒体引用的文件名。
//
// 为何媒体引用必须落盘:它是**作者数据**(谁给哪条知识挂了哪张图),
// 不是可重算的派生量。此前它只存在于内存,进程一重启 scanDir 重建条目时
// Media 就空了 —— 图片关联静默消失,而且因为不报错,没有任何迹象。
// 放在条目目录内(与 content.md 并列)而非全局文件:随条目一起生灭,
// Remove 的 os.RemoveAll 天然把它清掉,不会留下孤儿记录。
const mediaSidecarName = ".media.json"
// denseCacheEntry 是单条知识的稠密向量缓存。
type denseCacheEntry struct {
Dense []float64 `json:"dense"`
FP string `json:"fp"`
Dim int `json:"dim"`
}
// denseCache 是稠密向量的全局缓存文件。
//
// 它是**派生数据**(可由 content.md ⊕ 媒体重算),损坏/丢失只会导致一次
// 重算,不会丢内容。因此与媒体引用分开存放、分开承担风险。
type denseCache struct {
Fingerprint string `json:"fingerprint"`
Dim int `json:"dim"`
Entries map[string]denseCacheEntry `json:"entries"`
}
func NewStore(root string) *Store {
return &Store{
root: root,
indexPath: filepath.Join(root, ".index.json"),
denseCachePath: filepath.Join(root, ".dense.json"),
vec: vector.NewStore(),
lex: newLexicalStore(),
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
items: make(map[string]*Knowledge),
}
}
// newLexicalStore 造词法路存储。阈值设为 0:TF-IDF 余弦量级只有 0.0~0.2,
// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。
func newLexicalStore() *vector.Store {
st := vector.NewStore()
st.SetMinScore(0)
return st
}
// SetDenseSpace 注入多模态统一向量空间(text ↔ image 共享坐标系)。
// 未注入时知识库退化为原有的稀疏两路(词向量 + TF-IDF),保持既有行为。
func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) {
s.mu.Lock()
defer s.mu.Unlock()
s.dense = ds
// 接线与扫盘的先后顺序由调用方决定;这里补一次尝试,确保
// 「先 Start 后 SetDenseSpace」(agent 的现行顺序)也能命中缓存。
s.maybeLoadDenseCacheLocked()
}
// denseEnabled 报告稠密路是否可用(供 Stats/自证与分支判断)。
// 调用方必须已持锁。
func (s *Store) denseEnabled() bool {
return s.dense != nil && s.dense.Loaded()
}
// denseFor 计算一条知识的稠密向量:正文文本向量 ⊕ 各媒体块向量。
//
// 只有与当前空间**同指纹且同维度**的媒体向量才参与融合。只比指纹不够:
// 指纹相同但维度不同的向量会被 FuseVectors 按最大维度拼成错维度结果,
// 而它下次又被当成"已对齐",就永远错下去(docStore 里踩过同一个坑)。
//
// 返回 nil 表示本条目在当前空间下无向量(检索侧会跳过它)。
func (s *Store) denseFor(k *Knowledge) []float64 {
if !s.denseEnabled() {
return nil
}
dim := s.dense.Dim()
var parts [][]float64
if tv, err := s.dense.VectorizeDense(k.Name + " " + k.Content); err == nil && len(tv) == dim {
parts = append(parts, tv)
}
dir := filepath.Dir(k.Path)
for _, m := range k.Media {
if v := s.mediaDense(m, dir); v != nil {
parts = append(parts, v)
}
}
return vector.FuseVectors(parts...)
}
// mediaDense 读条目目录里的媒体字节并嵌入。任何一步拿不到就返回 nil——
// 媒体缺失不应让整条知识失去文本向量。
//
// ★ 2026-10-11 CAS 淘汰:字节直接从条目目录的 File 读,不再经 mediaGet。
func (s *Store) mediaDense(m KnowledgeMediaRef, dir string) []float64 {
if !s.denseEnabled() || m.File == "" {
return nil
}
data, err := os.ReadFile(filepath.Join(dir, m.File))
if err != nil || len(data) == 0 {
return nil
}
mime := m.MIME
if mime == "" {
mime = "application/octet-stream"
}
v, err := s.dense.EmbedImageDense(data, mime)
if err != nil || len(v) != s.dense.Dim() {
// 模态不在本空间覆盖范围内(如音频)时返回的是
// ErrModalityUnsupported:那是「永久无向量」,不是「本次失败」。
// 两者都不重试、也不拿别的模型的向量顶替。
return nil
}
return v
}
// 为一条知识算稠密向量。
//
// 融合顺序为 [文本, 媒体...]:FuseVectors 是逐维求和,对交换律不敏感,
// 顺序不影响结果;这里固定下来只为让日志/调试可复现。
func (s *Store) ReindexDense() (built, skipped int) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.denseEnabled() {
return 0, 0
}
fp := s.dense.Fingerprint()
dim := s.dense.Dim()
for _, k := range s.items {
if len(k.Dense) == dim && k.DenseFP == fp {
continue
}
v := s.denseFor(k)
if v == nil {
skipped++
continue
}
k.Dense, k.DenseFP = v, fp
built++
}
// 落盘,否则磁盘缓存永远对不上当前空间,判定条件永远成立 ——
// 每次启动都重算同一批(docStore 的 BuildDenseIndex 踩过这个坑)。
if built > 0 {
s.denseDirty = true
}
s.flushDenseLocked()
log.Printf("[knowledge] dense reindex complete: built=%d skipped=%d fp=%s dim=%d", built, skipped, shortFP(fp), dim)
return built, skipped
}
// AttachMedia 给一条已有知识挂上媒体,并**当场重算**它的稠密向量。
//
// 单独抽出来的理由:媒体入库(AddWithMedia)与媒体后续到达是两条时序,
// 前者少见(大多数场景是先有知识、图片晚一点才上传)。不重算的话,
// 新挂的媒体要等下次 ReindexDense 才参与召回。
func (s *Store) AttachMedia(name string, media ...KnowledgeMediaRef) error {
s.mu.Lock()
defer s.mu.Unlock()
id, k, err := s.resolve(name)
if err != nil {
return err
}
k.Media = append(k.Media, media...)
// 媒体引用与向量都要落盘:引用是作者数据,向量是派生缓存。
if err := writeMediaSidecar(filepath.Dir(k.Path), k.Media); err != nil {
return fmt.Errorf("写媒体引用失败: %w", err)
}
if s.denseEnabled() {
if v := s.denseFor(k); v != nil {
k.Dense, k.DenseFP = v, s.dense.Fingerprint()
s.denseDirty = true
}
}
s.flushDenseLocked()
_ = id
return nil
}
// DenseStats 报告稠密路的接线与覆盖情况,供状态页/自证使用。
func (s *Store) DenseStats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]interface{}{
"enabled": s.denseEnabled(),
}
if s.denseEnabled() {
fp := s.dense.Fingerprint()
dim := s.dense.Dim()
ready, stale := 0, 0
for _, k := range s.items {
switch {
case len(k.Dense) == dim && k.DenseFP == fp:
ready++
default:
stale++
}
}
out["fingerprint"] = fp
out["dim"] = dim
out["ready"] = ready
out["stale"] = stale
}
return out
}
// SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF
func (s *Store) SetVectorizer(v vector.Vectorizer) {
s.vectorizer = v
}
// ReindexWithVectorizer 用给定的向量化器重建稀疏两路索引。
// 稠密路不在此重建:它由独立的 ReindexDense 负责(换模型只影响它)。
func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items))
s.vec = vector.NewStore()
// 词法路的 IDF 必须建在全语料上(否则 IDF 没意义)
s.retrainLexLocked()
for _, k := range s.items {
text := k.Name + " " + k.Content
s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{
"name": k.Name, "path": k.Path,
})
}
log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size())
}
// lexText 是一条知识参与**词法路**(TF-IDF)统计的文本。
//
// 为何用规范名而不是原始入参名:DF 统计必须与 lex 里实际插入的文档
// **逐字一致**,否则重启后重新 Train 的 DF 与运行时增量维护的 DF 会对不上,
// IDF 悄悄漂移。取名一律以 items 里的规范名为准。
func lexText(id, content string) string { return id + " " + content }
// indexDocLocked 把一条知识登记进词法路索引并更新 IDF 统计。
//
// IDF 与索引必须**同步**维护:只插索引不更新 DF,新引入的词 df=0 会被
// Vectorize 当作未知词跳过,于是「新增的知识当场搜不到,重启后才恢复」。
// 同一个词的 DF 也不能重复计:覆盖写同名条目时先 RemoveDoc 旧文本。
// 调用方必须已持写锁。
func (s *Store) indexDocLocked(id string, k *Knowledge) {
text := lexText(id, k.Content)
if old, ok := s.items[id]; ok && old != nil {
s.veczer.RemoveDoc(lexText(id, old.Content))
}
s.veczer.AddDoc(text)
s.lex.Remove(id)
s.lex.Insert(id, id+": "+k.Content, s.veczer.Vectorize(text), nil)
}
// unindexDocLocked 把一条知识从词法路索引与 IDF 统计里同时摘掉。
// 调用方必须已持写锁。
func (s *Store) unindexDocLocked(id string, k *Knowledge) {
if k != nil {
s.veczer.RemoveDoc(lexText(id, k.Content))
}
s.lex.Remove(id)
}
// retrainLexLocked 从当前 items 全量重建词法路索引与 IDF。
// 启动与全量重建时走这条(比逐条增量更简单也更一致)。
// 调用方必须已持写锁。
func (s *Store) retrainLexLocked() {
texts := make([]string, 0, len(s.items))
for id, k := range s.items {
texts = append(texts, lexText(id, k.Content))
}
if len(texts) > 0 {
s.veczer.Train(texts)
} else {
s.veczer.Train(nil)
}
s.lex = newLexicalStore()
for id, k := range s.items {
text := lexText(id, k.Content)
s.lex.Insert(id, id+": "+k.Content, s.veczer.Vectorize(text), nil)
}
}
// vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF
func (s *Store) vectorize(text string) vector.Vector {
if s.vectorizer != nil {
return s.vectorizer.Vectorize(text)
}
return s.veczer.Vectorize(text)
}
func (s *Store) Start() error {
if err := os.MkdirAll(s.root, 0755); err != nil {
return fmt.Errorf("knowledge root: %w", err)
}
if err := s.scanAll(); err != nil {
log.Printf("[knowledge] scan error: %v", err)
}
// 稠密向量是派生数据:先尝试从缓存恢复,避免每次启动对每条知识
// 重跑一次嵌入(外部 HTTP 嵌入服务下就是 N 次网络调用)。
s.loadDenseCache()
// 重建索引文件(启动时必写一次,之后标脏延迟到 Flush/Stop)
if err := s.writeIndex(); err != nil {
log.Printf("[knowledge] write index error: %v", err)
}
log.Printf("[knowledge] started with %d items, %d vectors", len(s.items), s.vec.Size())
return nil
}
// Stop 落盘未持久化的派生数据(稠密向量缓存)。
// 正文与媒体引用在写入时已落盘,这里只是补上派生缓存。
func (s *Store) Stop() {
if err := s.Flush(); err != nil {
log.Printf("[knowledge] flush on stop: %v", err)
}
}
// 退化路径(未注入多模态空间时)的稀疏两路权重。
//
// 正常路径已不再做融合(稠密路是唯一召回路径,见 Search 的注释),
// 这个权重只影响**没配多模态空间的部署**——那些部署仍靠稀疏两路检索。
//
// sparseSemWeight 是稀疏预算里语义路占的比例(剩下给词法路)。
// 0.5 即历史上实测最优的「语义 0.5 / 词法 0.5」。保留 var 是为了
// rankdiag_test 的 KB_DIAG_SWEEP 仍可扫描。
var sparseSemWeight = 0.5
// denseSpaceWeight 已随「稠密路成为唯一召回路径」退场(2026-10-08):
// 既然不再融合,就不存在「稠密路占多少预算」的问题。
// 保留本注释是因为 rankdiag_test 的历史实测门槛引用过它。
// Search 检索知识库:**稠密向量路是唯一召回路径**。
//
// # 为何不再做三路融合(2026-10-08 改)
//
// 此前是「稠密 + 稀疏语义(词向量/TF-IDF)+ 词法(TF-IDF)」三路融合,
// 每路按**查询内最大值**归一化到 1.0 再加权。这个归一化是致命的:
//
// 实测(真实 KB 192 条)词法路对 `the_jet_engine` 的原始分只有 0.027,
// 归一化后变成 1.0,与稠密路的 1.0 **等权相加** —— 弱信号被抬到
// 与强信号同样的分量,噪声压倒信号。
//
// 后果是检索几乎完全失效:10 条带标注查询里 top-1 只命中 1 条,
// 连条目自己的全名都搜不到(`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`),
// 用户看到的是「查什么都返回同样的几个无关条目」。
//
// 而**稠密路单独打分时表现极好** —— 同一批查询全库排名 1/192:
//
// the_jet_engine → the_jet_engine_rolls-royce 0.769
// plugin_dev_sdk → plugin_dev_sdk 0.852
// homeagent_identity → homeagent_identity 0.823
//
// 改后实测:top1 1/10 → 6/10,top3 2/10 → 6/10。
//
// # 为何能去掉归一化
//
// 余弦分数**本身就是可比的**(同一空间、同一维度下),不需要逐路归一化。
// 归一化当初是为了把「量级不同的三路」拉到同一尺度,现在只剩一路,
// 它反而成了破坏排序的东西。
//
// # 历史结论为何在此时翻转
//
// 旧注释引的实测(仅稠密路 top-1 15%、MRR 0.271;融合后 21% / 0.376)
// 做在两处**已不再成立的前提**上:
// - 33 条的小规模 KB(现在是 192 条,且含大量英文标识符)
// - 稀疏 fastText 词向量(现在是 chineseclip 512 维多模态空间)
//
// 换成真正的多模态向量空间后,稠密路的语义能力与当时不可同日而语,
// 而稀疏路的「对专名/术语强」在**英文标识符**上反而因 jieba 把
// the_jet_engine 切成 [t h e _ j n g ...] 而完全失效——两条无关文档
// 的 token 重叠率高达 50%。
//
// 回归判据:dense_recall_quality_test.go(带标注的 10 条查询集)。
func (s *Store) Search(query string, topK int) []*Knowledge {
return s.SearchIn(query, "", topK)
}
// SearchIn 与 Search 同语义,但可把召回范围限定在某个分类子树内。
//
// category 为空 = 全库(等价于 Search)。非空时**前缀匹配**该分类路径:
// 查 "tech" 命中 "tech/go"、"tech/rust" 下的条目;查 "tech/go" 只命中
// 它的子孙。这样分层才真正参与召回——此前分层只是存储布局,检索是全库
// 平铺,`SearchTree`/`SearchCategories` 两个死代码想做的事没落到检索上。
//
// 为何用前缀而不是精确相等:分类是**层级**,不是标签。要么看整棵子树,
// 要么用精确路径定位到某一层;只匹配精确相等会让 "tech" 查不到
// "tech/go" 里的东西,那正是层级索引最该提供的价值。
func (s *Store) SearchIn(query, category string, topK int) []*Knowledge {
s.mu.RLock()
defer s.mu.RUnlock()
category = strings.Trim(strings.TrimSpace(category), "/")
inScope := func(id string) bool {
if category == "" {
return true
}
k, ok := s.items[id]
if !ok {
return false
}
// 条目自身的 Category 或条目全名以该前缀开头都算命中:
// Category 是父路径,而条目全名是 category/叶名,两者都要覆盖
// (顶层无分类的条目 Category 为空,只能靠全名判断)。
return k.Category == category ||
strings.HasPrefix(k.Category, category+"/") ||
strings.HasPrefix(id, category+"/")
}
if topK <= 0 {
topK = 5
}
if category != "" && !s.hasInScopeLocked(category) {
return nil // 该分类下没有任何条目,省掉全库打分
}
// ★ 稠密向量路是**唯一召回路径**(2026-10-08)。
//
// 详见 Search 的文档注释:三路融合的逐路归一化把 0.02x 的弱信号
// 抬到与稠密路的 1.0 等权,导致检索几乎完全失效(top1 1/10)。
// 这里直接返回稠密路结果,不经任何融合。
if s.denseEnabled() {
qv, err := s.dense.VectorizeDense(query)
if err != nil || len(qv) == 0 {
// 向量化失败或得到空向量(空/纯空白查询)。
// 稠密路是唯一路径,故返回空而不是退回稀疏路——
// 退回就等于把已验证的退化行为又放回来。
return nil
}
hits := s.denseHits(qv)
out := make([]*Knowledge, 0, topK)
for _, h := range hits {
if !inScope(h.id) {
continue
}
if k, ok := s.items[h.id]; ok {
out = append(out, k)
}
if len(out) >= topK {
break
}
}
return out
}
// ——— 退化路径:未注入多模态空间 ———
//
// 有意保留,不是遗漏:把 core.memory.multimodal_space.provider 留空的
// 部署仍需可检索,否则知识库直接变成「完全不能用」——那是比排序差
// 更严重的失败。此路径的排序质量与接入稠密路之前逐字一致
// (见 TestNoDenseSpaceKeepsLegacyBehavior)。
if s.vec.Size() == 0 && s.lex.Size() == 0 {
return nil
}
// 各路分别打分,再按查询内最大值归一化加权融合。
// 三个来源形状不同(稀疏路给 DocVectorHit),统一成 scoreHit 再交给
// addPath 收口。
scores := make(map[string]float64)
addPath := func(hits []scoreHit, weight float64) {
// 归一化取**作用域内**的最大值:拿全库最大值归一会让限定分类后的
// 分数被一个范围外的条目压低,跨路相对权重随之失真。
max := 0.0
for _, h := range hits {
if h.score > max && inScope(h.id) {
max = h.score
}
}
if max <= 0 {
return // 该路对这条查询(在作用域内)没有信号,全量让给其余路
}
for _, h := range hits {
if !inScope(h.id) {
continue
}
scores[h.id] += weight * h.score / max
}
}
// 稀疏语义路 + 词法路(仅退化路径使用)。
denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size())
lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())
addPath(toHits(denseHits), sparseSemWeight)
addPath(toHits(lexHits), 1-sparseSemWeight)
if len(scores) == 0 {
return nil
}
ids := make([]string, 0, len(scores))
for id := range scores {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool {
if scores[ids[i]] != scores[ids[j]] {
return scores[ids[i]] > scores[ids[j]]
}
return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复)
})
var out []*Knowledge
for _, id := range ids {
if k, ok := s.items[id]; ok {
out = append(out, k)
}
if len(out) >= topK {
break
}
}
return out
}
// KnowledgeEntryInput 是知识条目的写入参数(纯文本 / 带媒体)。
// 走 struct 而非多个位置参数:媒体与文本在 5 个方法里成对出现,
// 位置参数会让调用点难以自明(且带媒体时必填空串)。
type KnowledgeEntryInput struct {
Name string
Content string
Media []KnowledgeMediaRef
}
// Add 写入一条纯文本知识。媒体请用 AddWithMedia。
func (s *Store) Add(name, content string) error {
return s.AddWithMedia(name, content, nil)
}
// AddWithMedia 写入一条知识,可携带媒体块(媒体作为一等节点参与稠密召回)。
//
// 与 Add 的区别只在于媒体:稠密向量会把正文向量与各媒体向量**融合**成一个
// 向量(同一坐标系内求和后归一化),所以一条带图的知识既能被文字搜到,
// 也能被“这张图”本身搜到。
//
// 为何 EmbedImageDense 可能失败:当前模态不在本空间覆盖范围(如音频)时返回
// ErrModalityUnsupported。此时**静默跳过该媒体**、仅用文本建立向量——
// 绝不能拿另一个模型的向量顶替,那会把两套坐标系混进同一空间,相似度全无意义。
func (s *Store) AddWithMedia(name, content string, media []KnowledgeMediaRef) error {
return s.Write(KnowledgeEntryInput{Name: name, Content: content, Media: media})
}
// Write 按输入参数写入一条知识。
func (s *Store) Write(in KnowledgeEntryInput) error {
s.mu.Lock()
defer s.mu.Unlock()
// id 同时是三样东西:内存 map 的键、LLM 可见的知识名、盘上相对目录。
// 三者必须逐字相同——扫盘重建(scanDir)读回的是真实目录名,若与 Add 时的
// 键不一致,重启那一刻知识名就变了,knowledge_list / knowledge_delete 的
// key 全部对不上,删除还会静默失败(详见 Remove)。
id, err := normalizeName(in.Name)
if err != nil {
return err
}
category := ""
if idx := strings.LastIndex(id, "/"); idx >= 0 {
category = id[:idx]
}
dir := filepath.Join(s.root, filepath.FromSlash(id))
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create knowledge dir: %w", err)
}
path := filepath.Join(dir, "content.md")
if err := os.WriteFile(path, []byte(in.Content), 0644); err != nil {
return fmt.Errorf("write knowledge: %w", err)
}
// 媒体字节落进条目目录(CAS 淘汰后,字节是条目自带数据)。
// 失败只跳过该媒体并记录——正文已落盘,图片写不进顶多搜不到图。
if err := s.writeMediaFiles(dir, in.Media); err != nil {
log.Printf("[knowledge] media bytes write error for %s: %v", in.Name, err)
}
now := time.Now()
k := &Knowledge{
Name: id,
Content: in.Content,
Path: path,
Category: category,
Tags: memory.ExtractKeywords(filepath.ToSlash(id) + " " + in.Content),
UpdatedAt: now,
Media: in.Media,
}
s.items[id] = k
// 覆盖同名条目时必须先摘掉旧向量。
//
// vector.Store.Insert 是**追加**语义(s.docs = append + index.Add),不按 id
// 去重。少了这一步,更新一条知识会在向量索引里留下上一版的副本:条目数看起来
// 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。
s.vec.Remove(id)
text := in.Name + " " + in.Content
vec := s.vectorize(text)
s.vec.Insert(id, in.Name+": "+in.Content, vec, map[string]string{
"name": in.Name, "path": path,
})
// 词法路:重建本条索引 + 增量维护 IDF(覆盖写时先摘掉旧文本的贡献)
s.indexDocLocked(id, k)
// 媒体引用是作者数据,必须落盘(放条目目录内,随条目生灭)。
if err := writeMediaSidecar(dir, in.Media); err != nil {
// 侧车写失败不阻断知识本身:正文已落盘,媒体丢了只影响跨模态召回,
// 且下次 AddWithMedia/AttachMedia 会补写。但要留下痕迹。
log.Printf("[knowledge] media sidecar write error for %s: %v", in.Name, err)
}
// 稠密路:算完就挂上,使新写入的条目立即可被跨模态召回命中
// (不必等下次 ReindexDense)。
if s.denseEnabled() {
if v := s.denseFor(k); v != nil {
k.Dense, k.DenseFP = v, s.dense.Fingerprint()
s.denseDirty = true
}
}
s.flushDenseLocked()
// 索引写入改为「标脏 + 延迟收口」,见 indexDirty 字段注释。
s.indexDirty = true
log.Printf("[knowledge] added: %s (%d bytes, %d media)", in.Name, len(in.Content), len(in.Media))
return nil
}
// 树状检索与分类检索已于 2026-09 移除:全仓无调用方,且停留在 Search 修复
// **之前**的单路口径(直接 s.vec.Search,无词法融合、0.05 阈值)。
// 留着它们等于埋一份已知的检索质量回归;真需要按分类召回,应给 Search 加
// category 过滤参数,而不是复活这两个。
// Remove 删除一条知识。
//
// 盘上路径取自**条目自记的 Path**(Add 写入 / scanDir 扫盘时记下的事实),
// 不再用 name 重新拼一遍:拼出来的路径和真实落点只要有一个字符对不上,
// os.RemoveAll 就删空目录返 nil,工具层回报"已删除"而文件与索引条目都还在。
//
// 不存在的条目返回 ErrNotFound(webui 的 DELETE 处理器把 error 映射成 404,
// 正是这个语义)。只删不存在的条目是幂等操作,不算错误。
func (s *Store) Remove(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
id, k, err := s.resolve(name)
if err != nil {
return err
}
itemDir := filepath.Dir(k.Path)
if err := os.RemoveAll(itemDir); err != nil {
return err
}
// 顺带清掉空掉的分类目录:名字去掉最后一段就是分类路径,分类下最后一条
// 被删后目录会空留在盘上,越积越多。只往上到 s.root 为止,**绝不动 root**
// (root 被删 = 整个知识库连同索引一起没了)。
rootClean := filepath.Clean(s.root)
for dir := filepath.Clean(filepath.Dir(itemDir)); strings.HasPrefix(dir, rootClean+string(filepath.Separator)); dir = filepath.Dir(dir) {
if err := os.Remove(dir); err != nil {
break // 非空或无权限,留给上层判断
}
}
s.unindexDocLocked(id, k)
delete(s.items, id)
s.vec.Remove(id)
s.indexDirty = true
return nil
}
func (s *Store) Stats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
return map[string]interface{}{
"knowledge_count": len(s.items),
"vector_count": s.vec.Size(),
"root": s.root,
"index_file": s.indexPath,
}
}
func (s *Store) List() []string {
s.mu.RLock()
defer s.mu.RUnlock()
var names []string
for _, k := range s.items {
names = append(names, k.Name)
}
sort.Strings(names)
return names
}
// BuildTree 从当前知识库构建树状索引(含向量特征)
func (s *Store) BuildTree() *TreeIndex {
s.mu.RLock()
defer s.mu.RUnlock()
return s.buildTreeLocked()
}
// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。
// 为什么需要:writeIndex 会走 BuildTree(RLock),而 Add/Remove 持的是写锁,
// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里,
// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。
// buildTreeLocked 从当前条目重建树状索引。
//
// 向量直接取自 s.vec(稀疏语义路的既有结果),**不再逐条重算**:
// 此前每条都调一次 s.vectorize(),那是全量分词 + TF-IDF 加权,
// 而结果与 s.vec 里已经存着的向量是同一个东西。实测这是 Add 单条
// 耗时随库规模线性增长的主因(2.1ms@50 → 13.7ms@400)。
// 调用方必须已持锁。
func (s *Store) buildTreeLocked() *TreeIndex {
// 一次 O(N) 取全量向量建表(纯内存拷贝),替代 N 次分词计算。
vecByID := make(map[string]vector.Vector, s.vec.Size())
for _, d := range s.vec.All() {
vecByID[d.ID] = d.Vector
}
root := newTreeIndex("root")
for _, k := range s.items {
node := root
if k.Category != "" {
parts := strings.Split(k.Category, "/")
for _, part := range parts {
if part == "" {
continue
}
if _, ok := node.Children[part]; !ok {
node.Children[part] = newTreeIndex(part)
}
node = node.Children[part]
}
}
// 取该条目的向量并压缩(命中不到就留空向量,不再回退去重算——
// 那会把本函数重新拖回 O(N × 分词))
vec := vecByID[k.Name]
preview := []rune(k.Content)
previewStr := ""
if len(preview) > 200 {
previewStr = string(preview[:200]) + "..."
} else {
previewStr = string(preview)
}
item := IndexItem{
Name: k.Name,
Preview: previewStr,
Tags: k.Tags,
Vector: compressVector(vec, 20),
Size: len(k.Content),
}
node.Items = append(node.Items, item)
}
return root
}
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
func (s *Store) writeIndex() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.flushIndexLocked()
}
// flushIndexLocked 把过期的 .index.json 写回(调用方须持写锁)。
//
// 为什么延迟:批量导入 400 条就是 400 次全量序列化(实测 6.7ms/次),
// 而该文件**目前没有任何读取方**(Start 是全量扫盘重建索引)。
// 改为标脏 + 在 Stop/Flush 时收口,导入成本降为一次写。
// 若将来真把它当缓存读回,必须先把"读取"实现补上,再考虑是否仍需延迟。
func (s *Store) flushIndexLocked() error {
if !s.indexDirty {
return nil
}
if err := s.writeIndexLocked(); err != nil {
log.Printf("[knowledge] write index error: %v", err)
return err
}
s.indexDirty = false
return nil
}
// Flush 把待落盘的派生数据(树索引)写回。批量导入后由调用方显式调用,
// 否则要等 Stop。
func (s *Store) Flush() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.flushDenseLocked(); err != nil {
return err
}
return s.flushIndexLocked()
}
// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。
func (s *Store) writeIndexLocked() error {
tree := s.buildTreeLocked()
data, err := json.MarshalIndent(tree, "", " ")
if err != nil {
return err
}
// tmp + rename:直接 os.WriteFile 会在中途崩溃时留下半截 JSON。
// 本文件目前没有任何读取方(Start 是全量扫盘重建索引),所以损坏的
// 后果只是「导出物不可读」;但那是运气,不该依赖——何况将来若真把它
// 当缓存读回来,半截文件会被当成有效索引。
tmp := s.indexPath + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
if err := os.Rename(tmp, s.indexPath); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// ——— internal ———
func (s *Store) scanAll() error {
entries, err := os.ReadDir(s.root)
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// skip hidden dirs
if strings.HasPrefix(entry.Name(), ".") {
continue
}
s.scanDir("", entry.Name())
}
s.retrainLexLocked()
for _, k := range s.items {
text := k.Name + " " + k.Content
s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{
"name": k.Name, "path": k.Path,
})
}
s.scanned = true
// 若接线早于扫盘(测试与部分调用方会这么做),这里补一次缓存恢复。
s.maybeLoadDenseCacheLocked()
return nil
}
// scanDir 递归扫描目录
// category: 父级路径(从知识库根目录算起),如 "tech/go"
// dirName: 当前目录相对路径(从知识库根目录算起)
func (s *Store) scanDir(category, dirName string) {
dir := filepath.Join(s.root, dirName)
contentPath := filepath.Join(dir, "content.md")
data, err := os.ReadFile(contentPath)
if err == nil {
name := dirName
content := string(data)
now := time.Now()
k := &Knowledge{
Name: name,
Content: content,
Path: contentPath,
Category: category,
Tags: memory.ExtractKeywords(dirName + " " + content),
UpdatedAt: now,
}
k.Media = readMediaSidecar(dir)
s.items[name] = k
return
}
// 无 content.md => 是分类目录,递归子目录
subEntries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, sub := range subEntries {
if !sub.IsDir() || strings.HasPrefix(sub.Name(), ".") {
continue
}
childDir := dirName + "/" + sub.Name()
s.scanDir(dirName, childDir)
}
}
// ErrInvalidName 表示知识名不合法:空段、`.`、`..` 或以点开头的段。
var ErrInvalidName = errors.New("knowledge: 知识名不合法")
// ErrNotFound 表示要删除/读取的知识不存在。
var ErrNotFound = errors.New("knowledge: 知识不存在")
// normalizeName 把外部传入的知识名规范成**唯一**的规范名。
//
// 规范名同时充当三样东西:内存 map 的键、LLM 可见的知识名、盘上相对目录。
// 三者必须逐字相同——扫盘重建(scanDir)读回的是真实目录名,若与 Add 时的
// 键不一致,重启那一刻知识名就变了,knowledge_list / knowledge_delete 的
// key 全部对不上,删除还会静默失败(详见 Remove)。
//
// 为何**逐段** sanitize 而非整串:sanitize 内含 TrimSpace,只作用于整串两端。
// 整串处理时 "tech/ Go /note" 得到 id="tech/_go_/note"(段内前后空格变 "_"),
// 而建目录时逐段 sanitize 得到 "tech/_go/note"(段内空格被 TrimSpace 掉)——
// 两者从**第一次落盘起**就对不上。这不是重启才产生的漂移。
func normalizeName(name string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("%w: 空名", ErrInvalidName)
}
segs := strings.Split(name, "/")
out := make([]string, 0, len(segs))
for _, seg := range segs {
s := sanitize(seg)
// 空段 / "." / ".." 会让 filepath.Join 逃出知识根(实测 Remove("..")
// 直接删掉整个 data 目录);以点开头的段会被 scanDir 当隐藏目录跳过,
// 变成"内存有、盘上扫不回"的幽灵条目。
if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") {
return "", fmt.Errorf("%w: %q 含有空段、点段或隐藏段 %q", ErrInvalidName, name, seg)
}
out = append(out, s)
}
return strings.Join(out, "/"), nil
}
// findByLeaf 按最后一段(叶名)找条目,返回命中的 id 与命中数(大小写敏感)。
// 保留给需要精确叶名的调用方;resolve 用的是 findByLeafFold。
func (s *Store) findByLeaf(id string) (string, int) {
leaf := id
if idx := strings.LastIndex(id, "/"); idx >= 0 {
leaf = id[idx+1:]
}
found, n := "", 0
for k := range s.items {
l := k
if idx := strings.LastIndex(k, "/"); idx >= 0 {
l = k[idx+1:]
}
if l == leaf {
found, n = k, n+1
}
}
return found, n
}
func sanitize(name string) string {
name = strings.ToLower(name)
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, " ", "_")
name = strings.ReplaceAll(name, "\\", "_")
return name
}
// findByLeafFold 与 findByLeaf 同义,但叶名比较大小写不敏感——
// 遗留盘上目录可能带大写。
func (s *Store) findByLeafFold(want string) (string, int) {
want = strings.ToLower(want)
found, n := "", 0
for k := range s.items {
l := k
if idx := strings.LastIndex(k, "/"); idx >= 0 {
l = k[idx+1:]
}
if strings.ToLower(l) == want {
found, n = k, n+1
}
}
return found, n
}
// resolve 把外部传入的名字解析到一个真实存在的条目。
//
// 为何不能只查规范名:scanDir 是按**盘上目录原样**建键的,所以修复前 Add
// 留下的目录(大写、带空格,如 "Tech/Upper")在 items 里的键就是那个原样名。
// 直接拿 normalizeName 的结果去查会查不中,而盘上条目又确实存在——
// 结果就是老条目删不掉、清不清(实测)。查找按三层退让,但**删的路径
// 永远取自条目自记的 Path**,所以退让本身不带来误删风险。
func (s *Store) resolve(name string) (string, *Knowledge, error) {
// 1. 原样精确匹配(scanDir 建键与新建的规范名都会命中这里)
if k, ok := s.items[name]; ok {
return name, k, nil
}
norm, nerr := normalizeName(name)
leaf := name
if i := strings.LastIndex(name, "/"); i >= 0 {
leaf = name[i+1:]
}
// 2. 规范名匹配
if nerr == nil {
if k, ok := s.items[norm]; ok {
return norm, k, nil
}
if i := strings.LastIndex(norm, "/"); i >= 0 {
leaf = norm[i+1:]
} else {
leaf = norm
}
}
// 3. 叶名匹配(大小写不敏感)。唯一命中才接受——多条同名时宁可不删,
// 也不能猜错目录。
if id, n := s.findByLeafFold(leaf); n == 1 {
return id, s.items[id], nil
} else if n > 1 {
return "", nil, fmt.Errorf("%w: %q 命中 %d 条条目,请用全名", ErrNotFound, name, n)
}
// 都不中:名字本身非法就报非法(更具体),否则就是不存在。
if nerr != nil {
return "", nil, nerr
}
return "", nil, fmt.Errorf("%w: %q", ErrNotFound, name)
}
// scoreHit 是融合三路时统一的 (条目, 相似度) 形状。包级命名而非函数内
// 匿名 struct:三个来源(稠密/稀疏语义/词法)必须落在**同一**类型上,
// 否则 addPath 无法作为泛型收口点。
type scoreHit struct {
id string
score float64
}
// hasInScopeLocked 报告某分类子树下是否存在条目(调用方须持锁)。
// 用于在分类过滤下提前返回,避免三路对全库白打分。
func (s *Store) hasInScopeLocked(category string) bool {
for _, k := range s.items {
if k.Category == category ||
strings.HasPrefix(k.Category, category+"/") ||
strings.HasPrefix(k.Name, category+"/") {
return true
}
}
return false
}
// denseHits 在多模态空间内对全库打分。
//
// 维度守卫是硬要求:不同模型/维度的向量混进来算出的余弦没有意义
// (会得到一个夹在两套坐标系之间的方向,且“看起来还挺像”)。维度不符
// 一律跳过。同维但指纹过期的(模型换过)也跳过。
func (s *Store) denseHits(queryVec []float64) []scoreHit {
if !s.denseEnabled() || len(queryVec) == 0 {
return nil
}
dim := s.dense.Dim()
fp := s.dense.Fingerprint()
out := make([]scoreHit, 0, len(s.items))
for _, k := range s.items {
if len(k.Dense) != dim || k.DenseFP != fp {
continue
}
if score := vector.DenseCosine(queryVec, k.Dense); score > 0.01 {
out = append(out, scoreHit{id: k.Name, score: score})
}
}
// 分数相同时按名字定序:map 迭代顺序随机,缺了这一步同分条目的
// 相对次序会随每次调用变化(Search 的主排序早有这条,denseHits 漏了),
// 表现为「同样的查询两次给出不同首位」——测试偶发、用户看到结果在跳。
sort.Slice(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
return out[i].id < out[j].id
})
return out
}
// toHits 把稀疏路的 DocVectorHit 归一成 addPath 用的 (id, score) 形状。
func toHits(in []vector.DocVectorHit) []scoreHit {
out := make([]scoreHit, 0, len(in))
for _, h := range in {
out = append(out, scoreHit{id: h.Doc.ID, score: h.Score})
}
return out
}
// shortFP 截断 fingerprint 为可读日志格式。
func shortFP(fp string) string {
if len(fp) > 12 {
return fp[:12]
}
return fp
}
// flushDenseLocked 把脏的稠密缓存落盘(调用方须持写锁)。
//
// 为何在 Add 当场落盘而不是等 Stop:进程可能被 kill -9,那时没有任何
// 优雅关停钩子可跑,这批向量的计算就白费了(docStore 的 BuildDenseIndex
// 出于同样理由选择当场写盘)。
func (s *Store) flushDenseLocked() error {
if !s.denseDirty {
return nil
}
// 批量期不落盘:saveDenseCacheLocked 是全量序列化 + 重写整个文件,
// 逐条做就是 O(N²)。由 endBatch 收口一次。
if s.batchDepth > 0 {
return nil
}
s.saveDenseCacheLocked()
s.denseDirty = false
return nil
}
// ——— 稠密向量缓存 ———
// loadDenseCache 读回稠密向量缓存。只在该空间未变更时命中。
//
// 缓存本身是派生数据,坏了就当没有(下次重算),绝不返回 error 卡住启动。
func (s *Store) loadDenseCache() {
s.mu.Lock()
defer s.mu.Unlock()
s.maybeLoadDenseCacheLocked()
}
// maybeLoadDenseCacheLocked 在「已接线 + 已扫盘 + 未加载过」时恢复缓存的
// 稠密向量。调用方必须已持锁。
func (s *Store) maybeLoadDenseCacheLocked() {
if s.denseCacheLoaded || !s.scanned || !s.denseEnabled() {
return
}
s.denseCacheLoaded = true
data, err := os.ReadFile(s.denseCachePath)
if err != nil {
return
}
var c denseCache
if json.Unmarshal(data, &c) != nil {
return
}
// 换过模型/维度后整份作废:否则会把一个坐标系的向量当另一个用
if c.Fingerprint != s.dense.Fingerprint() || c.Dim != s.dense.Dim() {
return
}
dim := s.dense.Dim()
loaded := 0
for id, e := range c.Entries {
k, ok := s.items[id]
if !ok || len(e.Dense) != dim {
continue
}
k.Dense, k.DenseFP = e.Dense, e.FP
loaded++
}
log.Printf("[knowledge] dense cache restored: %d vectors (fp=%s dim=%d)", loaded, shortFP(c.Fingerprint), dim)
}
// saveDenseCacheLocked 把稠密向量写回缓存文件(调用方须持写锁)。
//
// 用 tmp+rename 原子替换:写一半的缓存文件会被下次启动当成"损坏"而整体丢弃,
// 代价是一次全量重算——可接受,但不该每次都发生。
func (s *Store) saveDenseCacheLocked() {
if !s.denseEnabled() {
return
}
fp, dim := s.dense.Fingerprint(), s.dense.Dim()
c := denseCache{Fingerprint: fp, Dim: dim, Entries: map[string]denseCacheEntry{}}
for id, k := range s.items {
if len(k.Dense) == dim && k.DenseFP == fp {
c.Entries[id] = denseCacheEntry{Dense: k.Dense, FP: k.DenseFP, Dim: dim}
}
}
data, err := json.Marshal(c)
if err != nil {
log.Printf("[knowledge] dense cache marshal error: %v", err)
return
}
tmp := s.denseCachePath + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
log.Printf("[knowledge] dense cache write error: %v", err)
return
}
if err := os.Rename(tmp, s.denseCachePath); err != nil {
os.Remove(tmp)
log.Printf("[knowledge] dense cache commit error: %v", err)
}
}
// ——— 媒体引用持久化 ———
// readMediaSidecar 读条目目录下的媒体引用文件。没有文件 = 无媒体(正常)。
func readMediaSidecar(dir string) []KnowledgeMediaRef {
data, err := os.ReadFile(filepath.Join(dir, mediaSidecarName))
if err != nil {
return nil
}
var refs []KnowledgeMediaRef
if json.Unmarshal(data, &refs) != nil || len(refs) == 0 {
return nil
}
return refs
}
// writeMediaSidecar 把媒体引用写回条目目录。
// 媒体为空时删掉该文件,避免留下 "[]" 这种无意义的残留。
func writeMediaSidecar(dir string, refs []KnowledgeMediaRef) error {
p := filepath.Join(dir, mediaSidecarName)
if len(refs) == 0 {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
data, err := json.MarshalIndent(refs, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0644)
}