mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:01:59 +00:00
913380f738
**倒排**:以前每次检索都 select(Chunk) 全量载入,再在 Python 里重建一遍 BM25 ——不只是扫表,是对每一段重新分词、重新统计词频,每次查询都来一遍。几百段 无感,上万段就是秒级,而那正是知识库开始有用的规模。 没用 SQLite 的 FTS5。方案里写了实施前先验证,验了:trigram 分词器要求查询 至少三个字符,中文的「商家」「订单」「权限」这些两字词一个都召不回。照搬会 让召回大幅倒退。现有分词器本来就做中文单字 + 二元组,自己存一张 chunk_terms 既保住召回,也不把知识库锁死在 SQLite 上。 快是次要的,一致才是前提——所以先写了"倒排必须和全表扫给出同一个名次"的 测试。它当场抓到两个真问题: 1. BM25 被算了两次:倒排按全集统计(N、avg_len、df)算一次,hybrid_rank 又在候选子集上重建一次,而子集的 df 和平均长度是另一套数。改成把倒排 算好的分传下去,不再重算。 2. 原来的 BM25.score 遍历的是**带重复**的查询词表:「导出订单最多多少行」 里「多」出现两次,贡献就加两次。倒排用 set() 去重,顶部那条差了 2.32。 改成按查询词频加权。 还有一处是排序确定性:分数并列时稳定排序保留输入顺序,而两条路径喂进去的 顺序天生不同——同一个查询会因为走了哪条路给出不同名次。两边都按 id 定序。 倒排跟着 ingest 建、跟着删文档清、跟着 reindex 重建;没建倒排的片段(存量 数据升级上来就是这样)退回全表扫——宁可慢,也不要因为索引没建好就说"没搜到"。 **解析**:upload 以前只收 UTF-8,PDF 传上去直接 415,而 mime 字段记了却从不 参与判断。现在认 PDF / DOCX / HTML。解析库走可选依赖 [docs](几十兆,只用 纯文本的人不该为此付出安装时间),HTML 用已在依赖里的 bs4。 几处是实际验出来的,不是想出来的:DOCX 要连表格一起取(合同和口径说明的关键 信息常常整个在表里);PDF 每行拖着排版填充空格,不清掉会混进切块和词频统计 (实测 416 字里只有 68 字是正文);扫描件提不出文字要明说,否则悄悄存一份 空文档,用户以为传成功了却永远搜不到。 PDF/DOCX 两条路径在临时 venv 里用真文件验过(含中文、表格、扫描件、坏文件), 没有动开发环境。
328 lines
11 KiB
Python
328 lines
11 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 inverted, 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")
|
|
|
|
from app.memory.parsing import UnsupportedDocument, extract
|
|
|
|
try:
|
|
content = extract(raw, file.filename or "", file.content_type or "")
|
|
except UnsupportedDocument as e:
|
|
# 原样交回:里面写的是"装哪个包"或"这是扫描件",都能照着做
|
|
raise HTTPException(415, str(e)) 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(),
|
|
# 没建倒排的片段会走全表扫——结果对,但慢
|
|
"unindexed_chunks": await inverted.missing_count(session, collection),
|
|
}
|
|
|
|
|
|
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()
|