// Package memory 的相关性评分模块. // // 原项目在 findRelevantMemories 中调用模型来评估记忆和查询的相关性. // Go 版本暂时用基于 token 重叠的简单文本相似度代替,避免每次检索都调用 API. // 后续可以替换为嵌入向量或模型评估. package memory import ( "math" "sort" "strings" "unicode" ) // defaultRelevanceLimit 是默认返回的最大相关记忆数量. const defaultRelevanceLimit = 5 // minRelevanceScore 是最低相关性阈值,低于此分数的记忆不返回. // 避免在所有记忆都不相关时返回噪音结果. const minRelevanceScore = 0.05 // Score 计算查询和描述之间的相关性分数(向后兼容的包装函数). // 历史包袱(LEGACY): 保留导出签名,内部转发到 textScore. // 新代码应使用 RelevanceScorer 接口. func Score(query, description string) float64 { return textScore(query, description) } // textScore 计算查询和描述之间的相关性分数. // // 算法:基于 token 重叠的 Jaccard-like 相似度 + TF-IDF 式加权. // // 设计决策: // - 用 tokenize 把文本拆成小写 token(按空格和标点分词) // - 计算交集大小 / 并集大小(Jaccard 系数)作为基础分数 // - 对较长的 token 给予更高权重(长词通常更有区分度,类似 IDF 思想) // - 支持子串匹配:如果查询 token 是描述 token 的子串(或反过来), // 给部分匹配分数(0.5),支持模糊检索 // // 返回值:0.0(完全不相关)到 1.0(完全匹配)之间的浮点数. func textScore(query, description string) float64 { if query == "" || description == "" { return 0 } queryTokens := tokenize(query) descTokens := tokenize(description) if len(queryTokens) == 0 || len(descTokens) == 0 { return 0 } // 构建描述的 token 集合 descSet := make(map[string]bool, len(descTokens)) for _, t := range descTokens { descSet[t] = true } // 计算加权匹配分数 var matchScore float64 var totalWeight float64 for _, qt := range queryTokens { // token 长度加权:长 token 更有区分度 weight := tokenWeight(qt) totalWeight += weight if descSet[qt] { // 精确匹配 matchScore += weight } else { // 尝试子串匹配(部分匹配给半分) partialScore := partialMatch(qt, descTokens) matchScore += weight * partialScore } } if totalWeight == 0 { return 0 } // 基础分数:查询 token 在描述中的覆盖率 coverageScore := matchScore / totalWeight // Jaccard 系数:交集/并集,衡量双向相似度 querySet := make(map[string]bool, len(queryTokens)) for _, t := range queryTokens { querySet[t] = true } intersection := 0 for _, t := range queryTokens { if descSet[t] { intersection++ } } union := len(querySet) + len(descSet) - intersection jaccardScore := 0.0 if union > 0 { jaccardScore = float64(intersection) / float64(union) } // 精妙之处(CLEVER): 双指标融合--覆盖率衡量"查询词有多少出现在描述中"(召回率视角), // Jaccard 衡量"两个集合有多相似"(防止短查询匹配到长描述时分数过高). // 0.7:0.3 的权重让检索偏向高召回率,适合记忆查找场景(宁可多返回几条也不漏掉关键记忆). finalScore := 0.7*coverageScore + 0.3*jaccardScore return finalScore } // SelectRelevant 从记忆头信息列表中选出与查询最相关的记忆. // // 升华改进(ELEVATED): 接受可选的 RelevanceScorer 参数, // scorer 为 nil 时使用默认 TextScorer,保持向后兼容. // 替代方案:强制传入 scorer(破坏现有调用方). // // 流程: // 1. 对每个记忆使用评分器计算相关性分数 // 2. 过滤掉低于阈值的结果 // 3. 按分数降序排列 // 4. 返回前 limit 个 func SelectRelevant(query string, headers []MemoryHeader, limit int, scorer ...RelevanceScorer) []MemoryHeader { if limit <= 0 { limit = defaultRelevanceLimit } // 精妙之处(CLEVER): 使用变参实现可选参数,保持向后兼容-- // 旧代码 SelectRelevant(q, h, n) 继续工作, // 新代码 SelectRelevant(q, h, n, myScorer) 注入自定义评分器. var s RelevanceScorer if len(scorer) > 0 && scorer[0] != nil { s = scorer[0] } else { s = defaultScorer } type scored struct { header MemoryHeader score float64 } var results []scored for _, h := range headers { hCopy := h combinedScore := s.Score(query, &hCopy) if combinedScore >= minRelevanceScore { results = append(results, scored{header: h, score: combinedScore}) } } // 按分数降序排列 sort.Slice(results, func(i, j int) bool { return results[i].score > results[j].score }) // 取前 limit 个 if len(results) > limit { results = results[:limit] } out := make([]MemoryHeader, len(results)) for i, r := range results { out[i] = r.header } return out } // tokenize 将文本拆分成小写 token. // // 分词策略:按非字母数字字符分割,转小写,过滤掉长度 <= 1 的 token // (单字母 token 通常是噪音,如 "a","I"). // 对中文/日文等 CJK 字符,每个字符作为独立 token. func tokenize(text string) []string { text = strings.ToLower(text) // 先用 FieldsFunc 按非字母数字分割 rawTokens := strings.FieldsFunc(text, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }) var tokens []string for _, t := range rawTokens { // 过滤掉太短的 token(但保留 CJK 单字符) if len(t) <= 1 { // 检查是否是 CJK 字符 runes := []rune(t) if len(runes) == 1 && isCJK(runes[0]) { tokens = append(tokens, t) } continue } tokens = append(tokens, t) } return tokens } // tokenWeight 返回 token 的权重. // 升华改进(ELEVATED): 用连续对数函数替代离散分段-- // 在信息检索领域(不限于编程),IDF 的理论基础是对数函数而非阶梯函数. // 离散分段在边界处有跳变(长度 2→3 时权重从 0.5 跳到 1.0), // 连续函数消除了这种不自然的跳变,且对任意语言的 token 长度分布都适用. // 公式:0.5 + log2(length) * 0.35,下限 0.5,上限用 log 自然封顶. // 替代方案:<原方案用 switch-case 分 4 段:<=2 返回 0.5, <=4 返回 1.0, <=8 返回 1.5, 其余 log> func tokenWeight(token string) float64 { length := len([]rune(token)) if length <= 0 { return 0 } // 连续对数权重:短 token (~1-2字符) ≈ 0.5, 中等 (~4字符) ≈ 1.2, 长 (~8字符) ≈ 1.55 w := 0.5 + math.Log2(float64(length)+1)*0.35 return w } // partialMatch 检查 token 是否和目标列表中的某个 token 部分匹配. // 返回 0.0(不匹配)到 0.5(子串匹配)之间的分数. func partialMatch(token string, targets []string) float64 { // token 太短不做子串匹配(避免 "a" 匹配所有东西) if len([]rune(token)) < 3 { return 0 } for _, t := range targets { if strings.Contains(t, token) || strings.Contains(token, t) { return 0.5 } } return 0 } // isCJK 判断字符是否是 CJK 统一表意文字. // // 精妙之处(CLEVER): 故意排除 U+F900-U+FAFF(CJK Compatibility Ideographs)-- // 该区块是 Unicode 为向后兼容韩国/日本旧编码收录的"规范等价副本", // 每个字符在 Unicode 标准中都有对应的正则形式(U+4E00-U+9FFF 区块内). // 如果在此区块过滤,isCJK('豈') == true 但 isCJK('並') == false, // 会导致规范化后相同语义的词被分词器拆散成不同 token,破坏相关性评分的一致性. // 反向思维:排除后会丢失哪些字?只有极少数使用旧编码的遗留文件受影响, // 且这些字都有 Unicode 正则形式,分词器对 NFC/NFD 规范化后仍能正确处理. // 替代方案:保留 U+F900-U+FAFF(旧实现,不一致但覆盖面更广). func isCJK(r rune) bool { return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs(基本汉字) (r >= 0x3400 && r <= 0x4DBF) || // CJK Extension A(扩展A) (r >= 0x20000 && r <= 0x2A6DF) // CJK Extension B(扩展B,补充平面) // 注意:故意排除 U+F900-U+FAFF(CJK Compatibility Ideographs)-- // 这些是规范等价副本,不是独立字符,包含会破坏分词一致性. }