Files
AgentLab/backend/app/memory/store.py
T
ThearchyHelios df935599f2 知识库:换 embedding 模型不再 500,检索能回指了
三件事,都是"这三个模块一条测试都没有"的后果。

**换 embedder 会让检索整体 500。** 库里是 512 维的本地向量、查询是 1536 维的
远端向量时,cosine 直接 `shapes (1536,) and (512,) not aligned`。from_blob 有
dim 参数,但 kb.py 和 memory/store.py 调用时都没传;而且存量向量属于哪个模型
代码里没有任何地方记着,所以也无从降级。

Chunk / MemoryItem 各加 embed_model + embed_dim(走现成的 _COLUMN_MIGRATIONS,
存量回填空值,正好表示"不知道是谁建的")。检索遇到对不上的向量整体退回纯
关键词并说破,不是把它们当 0 分静默沉底——那样用户看到的是结果莫名其妙变差,
而不是"这里有一批索引该重建了"。配套 kb.reindex + POST /api/kb/reindex。

长期记忆那份一起修了。判重那条更危险:维度碰巧相同但模型不同时 cosine 算出来
是随机数,会把不相关的旧记忆认成"重复",然后把新内容丢掉。

**默认 embedder 没有语义能力,而开关藏在一个环境变量里。** LocalEmbedder 是
特征哈希 + TF,和 BM25 吃同一批词;alpha 默认 0.5 等于给了它一半权重,同义
改写一条都召不回。开启真模型的唯一开关是 AGENTLAB_USE_OPENAI_EMBEDDINGS——
没界面、没人会发现。改成设置项(照 COPILOT_SETTING_KEY 的路子),知识库页顶部
能选能看能重建。

配置失败必须响:第一版写成静默退回本地,但选了 OpenAI 却拿到哈希向量的人,
在搜不出同义表达时完全无从怀疑到这里。现在抛 EmbedderUnavailable,API 400
原样交回"检查 OPENAI_API_KEY 是否配了",且不会把配置改掉。

**检索结果不进工件库。** db_query、工具节点、agent 工具调用都落 put_json 快照,
run_retrieve 什么都不落——于是结论只要来自知识库,"这个数是哪来的"就断在这儿,
而数字回指正是这个项目的签名能力。现在落 kind="retrieval_snapshot",并发一条
retrieve.end 带 artifact id。解码层补了正式映射(原来会掉进"未知事件"兜底,
画成一坨原始 JSON)。

新增 test_kb.py 11 条,补上这三个模块此前的零覆盖,包括 embeddings.py 里
注释记着、但一直没有测试守的那个归一化边界(单条候选被 min_score 误杀)。

端到端在真实服务上验过:撞上存量向量返回 200 + 降级说明(以前 500)、
重建后 stale 归零、配不起来的远端模型 400 且配置不变。
2026-09-19 09:27:07 +08:00

