Files
HomeAgent/internal/memory/social/social.go
JianFeeeee df865518e6 perf(memory): Introspect 507ms→11ms、ListPersons 130ms→3ms
继续清「非常卡」。上次修完 pulse 端点后,`/kernel` 与 `/runtime` 仍各约
600ms(每 15s 一次)。这次**逐项量**而不是猜(临时探针量各子收集器):

    memDB.Introspect      507ms   ← 主凶
    social.ListPersons    129ms
    textMem.FileCount     0.1ms
    docStore.Stats        0.0ms
    knowledge.List/Stats  0.0ms

## ① Introspect 507ms → 11ms(43×)

hotspots 的度数写成**相关标量子查询**:

    COALESCE((SELECT COUNT(*) FROM memory_block_edges e
               WHERE (e.source_id = b.id OR e.target_id = b.id) ...), 0)

EXPLAIN 是 `SCAN b`(3364 块)→ `CORRELATED SCALAR SUBQUERY` → `SCAN e`
(2829 边),即 **3364 × 2829 ≈ 950 万次行扫描**,且 ORDER BY 前必须算完
全部块的度数。

改成边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
边表只扫两遍而不是 3364 遍。**新旧查询输出逐字节一致**(已 diff 验证)。

## ② ListPersons 130ms → 3ms(38×)

它走 `Recall(nil, nil, 1, "")`,而那条 full-recall 路径为了填
`result.Entities` 会加载**全部块(blockColumns 含稠密向量)**:

    Recall 全程 130ms:实体 3266 个、关系 30 条

即为了 30 条关系反序列化了 3266 个块的向量,而 ListPersons
**只看 result.Relations**。

新增 `GraphDB.RecentRelations(limit)`:与 full-recall 分支里的关系子查询
**逐字对齐**(同一 WHERE/ORDER/LIMIT,含 trait 边——过滤掉它会让特质
主体漏出人物表)。实测该查询单独 4ms。

等价性验证:新旧路径的人物列表 + 关系集**逐字节 diff 完全一致**
(56 人、同样的边)。旧路径输出是先 git stash 采样的,不是凭记忆比对。

## 验证

- `TestIntrospect_HotspotsQueryIsNotCorrelated`(源码形状)+
  `TestIntrospect_LargeGraphIsFast`(2000 块/1500 边的耗时门)。
  **两者各自独立可杀变异**(实测):
  - 源码断言:退回相关子查询 → FAIL「必须用一次聚合」
  - 耗时门:同变异 → FAIL「149ms 超过 60ms 门槛」(修复后 11ms)

  为何源码断言要锚在返回值行而非 `Index`:注释里也提到 memory_hotspots,
  用 Index 会截到注释处、取到的片段根本没有查询——第一版就这么写错了,
  自己先红了一次才发现。
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
2026-10-08 16:41:03 +08:00

