Files
AgentLab/backend/app/api/knowledge.py
T
ThearchyHelios e8ef6ff9a8 检索质量有数了:评测集 + 重排,alpha 不再写死
先做评测再做重排——否则重排的收益只能靠"搜两下感觉准了"。

**评测集**(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,四条路径都有测试。重排是增强,不该因为模型抽风就让整个检索挂掉。
2026-09-19 09:33:09 +08:00

322 lines
10 KiB
Python

from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import get_session
from app.db.models import Document, MemoryItem, Skill
from app.memory import kb, store
# --------------------------------------------------------------------------
# 长期记忆
# --------------------------------------------------------------------------
memory_router = APIRouter(prefix="/api/memory", tags=["memory"])
class MemoryIn(BaseModel):
content: str = Field(min_length=1)
scope: str = "default"
kind: str = "fact"
importance: float = Field(default=0.5, ge=0, le=1)
meta: dict[str, Any] = Field(default_factory=dict)
class MemoryOut(BaseModel):
id: str
scope: str
kind: str
content: str
importance: float
use_count: int
meta: dict[str, Any]
created_at: Any = None
model_config = {"from_attributes": True}
@memory_router.get("/scopes")
async def memory_scopes(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
return await store.list_scopes(session)
@memory_router.get("", response_model=list[MemoryOut])
async def list_memory(
scope: str | None = None,
limit: int = Query(default=200, le=500),
session: AsyncSession = Depends(get_session),
) -> list[MemoryItem]:
return await store.list_memories(session, scope=scope, limit=limit)
@memory_router.post("", response_model=MemoryOut, status_code=201)
async def add_memory(
payload: MemoryIn, session: AsyncSession = Depends(get_session)
) -> MemoryItem:
return await store.remember(
session,
scope=payload.scope,
content=payload.content,
kind=payload.kind,
importance=payload.importance,
meta=payload.meta,
)
@memory_router.get("/search")
async def search_memory(
q: str,
scope: str = "default",
limit: int = 10,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
hits = await store.recall(session, scope=scope, query=q, limit=limit)
return {"query": q, "scope": scope, "results": hits}
@memory_router.delete("/{memory_id}", status_code=204)
async def delete_memory(
memory_id: str, session: AsyncSession = Depends(get_session)
) -> None:
if not await store.forget(session, memory_id):
raise HTTPException(404, "记忆不存在")
@memory_router.delete("/scope/{scope}")
async def clear_scope(scope: str, session: AsyncSession = Depends(get_session)) -> dict[str, int]:
return {"removed": await store.clear_scope(session, scope)}
# --------------------------------------------------------------------------
# 知识库
# --------------------------------------------------------------------------
kb_router = APIRouter(prefix="/api/kb", tags=["knowledge"])
class DocumentOut(BaseModel):
id: str
collection: str
title: str
source: str
mime: str
chunk_count: int
created_at: Any = None
model_config = {"from_attributes": True}
@kb_router.get("/collections")
async def collections(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
return await kb.list_collections(session)
@kb_router.get("/documents", response_model=list[DocumentOut])
async def list_documents(
collection: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Document]:
stmt = select(Document).order_by(Document.created_at.desc()).limit(300)
if collection:
stmt = stmt.where(Document.collection == collection)
return list((await session.execute(stmt)).scalars())
class IngestIn(BaseModel):
collection: str = "default"
title: str = ""
content: str = Field(min_length=1)
source: str = ""
@kb_router.post("/documents", response_model=DocumentOut, status_code=201)
async def ingest(payload: IngestIn, session: AsyncSession = Depends(get_session)) -> Document:
return await kb.ingest_document(
session,
collection=payload.collection,
title=payload.title,
content=payload.content,
source=payload.source,
)
@kb_router.post("/upload", response_model=DocumentOut, status_code=201)
async def upload(
file: UploadFile = File(...),
collection: str = "default",
session: AsyncSession = Depends(get_session),
) -> Document:
raw = await file.read()
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(413, "文件超过 10MB")
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(415, "目前只支持 UTF-8 文本文件(txt / md / json / csv 等)") from None
return await kb.ingest_document(
session,
collection=collection,
title=file.filename or "上传文档",
content=content,
source=file.filename or "",
mime=file.content_type or "text/plain",
)
@kb_router.get("/search")
async def search_kb(
q: str,
collection: str | None = None,
limit: int = 5,
alpha: float | None = Query(default=None, ge=0, le=1,
description="1=纯向量, 0=纯关键词;不传则按 embedder 能力取默认"),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
notes: list[str] = []
hits = await kb.search(session, collection=collection, query=q, limit=limit,
alpha=alpha, on_degrade=notes.append)
# 退回关键词这件事要跟着结果一起交出去,而不是只写进服务端日志——
# 调用方(设置页的"试一下"、外部脚本)看到的是结果变差,得知道为什么
return {"query": q, "collection": collection, "results": hits, "degraded": notes}
@kb_router.get("/embedding")
async def embedding_status(
collection: str | None = None, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""当前用的是哪个 embedder,以及有多少条向量已经对不上了。"""
from app.memory.embeddings import (
EMBEDDING_SETTING_KEY, default_alpha, embedder_dim, embedder_id, has_semantics,
)
from app.db.models import Setting
row = await session.get(Setting, EMBEDDING_SETTING_KEY)
saved = (row.value if row else {}) or {}
return {
"embedder": embedder_id(),
"dim": embedder_dim(),
"configured": bool(saved.get("kind")),
"kind": saved.get("kind", "local"),
"model": saved.get("model", ""),
"stale_chunks": await kb.stale_count(session, collection),
"has_semantics": has_semantics(),
"default_alpha": default_alpha(),
}
class EmbeddingIn(BaseModel):
kind: str = Field(default="local", pattern="^(local|openai)$")
model: str = ""
@kb_router.put("/embedding")
async def set_embedding(
payload: EmbeddingIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""换 embedding 模型。
建不起来就 400 原样交回原因——选了远端却静默退回本地哈希向量,
用户会以为自己在用语义检索,而搜不出同义表达时完全无从怀疑到这里。
"""
from app.memory import embeddings as emb
from app.db.models import Setting
try:
emb.configure(payload.kind, payload.model)
except emb.EmbedderUnavailable as e:
raise HTTPException(400, str(e)) from e
row = await session.get(Setting, emb.EMBEDDING_SETTING_KEY)
value = {"kind": payload.kind, "model": payload.model}
if row:
row.value = value
else:
session.add(Setting(key=emb.EMBEDDING_SETTING_KEY, value=value))
await session.commit()
return {
"embedder": emb.embedder_id(),
"dim": emb.embedder_dim(),
"stale_chunks": await kb.stale_count(session),
}
@kb_router.post("/reindex")
async def reindex(
collection: str | None = None, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""用当前 embedder 重算向量。换了模型之后唯一的恢复手段。"""
return await kb.reindex(session, collection)
@kb_router.delete("/documents/{doc_id}", status_code=204)
async def delete_document(doc_id: str, session: AsyncSession = Depends(get_session)) -> None:
if not await kb.delete_document(session, doc_id):
raise HTTPException(404, "文档不存在")
# --------------------------------------------------------------------------
# Skill(方法论)
# --------------------------------------------------------------------------
skills_router = APIRouter(prefix="/api/skills", tags=["knowledge"])
class SkillIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str = ""
instructions: str = ""
examples: list[dict[str, Any]] = Field(default_factory=list)
suggested_tools: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
class SkillOut(SkillIn):
id: str
created_at: Any = None
model_config = {"from_attributes": True}
@skills_router.get("", response_model=list[SkillOut])
async def list_skills(session: AsyncSession = Depends(get_session)) -> list[Skill]:
return list((await session.execute(select(Skill).order_by(Skill.created_at.desc()))).scalars())
@skills_router.post("", response_model=SkillOut, status_code=201)
async def create_skill(payload: SkillIn, session: AsyncSession = Depends(get_session)) -> Skill:
if (await session.execute(select(Skill).where(Skill.name == payload.name))).scalar_one_or_none():
raise HTTPException(409, "同名 Skill 已存在")
row = Skill(**payload.model_dump())
session.add(row)
await session.commit()
await session.refresh(row)
return row
@skills_router.patch("/{skill_id}", response_model=SkillOut)
async def update_skill(
skill_id: str, payload: SkillIn, session: AsyncSession = Depends(get_session)
) -> Skill:
row = await session.get(Skill, skill_id)
if not row:
raise HTTPException(404, "Skill 不存在")
for key, value in payload.model_dump().items():
setattr(row, key, value)
await session.commit()
await session.refresh(row)
return row
@skills_router.delete("/{skill_id}", status_code=204)
async def delete_skill(skill_id: str, session: AsyncSession = Depends(get_session)) -> None:
row = await session.get(Skill, skill_id)
if not row:
raise HTTPException(404, "Skill 不存在")
await session.delete(row)
await session.commit()