178 lines
6.1 KiB
Python
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.
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import MemoryItem
from app.memory.embeddings import (
cosine, embed_text, embedder_dim, embedder_id, from_blob, hybrid_rank, to_blob, usable,
)
# 判重阈值:余弦相似度高于它才算同一件事。比排名分数严格得多,
# 因为这里比的是真实语义距离,不是"谁排第一"。
_DEDUPE_THRESHOLD = 0.92
async def remember(
session: AsyncSession,
*,
scope: str,
content: str,
kind: str = "fact",
meta: dict[str, Any] | None = None,
importance: float = 0.5,
dedupe: bool = True,
) -> MemoryItem:
"""写一条长期记忆。
dedupe 会先看同 scope 下有没有近乎重复的内容 —— agent 很容易把同一件事
反复记下来,不去重的话记忆库几轮就被同义句灌满了。
"""
content = content.strip()
vec = await embed_text(content)
if dedupe:
# 判重必须看**真实相似度**,不能用 recall 的分数。recall 走 hybrid_rank,
# 那里的分数是 min-max 排名归一化的结果:排第一的那条在两路信号上都会
# 被归成 1.0,与它和查询像不像毫无关系。于是 scope 里只要已有两条记忆,
# 新内容随便撞上一个字排到第一,就会被判成"重复"而丢弃,接口还返回 201
# 并把那条不相关的旧记忆当成"刚保存的"交回去——静默丢数据。
stmt = select(MemoryItem).where(MemoryItem.scope == scope)
if kind:
stmt = stmt.where(MemoryItem.kind == kind)
candidates = list((await session.execute(stmt)).scalars())
best, best_score = None, 0.0
for row in candidates:
# 对不上当前 embedder 的直接跳过:维度不同 cosine 会抛,维度碰巧
# 相同但模型不同则更糟——两个空间之间的相似度是个随机数,
# 拿它判重会把不相关的旧记忆认成"重复"然后丢掉新内容
if not usable(row.embed_model, row.embed_dim):
continue
other = from_blob(row.embedding, embedder_dim())
if other is None:
continue
score = cosine(vec, other)
if score > best_score:
best, best_score = row, score
if best is not None and best_score > _DEDUPE_THRESHOLD:
best.importance = max(best.importance, importance)
best.use_count += 1
await session.commit()
await session.refresh(best)
return best
item = MemoryItem(
scope=scope,
kind=kind,
content=content,
meta=meta or {},
importance=importance,
embedding=to_blob(vec),
embed_model=embedder_id(),
embed_dim=embedder_dim(),
)
session.add(item)
await session.commit()
await session.refresh(item)
return item
async def recall(
session: AsyncSession,
*,
scope: str,
query: str,
limit: int = 5,
kind: str | None = None,
min_score: float = 0.05,
) -> list[dict[str, Any]]:
"""按混合相关度取回记忆,并按重要性和使用次数做轻微加权。"""
stmt = select(MemoryItem).where(MemoryItem.scope == scope)
if kind:
stmt = stmt.where(MemoryItem.kind == kind)
rows = list((await session.execute(stmt)).scalars())
if not rows:
return []
query_vec = await embed_text(query)
# 和 kb.search 同一个道理:有对不上的就整体退回关键词,而不是让它们静默沉底
if any(not usable(r.embed_model, r.embed_dim) for r in rows):
vectors: list[Any] = [None] * len(rows)
alpha = 0.0
else:
vectors = [from_blob(r.embedding, embedder_dim()) for r in rows]
alpha = 0.5
ranked = hybrid_rank(
query,
[r.content for r in rows],
vectors,
query_vec,
alpha=alpha,
)
results: list[dict[str, Any]] = []
now = datetime.now(timezone.utc)
for idx, score, parts in ranked:
row = rows[idx]
# 重要的、被用得多的记忆略微上浮
boosted = score * (0.8 + 0.4 * row.importance) * (1 + min(row.use_count, 10) * 0.01)
if boosted < min_score:
continue
results.append(
{
"id": row.id,
"content": row.content,
"kind": row.kind,
"meta": row.meta,
"importance": row.importance,
"score": round(boosted, 4),
"signals": {k: round(v, 4) for k, v in parts.items()},
"created_at": row.created_at.isoformat() if row.created_at else None,
}
)
if len(results) >= limit:
break
# 记一下被召回过,作为下次排序的信号
for r in results:
item = await session.get(MemoryItem, r["id"])
if item:
item.use_count += 1
item.last_used_at = now
await session.commit()
return results
async def forget(session: AsyncSession, memory_id: str) -> bool:
item = await session.get(MemoryItem, memory_id)
if not item:
return False
await session.delete(item)
await session.commit()
return True
async def clear_scope(session: AsyncSession, scope: str) -> int:
result = await session.execute(delete(MemoryItem).where(MemoryItem.scope == scope))
await session.commit()
return result.rowcount or 0
async def list_memories(
session: AsyncSession, *, scope: str | None = None, limit: int = 200
) -> list[MemoryItem]:
stmt = select(MemoryItem).order_by(MemoryItem.created_at.desc()).limit(limit)
if scope:
stmt = stmt.where(MemoryItem.scope == scope)
return list((await session.execute(stmt)).scalars())
async def list_scopes(session: AsyncSession) -> list[dict[str, Any]]:
rows = await session.execute(
select(MemoryItem.scope, func.count(MemoryItem.id)).group_by(MemoryItem.scope)
)
return [{"scope": scope, "count": count} for scope, count in rows]