refactor: output channel interface (payload/meta/type) + memory fixes

- Redesign output_send__ tools: content JSON string -> structured
  payload/meta/type params for LLM reliability
- executeOutputSendTool: route by type with capability check
- executeOutputSendHelp: show meta format + type enum
- Updated system prompt rules for new interface
- docToTriples: use jieba exact mode adjacent co-occurrence
- Unify vector space: Doc.Vector field, ContextToDoc vectorizer,
  ReindexWithVectorizer on startup
This commit is contained in:
root
2026-07-19 10:29:21 +08:00
parent 9b3a751d98
commit 8ae1d19869
10 changed files with 453 additions and 109 deletions

2
go.mod
View File

@ -3,7 +3,7 @@ module gitcode.com/JianFeeeee/HomeAgent
go 1.25.0
require (
github.com/mattn/go-sqlite3 v1.14.22
github.com/mattn/go-sqlite3 v1.14.48
github.com/yuin/gopher-lua v1.1.2
gopkg.in/yaml.v3 v3.0.1
)

2
go.sum
View File

@ -1,5 +1,7 @@
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/yanyiwu/gojieba v1.4.7 h1:2YkXELcYLTE0SJetq6xv4MjpEikWga6VpFn4jIFFQ/k=
github.com/yanyiwu/gojieba v1.4.7/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=

View File

