mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:42:00 +00:00
e8ef6ff9a8
先做评测再做重排——否则重排的收益只能靠"搜两下感觉准了"。
**评测集**(test_retrieval_quality.py):24 篇语料 + 16 个问题,问题分成字面
重叠和纯语义两类。第一版是 8 篇、主指标 hit@3,结果各档 alpha 全是 100%,
等于什么都没量——8 选 3 瞎猜都有 37%。扩语料、加干扰、主指标换成 hit@1
之后才开始能分辨。
量出来的第一个结论就推翻了我原来的猜测:
alpha hit@1 hit@5 MRR 字面 语义
0.0 88% 100% 0.911 100% 75%
0.2 81% 100% 0.880 88% 75%
0.5 81% 100% 0.880 88% 75%
1.0 81% 100% 0.880 88% 75%
本地哈希向量那一路不是"弱一点",是**纯噪音**:给它任何权重,hit@1 都从 88%
掉到 81%。而 alpha 的默认值一直写死 0.5,等于主动把检索做差。改成跟着
embedder 能力走(default_alpha:有语义泛化才给 0.5,否则 0),节点上的
alpha 也不再写死默认值。
**重排**(memory/rerank.py):初筛多捞一些(20 条),让模型按"对回答这个问题
有多大帮助"重排,再取 top_n。只捞 5 条的话重排最多把这 5 条换个顺序,第 6
条是对的也救不回来。
用已配置的对话模型做,不引入本地 cross-encoder——后者要下模型权重,和"零配置
能跑起来"的基线冲突。代价是每次检索多一次调用,所以默认关闭。
失败一律退回初筛顺序并说一声:解析不出分数、个数对不上、模型抛异常、没配
provider,四条路径都有测试。重排是增强,不该因为模型抽风就让整个检索挂掉。
161 lines
6.3 KiB
Python
161 lines
6.3 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from app.core.events import EventType
|
||
from app.db.base import SessionLocal
|
||
from app.engine.context import NodeContext, NodeError
|
||
from app.engine.state import GraphState, template_context
|
||
from app.memory import kb, store
|
||
|
||
|
||
async def run_memory(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""长期记忆读写节点。
|
||
|
||
把记忆做成画布上的一个节点,而不是藏在 agent 内部,是为了让"什么时候记、
|
||
记什么、记到哪个 scope"变成可见、可调的编排决策。
|
||
"""
|
||
action = ctx.cfg("action", "recall") # recall | write | clear
|
||
scope = ctx.render_str(ctx.cfg("scope", "") or ctx.run.memory_scope, state)
|
||
|
||
async with SessionLocal() as session:
|
||
if action == "recall":
|
||
query = ctx.render_str(ctx.cfg("query", "{{ last_message }}"), state)
|
||
limit = int(ctx.cfg("limit", 5) or 5)
|
||
hits = await store.recall(
|
||
session, scope=scope, query=query, limit=limit, kind=ctx.cfg("kind") or None
|
||
)
|
||
text = "\n".join(f"- {h['content']}" for h in hits)
|
||
result: dict[str, Any] = {
|
||
"memories": hits,
|
||
"count": len(hits),
|
||
"text": text,
|
||
"scope": scope,
|
||
}
|
||
elif action == "write":
|
||
content = ctx.render_str(ctx.cfg("content", ""), state)
|
||
if not content.strip():
|
||
raise NodeError(ctx.node.id, "记忆写入节点没有内容")
|
||
item = await store.remember(
|
||
session,
|
||
scope=scope,
|
||
content=content,
|
||
kind=ctx.cfg("kind", "fact") or "fact",
|
||
importance=float(ctx.cfg("importance", 0.5) or 0.5),
|
||
meta={"run_id": ctx.run.run_id, "node_id": ctx.node.id},
|
||
)
|
||
result = {"id": item.id, "saved": True, "scope": scope, "content": item.content}
|
||
elif action == "clear":
|
||
removed = await store.clear_scope(session, scope)
|
||
result = {"cleared": removed, "scope": scope}
|
||
else:
|
||
raise NodeError(ctx.node.id, f"未知的记忆操作:{action}")
|
||
|
||
ctx.emit(EventType.LOG, level="info",
|
||
message=f"记忆 {action} @ {scope}:{result.get('count', result.get('cleared', 1))} 条")
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: result.get("text", result)}
|
||
return updates
|
||
|
||
|
||
async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""知识库检索节点。输出既有结构化命中列表,也有拼好的上下文文本。"""
|
||
query = ctx.render_str(ctx.cfg("query", "{{ last_message }}"), state)
|
||
if not query.strip():
|
||
raise NodeError(ctx.node.id, "检索节点没有查询内容")
|
||
|
||
collection = ctx.render_str(ctx.cfg("collection", "") or ctx.run.collection, state)
|
||
limit = int(ctx.cfg("limit", 5) or 5)
|
||
# 不写死 0.5:没配置就跟着 embedder 的能力走(见 embeddings.default_alpha)
|
||
raw_alpha = ctx.cfg("alpha")
|
||
alpha = float(raw_alpha) if raw_alpha not in (None, "") else None
|
||
|
||
# 要重排就先多捞一些:重排只能在初筛给出的候选里挑,初筛只给 5 条的话
|
||
# 它最多把这 5 条换个顺序,第 6 条是对的也救不回来
|
||
mode = ctx.cfg("rerank", "off") or "off"
|
||
fetch = max(limit, 20) if mode == "model" else limit
|
||
|
||
degraded: list[str] = []
|
||
async with SessionLocal() as session:
|
||
hits = await kb.search(
|
||
session, collection=collection, query=query, limit=fetch, alpha=alpha,
|
||
on_degrade=degraded.append,
|
||
)
|
||
|
||
if mode == "model" and hits:
|
||
from app.engine.nodes.llm import _model_spec
|
||
from app.memory.rerank import rerank
|
||
from app.providers.factory import ProviderNotConfigured, get_chat_model
|
||
|
||
try:
|
||
async with SessionLocal() as session:
|
||
model, _ = await get_chat_model(session, _model_spec(ctx))
|
||
except ProviderNotConfigured as e:
|
||
degraded.append(f"重排用不了:{e}")
|
||
model = None
|
||
hits = await rerank(model, query, hits, top_n=limit, on_note=degraded.append)
|
||
else:
|
||
hits = hits[:limit]
|
||
for note in degraded:
|
||
# 检索悄悄少了一半能力,不说的话用户只会觉得"最近搜得不准"
|
||
ctx.emit(EventType.LOG, level="warn", message=note)
|
||
|
||
min_score = float(ctx.cfg("min_score", 0.0) or 0.0)
|
||
hits = [h for h in hits if h["score"] >= min_score]
|
||
|
||
# 拼成可以直接塞进 prompt 的上下文,带编号方便模型引用出处
|
||
context_text = "\n\n".join(
|
||
f"[{i + 1}] {h['title']}(片段 {h['ordinal']})\n{h['content']}"
|
||
for i, h in enumerate(hits)
|
||
)
|
||
# 命中落工件库。SQL 取数、工具调用、agent 工具调用都有快照,检索一直没有,
|
||
# 于是结论只要来自知识库,"这个数是哪来的"这条链就断在这儿
|
||
from app.core.artifact_store import put_json
|
||
from app.memory.embeddings import default_alpha, embedder_id
|
||
|
||
snapshot: str | None = None
|
||
try:
|
||
snapshot = await put_json(
|
||
{
|
||
"query": query, "collection": collection,
|
||
"alpha": alpha if alpha is not None else default_alpha(),
|
||
"min_score": min_score,
|
||
"embedder": embedder_id(), "rerank": mode, "degraded": degraded,
|
||
"hits": hits,
|
||
},
|
||
kind="retrieval_snapshot",
|
||
run_id=ctx.run.run_id, node_id=ctx.node.id,
|
||
)
|
||
except Exception: # noqa: BLE001 - 存不下不影响这次检索本身
|
||
snapshot = None
|
||
|
||
ctx.emit(
|
||
EventType.RETRIEVE_END,
|
||
collection=collection,
|
||
query=query[:200],
|
||
count=len(hits),
|
||
top_score=hits[0]["score"] if hits else 0,
|
||
reranked=mode == "model",
|
||
degraded=bool(degraded),
|
||
artifact=snapshot,
|
||
)
|
||
|
||
result = {
|
||
"hits": hits,
|
||
"count": len(hits),
|
||
"text": context_text,
|
||
"query": query,
|
||
"collection": collection,
|
||
"artifact": snapshot,
|
||
}
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: context_text}
|
||
return updates
|