357 lines
10 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 social
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
const (
entityTypePerson = "person"
entityTypeTrait = "trait_value"
traitPrefix = "trait:"
)
type PersonProfile struct {
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
}
type SocialRelation struct {
Person string `json:"person"`
Relation string `json:"relation"` // 关系类型:朋友/家人/同事/邻居/...
}
type SocialStore struct {
db *memory.GraphDB
mu sync.RWMutex
}
func New(db *memory.GraphDB) *SocialStore {
return &SocialStore{db: db}
}
// GetPerson 获取人物完整档案(特质 + 社交关系)
func (s *SocialStore) GetPerson(name string) (*PersonProfile, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
result, err := s.db.Recall([]string{name}, nil, 2, "")
if err != nil {
return nil, err
}
profile := &PersonProfile{
Name: name,
Traits: make(map[string]string),
}
// 查找指定 person 的 ID
// ★ 按**名字**匹配,不按 ID(2026-10-04)
//
// 旧实现:先找出 personID(e.ID),再用 `r.SourceID == personID` 匹配关系。
// ★★ 块侧这不再成立:e.ID 是「本次召回内的序号」(int64),
// 而 Relation.SourceID 是**块 ID**(blk_ent_<hash>)——
// 两个不同 ID 空间,永远匹配不上。
//
// 而「张三的特质」这个语义本来就与 ID 无关,只关乎名字。
// ★ 用 ID 匹配还有个隐患:同一个人可能有两个块
// (历史数据里 blk_ent_a 与 blk_ent_b 文本相同),
// 按 ID 只认其中一个,另一个的特质就丢了。
//
// 先确认人在召回结果里 —— 找不到就报错,不静默返回空档案。
found := false
for _, e := range result.Entities {
if e.Name == name {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("person '%s' not found", name)
}
// 区分 trait 关系和社交关系
for _, r := range result.Relations {
isSource := r.SourceName == name
isTarget := r.TargetName == name
if !isSource && !isTarget {
continue
}
if strings.HasPrefix(r.RelationType, traitPrefix) {
// 特质:trait:<特质名>,值在另一端
traitName := strings.TrimPrefix(r.RelationType, traitPrefix)
other := r.TargetName
if !isSource {
other = r.SourceName
}
profile.Traits[traitName] = other
continue
}
other := r.TargetName
if !isSource {
other = r.SourceName
}
profile.Relations = append(profile.Relations, SocialRelation{
Person: other,
Relation: r.RelationType,
})
}
return profile, nil
}
// SetTrait 设置/更新人物特质。如果同名特质已存在则覆盖
func (s *SocialStore) SetTrait(name, trait, value string) error {
if s.db == nil {
return fmt.Errorf("social store not available")
}
// 先清除旧特质值
oldVal, found := s.GetTrait(name, trait)
if found && oldVal != "" {
s.db.Purge(map[string]string{
"subject_contains": name,
"relation_type": traitPrefix + trait,
}, "soft")
}
triples := []memory.Triple{
{
Subject: name,
SubjectType: entityTypePerson,
Relation: traitPrefix + trait,
Object: value,
ObjectType: entityTypeTrait,
Confidence: 1.0,
},
}
_, _, err := s.db.Commit(triples, "social_trait", 0)
return err
}
// GetTrait 获取指定人物的指定特质值
func (s *SocialStore) GetTrait(name, trait string) (string, bool) {
if s.db == nil {
return "", false
}
result, err := s.db.Recall([]string{name}, nil, 1, "")
if err != nil || result == nil {
return "", false
}
// ★ 按名字匹配,不按 ID(2026-10-04)
//
// 旧实现找 personID 再用 `r.SourceID == personID` 过滤。
// ★★ 块侧不成立:e.ID 是召回内的序号(int64),
// Relation.SourceID 是块 ID(字符串)—— 两个 ID 空间,永不匹配。
//
// 症状极具迷惑性:第 17 行 `return r.SourceName` 在
// 「SourceID != personID」时**无条件**执行,于是 GetTrait 返回
// **人物自己的名字**当特质值(判据里是 got "张三" 而非空串)——
// 看不出是 ID 空间错配,只像是数据错了。
wantRel := traitPrefix + trait
for _, r := range result.Relations {
if r.RelationType != wantRel {
continue
}
if r.SourceName == name {
return r.TargetName, true
}
if r.TargetName == name {
return r.SourceName, true
}
}
return "", false
}
// AddRelation 建立两人之间的社交关系
func (s *SocialStore) AddRelation(personA, relation, personB string) error {
if s.db == nil {
return fmt.Errorf("social store not available")
}
triples := []memory.Triple{
{
Subject: personA,
SubjectType: entityTypePerson,
Relation: relation,
Object: personB,
ObjectType: entityTypePerson,
Confidence: 1.0,
},
}
_, _, err := s.db.Commit(triples, "social_relation", 0)
return err
}
// RemoveRelation 删除两人之间的社交关系
func (s *SocialStore) RemoveRelation(personA, relation, personB string) error {
if s.db == nil {
return fmt.Errorf("social store not available")
}
_, err := s.db.Purge(map[string]string{
"subject_contains": personA,
"target_contains": personB,
"relation_type": relation,
}, "soft")
return err
}
// GetRelations 获取指定人物的所有社交关系
func (s *SocialStore) GetRelations(name string) ([]SocialRelation, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
result, err := s.db.Recall([]string{name}, nil, 1, "")
if err != nil {
return nil, err
}
// ★ 按名字匹配,不按 ID —— 见 GetTrait 的说明:
// Entity.ID 是召回内序号,Relation.SourceID/TargetID 是块 ID,
// 两个 ID 空间,按 ID 匹配永远为假。
//
// ★ 顺带修掉的隐患:旧实现找不到 personID 就 return nil(空列表),
// 而「人不在库里」与「人存在但没有关系」是两件事 ——
// 前者是错误,后者是事实。空列表让调用方无从区分。
var relations []SocialRelation
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
continue
}
if r.SourceName == name {
relations = append(relations, SocialRelation{Person: r.TargetName, Relation: r.RelationType})
} else if r.TargetName == name {
relations = append(relations, SocialRelation{Person: r.SourceName, Relation: r.RelationType})
}
}
return relations, nil
}
// GetNetwork 获取指定人物周围 depth 度的社交网络
func (s *SocialStore) GetNetwork(name string, depth int) ([]*PersonProfile, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
// 用 Recall 的 BFS 遍历获取多度关联
result, err := s.db.Recall([]string{name}, nil, depth, "")
if err != nil {
return nil, err
}
personMap := make(map[int64]*PersonProfile)
for _, e := range result.Entities {
p := &PersonProfile{
Name: e.Name,
Traits: make(map[string]string),
}
personMap[e.ID] = p
}
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
traitName := strings.TrimPrefix(r.RelationType, traitPrefix)
if p, ok := personMap[r.SourceID]; ok {
p.Traits[traitName] = r.TargetName
}
if p, ok := personMap[r.TargetID]; ok {
p.Traits[traitName] = r.SourceName
}
}
}
// 收集关系
for _, r := range result.Relations {
if strings.HasPrefix(r.RelationType, traitPrefix) {
continue
}
sr := SocialRelation{Relation: r.RelationType}
if p, ok := personMap[r.SourceID]; ok {
sr.Person = r.TargetName
p.Relations = append(p.Relations, sr)
}
sr = SocialRelation{Relation: r.RelationType}
if p, ok := personMap[r.TargetID]; ok {
sr.Person = r.SourceName
p.Relations = append(p.Relations, sr)
}
}
var profiles []*PersonProfile
for _, p := range personMap {
profiles = append(profiles, p)
}
return profiles, nil
}
// ListPersons 列出所有已知人物(entity.type = person)
func (s *SocialStore) ListPersons() ([]string, error) {
if s.db == nil {
return nil, fmt.Errorf("social store not available")
}
// ★★★ 改从**图结构**推断人物(2026-10-04)
//
// 旧实现靠 `e.Type == "person"` 筛选。块侧没有 type:
// Triple.SubjectType / ObjectType 只写旧 entities.type,
// memory_blocks 里**没有任何类型列**。
//
// ★ 而「谁是人物」在社交图里本来就是**结构可判定的**:
//
// 人物 = 社交关系(非 trait 关系)的任一端点
// ∪ trait 关系的**源**(特质挂在主体上,trait:性格→开朗)
//
// 这不是权宜之计 —— 它比 type 更可靠:type 是写的时候声明的
// (调用方可能不声明、可能声明错),而结构是数据本身的性质。
// 旧实现里「AddRelation 加了人但没 SubjectType」就会漏掉那个人。
//
// ★ 保留一个回退:若图里一条边都没有(纯空库),
// 返回空列表而不是全部 —— 那才是「没有人物」。
// ★ 改走 RecentRelations(2026-10-08 修 130ms)
//
// 原实现用 Recall(nil, nil, 1, ""),而那条 full-recall 路径为了填
// result.Entities 会加载**全部块(含稠密向量)**。生产实测:
//
// Recall 全程 130ms:实体 3266 个、关系 30 条
//
// 即为了 30 条关系白付了 3266 个块的向量反序列化。而本函数只看
// result.Relations。改成只取关系(同一 WHERE/ORDER/LIMIT,4ms)。
rels, err := s.db.RecentRelations(30)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
for _, r := range rels {
if strings.HasPrefix(r.RelationType, traitPrefix) {
// 特质:源是主体(人物),目标是特质值
seen[r.SourceName] = true
continue
}
// 社交关系:两端都是人物
seen[r.SourceName] = true
seen[r.TargetName] = true
}
persons := make([]string, 0, len(seen))
for name := range seen {
if name != "" {
persons = append(persons, name)
}
}
// ★ 排序:Recall 的行序由 SQLite 决定,不稳定。
// 列表接口的输出必须可复现,否则测试与展示都会飘。
sort.Strings(persons)
return persons, nil
}