@ -165,6 +165,13 @@ func New(cfg AgentConfig) *Agent {
if cfg.MaxContextSize <= 0 {
cfg.MaxContextSize = 30
}
embedder := memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)
if cfg.DocStore != nil {
cfg.DocStore.SetVectorizer(embedder)
cfg.DocStore.ReindexWithVectorizer(embedder)
}
return &Agent{
id: cfg.ID,
startTime: time.Now(),
@ -175,7 +182,7 @@ func New(cfg AgentConfig) *Agent {
indexer: cfg.Indexer,
skills: cfg.Skills,
tracker: cfg.Tracker,
context: NewRelevanceContext(cfg.ContextSavePath, memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)),
context: NewRelevanceContext(cfg.ContextSavePath, embedder),
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
@ -734,6 +741,10 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
break
}
if errors.Is(llmErr, context.Canceled) {
// 打断不是 provider 故障,不标记不可用,直接让外层循环重试
break
}
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
@ -1612,8 +1623,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 输出指令:使用 output_send__{channel} 作为回复手段
prompt += "\n\n【输出规则】你有多组输出门工具type=output每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
prompt += "- content 参数是 JSON 字符串,包含要发送的内容。具体格式因通道而异,用 output_send__{通道名}_help 查看每个通道的 JSON 格式说明。\n"
prompt += "- output_send__{通道名}_help 是普通 function 类型工具,调用后返回该通道的 JSON 格式详情和示例。\n"
prompt += "- payload 参数是消息载荷文本直接填文字type 指定载荷类型text/voice/image/filemeta 是 JSON 发送元数据(群号/用户号等)。\n"
prompt += "- output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举。\n"
prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
prompt += "- 直接返回纯文本不会到达任何用户端。"
@ -2081,16 +2092,24 @@ func (a *Agent) buildToolDefs() []interface{} {
"type": "function",
"function": map[string]interface{}{
"name": "output_send__" + ch.Name,
"description": desc + "。能力: " + capStr + "。content 参数为 JSON 字符串,具体格式请调用 output_send__" + ch.Name + "_help 查看。",
"description": desc + "。能力: " + capStr + "。payload 为消息载荷meta 为 JSON 发送元数据type 为载荷类型。用 _help 查看 meta 格式和 type 枚举。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{
"payload": map[string]interface{}{
"type": "string",
"description": "JSON 字符串,包含要发送的内容和路由信息。格式因通道而异,用 _help 工具查看详情。",
"description": "消息载荷。type=text 时填文字type=file/image 时填 URL 或路径",
},
"meta": map[string]interface{}{
"type": "string",
"description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
},
"type": map[string]interface{}{
"type": "string",
"description": "载荷类型,用 channel._help 查看支持的枚举值",
},
},
"required": []string{"content"},
"required": []string{"payload", "type"},
},
},
})
@ -2100,7 +2119,7 @@ func (a *Agent) buildToolDefs() []interface{} {
"type": "function",
"function": map[string]interface{}{
"name": "output_send__" + ch.Name + "_help",
"description": "查看 " + ch.Name + " 输出通道的 JSON 格式说明和示例",
"description": "查看 " + ch.Name + " 输出通道的 meta 格式说明和 type 枚举",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
@ -2425,12 +2444,16 @@ func (a *Agent) evaluateGraphQuality() {
// 选出低质量候选generic 关系(如 distiller 自动生成的泛化关系)
var lowQuality []string
for _, r := range result.Relations {
// 自动蒸馏生成的 (用户, 提及, ...) (AI, 回应, ...) 通常是噪音
// 自动蒸馏生成的 (用户, 提及, ...), (AI, 回应, ...) 和 jieba 共现 (..., 关联, ...) 通常是噪音
if (r.SourceName == "用户" || r.SourceName == "AI") &&
(r.RelationType == "提及" || r.RelationType == "回应") {
lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」", r.SourceName, r.RelationType, r.TargetName))
continue
}
if r.RelationType == "关联" {
lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(jieba 共现)", r.SourceName, r.RelationType, r.TargetName))
continue
}
// 极低 mention 的实体+generic 关系
if r.Confidence < 0.3 && r.RelationType != "" {
lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(confidence=%.1f)", r.SourceName, r.RelationType, r.TargetName, r.Confidence))
@ -2519,31 +2542,41 @@ func docToTriples(doc *document.Doc) []memory.Triple {
triples = append(triples, memory.Triple{
Subject: "文档",
Relation: "包含内容",
SubjectType: "Concept",
Relation: "主题",
Object: doc.Summary,
ObjectType: "Topic",
Confidence: 1.0,
})
for _, entity := range doc.Entities {
// 用 jieba 精确模式逐句分词 → 相邻词共现三元组
lines := strings.Split(doc.Content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
terms := memory.CutExact(line)
for i := 0; i < len(terms)-1; i++ {
triples = append(triples, memory.Triple{
Subject: "文档",
Relation: "提及实体",
Object: entity,
Subject: terms[i],
SubjectType: "Concept",
Relation: "关联",
Object: terms[i+1],
ObjectType: "Concept",
Confidence: 0.8,
})
}
for _, tag := range doc.Tags {
triples = append(triples, memory.Triple{
Subject: "文档",
Relation: "标签",
Object: tag,
})
}
if doc.Source != "" {
triples = append(triples, memory.Triple{
Subject: "文档",
SubjectType: "Concept",
Relation: "来源",
Object: doc.Source,
ObjectType: "Source",
Confidence: 1.0,
})
}
@ -2601,55 +2634,77 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
// tool name is "output_send__{channel}"
channel := strings.TrimPrefix(tc.Name, "output_send__")
content, _ := tc.Arguments["content"].(string)
if channel == "" || content == "" {
return "工具名称格式: output_send__{channel}content 不能为空"
payload, _ := tc.Arguments["payload"].(string)
rawType, _ := tc.Arguments["type"].(string)
if channel == "" || payload == "" || rawType == "" {
return "工具名称格式: output_send__{channel}payload 和 type 不能为空"
}
// content 是一个 JSON 字符串,插件通过解析它确定如何发送消息
// === Stage: before_output — 输出前插件可审查/改写/拦截 ===
stageCtx := &sdk.StageContext{
FinalText: content,
Phase: sdk.StageBeforeOutput,
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
if stageCtx.Response != nil {
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
}
content = stageCtx.FinalText
if content == "" {
return "输出被插件清空"
}
tc.Arguments["content"] = content
meta, _ := tc.Arguments["meta"].(string)
// 通道能力检查
caps := a.io.GetChannelCapabilities(channel)
if caps == 0 {
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
}
switch rawType {
case "text":
if !caps.Supports(agentIO.CapText) {
return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s", channel, caps.String())
}
case "voice", "audio":
if !caps.Supports(agentIO.CapAudio) {
return fmt.Sprintf("通道 [%s] 不支持语音输出(能力: %s", channel, caps.String())
}
case "image":
if !caps.Supports(agentIO.CapImage) {
return fmt.Sprintf("通道 [%s] 不支持图片输出(能力: %s", channel, caps.String())
}
case "file":
if !caps.Supports(agentIO.CapFile) {
return fmt.Sprintf("通道 [%s] 不支持文件输出(能力: %s", channel, caps.String())
}
}
// 组装参数传给设备处理器
args := map[string]interface{}{
"payload": payload,
"type": rawType,
}
if meta != "" {
args["meta"] = meta
}
// === Stage: before_output — 输出前插件可审查/改写/拦截 ===
stageCtx := &sdk.StageContext{
FinalText: payload,
Phase: sdk.StageBeforeOutput,
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
if stageCtx.Response != nil {
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
}
if stageCtx.FinalText == "" {
return "输出被插件清空"
}
args["payload"] = stageCtx.FinalText
// 通过设备处理器投递
if dev := a.io.GetDevice(channel); dev != nil {
result, err := dev.Execute("output", tc.Arguments)
result, err := dev.Execute("output", args)
if err != nil {
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
}
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
}
// 降级:发送到 outputCh供 OutputChan 消费者)
a.io.EmitTextTo("agent_io", channel, content)
// 降级
a.io.EmitTextTo("agent_io", channel, payload)
return fmt.Sprintf("已通过 [%s] 通道发送", channel)
}
// executeOutputSendHelp — 返回指定通道的 JSON 格式说明
// executeOutputSendHelp — 返回指定通道的 meta 格式和 type 枚举
func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
// tool name is "output_send__{channel}_help"
suffix := strings.TrimPrefix(tc.Name, "output_send__")
channel := strings.TrimSuffix(suffix, "_help")
if channel == "" {
@ -2676,13 +2731,24 @@ func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
描述: %s
能力: %s
content JSON 格式说明】
发送到此通道时 content 必须是 JSON 字符串,包含以下字段:
- "content": 消息正文(必填
- 根据通道不同可能还需要路由字段(如 "group_id", "user_id" 等)
参数说明】
payload — 消息载荷必填。type=text 时直接填文字type=file/image 时填 URL 或路径
meta — JSON 对象,发送所需的元数据(可选,取决于通道是否需要路由信息
type — 载荷类型(必填),枚举值见下方
请在通道描述中查看具体字段要求。
示例: {"content":"你好"}`, channel, desc, capStr)
【type 枚举】
- text — 文本消息
- voice — 语音消息
- image — 图片
- file — 文件
【meta JSON 格式】
由通道描述定义,通常包含:
- "group_id" 群号(群聊时必填)
- "user_id" 目标用户 QQ 号(私聊时必填)
- "reply_to" 回复某条消息 ID可选
示例: output_send__%s(payload="你好", meta="{\"group_id\": 123456789}", type="text")`, channel, desc, capStr, channel)
}
// executeOutputListChannels — 列出所有可用通道及其能力

View File

@ -29,48 +29,34 @@ func TestDocToTriples(t *testing.T) {
doc := &document.Doc{
Summary: "用户喜欢编程",
Content: "用户提到喜欢Go和Python",
Tags: []string{"编程", "Go"},
Entities: []string{"Go", "Python"},
Source: "context",
}
triples := docToTriples(doc)
if len(triples) == 0 {
t.Fatal("expected non-empty triples")
}
foundSummary := false
foundEntity := false
foundTag := false
foundRel := false
foundSource := false
for _, tr := range triples {
if tr.Subject == "文档" && tr.Relation == "包含内容" {
switch {
case tr.Subject == "文档" && tr.Relation == "主题":
foundSummary = true
}
if tr.Subject == "文档" && tr.Relation == "提及实体" {
foundEntity = true
}
if tr.Subject == "文档" && tr.Relation == "标签" {
foundTag = true
}
if tr.Subject == "文档" && tr.Relation == "来源" {
case tr.Relation == "关联":
foundRel = true
case tr.Subject == "文档" && tr.Relation == "来源":
foundSource = true
}
}
if !foundSummary {
t.Error("missing '包含内容' triple")
}
if !foundEntity {
t.Error("missing '提及实体' triple")
}
if !foundTag {
t.Error("missing '标签' triple")
t.Error("missing '主题' triple")
}
if !foundSource {
t.Error("missing '来源' triple")
}
if needJieba() && !foundRel {
t.Error("missing '关联' triple with jieba available")
}
}
func TestDocToTriplesNil(t *testing.T) {
@ -97,13 +83,32 @@ func TestDocToTriplesTypes(t *testing.T) {
doc := &document.Doc{
Summary: "测试三元组类型",
Content: "用于验证 SubjectType 和 ObjectType",
Entities: []string{"Go"},
Source: "test",
}
triples := docToTriples(doc)
// 主题 and 来源 triples have Subject=文档
for _, tr := range triples {
if tr.Subject != "文档" {
t.Errorf("expected subject '文档', got %q", tr.Subject)
if tr.Subject == "文档" {
if tr.SubjectType != "Concept" {
t.Errorf("文档 subject_type should be Concept, got %q", tr.SubjectType)
}
if tr.Confidence != 1.0 {
t.Errorf("文档 triple confidence should be 1.0, got %f", tr.Confidence)
}
} else {
// 关联 triples use extracted terms as subject/object
if tr.Relation != "关联" {
t.Errorf("non-文档 triple should have 关联 relation, got %q", tr.Relation)
}
if tr.Confidence != 0.8 {
t.Errorf("关联 triple confidence should be 0.8, got %f", tr.Confidence)
}
}
// all should have SubjectType/ObjectType set
if tr.SubjectType == "" || tr.ObjectType == "" {
t.Errorf("triple %+v missing SubjectType or ObjectType", tr)
}
}
}

View File

@ -2,8 +2,106 @@ package core
import (
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
func needJieba() bool {
return memory.GetJieba() != nil
}
func TestDocToTriplesEmpty(t *testing.T) {
doc := &document.Doc{
Summary: "empty doc",
Content: "",
Source: "test",
}
triples := docToTriples(doc)
if len(triples) < 2 {
t.Fatalf("expected at least 2 triples (主题+来源), got %d", len(triples))
}
if triples[0].Subject != "文档" || triples[0].Relation != "主题" || triples[0].Object != "empty doc" {
t.Errorf("first triple mismatch: %+v", triples[0])
}
last := triples[len(triples)-1]
if last.Subject != "文档" || last.Relation != "来源" || last.Object != "test" {
t.Errorf("last triple mismatch: %+v", last)
}
}
func TestDocToTriplesConversation(t *testing.T) {
doc := &document.Doc{
Summary: "测试对话 (qq) 涉及: 天气",
Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好",
Source: "qq",
}
triples := docToTriples(doc)
minLen := 2
hasJieba := needJieba()
if hasJieba && len(triples) <= minLen {
t.Errorf("expected more than %d triples with jieba, got %d", minLen, len(triples))
}
for i, tr := range triples {
if tr.Subject == "" || tr.Relation == "" || tr.Object == "" {
t.Errorf("triple[%d] has empty field: %+v", i, tr)
}
if tr.Confidence <= 0 {
t.Errorf("triple[%d] has non-positive confidence: %+v", i, tr)
}
}
relCount := 0
for _, tr := range triples {
if tr.Relation == "关联" {
relCount++
if tr.Subject == tr.Object {
t.Errorf("关联 triple has same subject and object: %+v", tr)
}
}
}
if hasJieba && relCount == 0 {
t.Errorf("expected 关联 triples with jieba enabled, got 0 in %+v", triples)
}
}
func TestDocToTriplesMultiLine(t *testing.T) {
doc := &document.Doc{
Summary: "多轮对话",
Content: "[10:00] user: 你好\n[10:01] agent: 你好,有什么可以帮助你的\n[10:02] user: 今天天气如何\n[10:03] agent: 今天天气很好",
Source: "qq",
}
triples := docToTriples(doc)
if len(triples) < 2 {
t.Fatalf("expected at least 2 triples, got %d", len(triples))
}
if triples[0].Subject != "文档" || triples[0].Relation != "主题" {
t.Errorf("first triple should be 主题, got %+v", triples[0])
}
last := triples[len(triples)-1]
if last.Subject != "文档" || last.Relation != "来源" {
t.Errorf("last triple should be 来源, got %+v", last)
}
}
func TestDocToTriplesEmptyContent(t *testing.T) {
doc := &document.Doc{
Summary: "空内容",
Content: "",
Source: "test",
}
triples := docToTriples(doc)
if len(triples) != 2 {
t.Fatalf("expected exactly 2 triples (主题+来源) for empty content, got %d", len(triples))
}
}
func TestTruncateStr(t *testing.T) {
tests := []struct {
input string

View File

@ -200,7 +200,7 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
Response: s.event.Response,
}
}
doc, err := docStore.ContextToDoc("context_archived", entries)
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
if err == nil && doc != nil {
archived = len(archive)
}

View File

@ -117,3 +117,26 @@ func ExtractKeywords(text string) []string {
}
return keywords
}
// CutExact 精确模式分词:返回去停用词后的所有有义项(不限数量),用于 doc→graph 蒸馏
func CutExact(text string) []string {
text = CleanTemplateText(text)
x := GetJieba()
if x == nil {
return nil
}
words := x.Cut(text, false)
var result []string
seen := make(map[string]bool)
for _, w := range words {
if stopWords[w] || seen[w] {
continue
}
if !validEntityName(w) {
continue
}
seen[w] = true
result = append(result, w)
}
return result
}

107
internal/memory/cut_test.go Normal file
View File

@ -0,0 +1,107 @@
package memory
import (
"testing"
)
func needJieba(t *testing.T) {
t.Helper()
if GetJieba() == nil {
t.Skip("jieba dictionaries not found")
}
}
func TestCutExact(t *testing.T) {
needJieba(t)
tests := []struct {
name string
text string
min int
not []string
}{
{
name: "chinese_sentence",
text: "今天天气怎么样",
min: 2,
not: nil,
},
{
name: "stop_words_removed",
text: "和天气地",
min: 1,
not: []string{"的", "和"},
},
{
name: "short_words_filtered",
text: "今天好天气",
min: 1,
not: []string{"好"},
},
{
name: "empty_text",
text: "",
min: 0,
not: nil,
},
{
name: "qq_conversation",
text: "今天天气怎么样 → 今天天气很好",
min: 2,
not: nil,
},
{
name: "all_stop_words",
text: "的了呢吗",
min: 0,
not: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CutExact(tt.text)
if len(got) < tt.min {
t.Errorf("CutExact(%q) = %v (len=%d), want at least %d terms", tt.text, got, len(got), tt.min)
}
for _, forbid := range tt.not {
for _, g := range got {
if g == forbid {
t.Errorf("CutExact(%q) = %v, should not contain %q", tt.text, got, forbid)
}
}
}
})
}
}
func TestCutExactNoDuplicates(t *testing.T) {
needJieba(t)
got := CutExact("天气天气天气")
if len(got) > 1 {
t.Errorf("expected deduplicated result, got %v (len=%d)", got, len(got))
}
}
func TestCutExactValidEntityName(t *testing.T) {
needJieba(t)
got := CutExact("a b c")
for _, g := range got {
if !validEntityName(g) {
t.Errorf("CutExact returned invalid entity name %q", g)
}
}
}
func TestCutExactRemoveTimestamp(t *testing.T) {
needJieba(t)
got := CutExact("[15:04] 今天天气不错")
for _, g := range got {
if g == "15" || g == "04" || g == "15:04" {
t.Errorf("timestamp should be removed by CleanTemplateText, got %q in %v", g, got)
}
}
}

View File

@ -28,6 +28,7 @@ type Doc struct {
Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"` // 访问次数
LastAccess time.Time `json:"last_access"` // 最后访问时间
Vector vector.Vector `json:"vector,omitempty"` // 预计算向量(与 context 同空间nil 则用 TF-IDF 兜底
}
// Store — 文档记忆存储,包含向量索引
@ -39,10 +40,29 @@ type Store struct {
docs map[string]*Doc
summaries []string // 用于训练向量化器,最大 10000 条
vectorizer vector.Vectorizer // 可选:与 context 同空间的向量化器
dirty bool
}
func (s *Store) SetVectorizer(v vector.Vectorizer) {
s.vectorizer = v
}
// ReindexWithVectorizer 用给定的向量化器重建所有文档的向量索引
func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
s.mu.Lock()
defer s.mu.Unlock()
log.Printf("[document memory] reindex with vectorizer (%d docs)", len(s.docs))
s.vec = vector.NewStore()
for _, doc := range s.docs {
doc.Vector = v.Vectorize(doc.Summary + " " + doc.Content)
s.vec.Insert(doc.ID, doc.Summary, doc.Vector, doc.Meta)
}
log.Printf("[document memory] reindex with vectorizer complete (%d vectors)", s.vec.Size())
}
const maxSummaries = 10000
func NewStore(dir string) *Store {
@ -88,7 +108,10 @@ func (s *Store) Insert(doc *Doc) error {
// 增量训练向量化器并加入向量索引
s.addSummary(doc.Summary)
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
vec := doc.Vector
if vec == nil {
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
}
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
// 立即写盘
@ -101,7 +124,7 @@ func (s *Store) Insert(doc *Doc) error {
}
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error) {
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
@ -140,6 +163,12 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
}
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
var docVec vector.Vector
if vec != nil {
docVec = vec.Vectorize(summary + " " + content)
} else {
docVec = s.veczer.Vectorize(summary + " " + content)
}
doc := &Doc{
ID: id,
Summary: summary,
@ -152,13 +181,13 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
AccessCount: 1,
Source: source,
Meta: map[string]string{"content_hash": contentHash},
Vector: docVec,
}
s.docs[id] = doc
// 增量训练向量化器并加入向量索引
// 加入向量索引
s.addSummary(summary)
vec := s.veczer.Vectorize(summary + " " + content)
s.vec.Insert(id, summary, vec, nil)
s.vec.Insert(id, summary, doc.Vector, nil)
s.dirty = true
s.mu.Unlock()
@ -180,7 +209,7 @@ func (s *Store) Consume(text string, topK int) []*Doc {
topK = 5
}
vec := s.veczer.Vectorize(text)
vec := s.vectorizeQuery(text)
results := s.vec.Search(vec, topK)
var docs []*Doc
@ -194,6 +223,14 @@ func (s *Store) Consume(text string, topK int) []*Doc {
return docs
}
// vectorizeQuery 用语义向量化器(首选)或 TF-IDF兜底处理查询文本
func (s *Store) vectorizeQuery(text string) vector.Vector {
if s.vectorizer != nil {
return s.vectorizer.Vectorize(text)
}
return s.veczer.Vectorize(text)
}
// Query — 向量相似度查询文档
func (s *Store) Query(text string, topK int) []*Doc {
s.mu.RLock()
@ -203,7 +240,7 @@ func (s *Store) Query(text string, topK int) []*Doc {
topK = 5
}
vec := s.veczer.Vectorize(text)
vec := s.vectorizeQuery(text)
results := s.vec.Search(vec, topK)
var docs []*Doc
@ -228,7 +265,10 @@ func (s *Store) Reindex() {
s.vec = vector.NewStore()
for _, doc := range s.docs {
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
vec := doc.Vector
if vec == nil {
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
}
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
}
@ -348,7 +388,10 @@ func (s *Store) loadAll() error {
// 重建向量索引
for _, doc := range s.docs {
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
vec := doc.Vector
if vec == nil {
vec = s.veczer.Vectorize(doc.Summary + " " + doc.Content)
}
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
}

View File

@ -82,7 +82,7 @@ func TestContextToDoc(t *testing.T) {
{Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"},
}
doc, err := s.ContextToDoc("test", entries)
doc, err := s.ContextToDoc("test", entries, nil)
if err != nil {
t.Fatal(err)
}