mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:31:59 +00:00
df935599f2
三件事,都是"这三个模块一条测试都没有"的后果。 **换 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 且配置不变。
175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
"""知识库:切块、混合检索、换 embedder 之后还能不能用。
|
|
|
|
这三个模块(kb.py / embeddings.py / memory/store.py,共 526 行检索逻辑)
|
|
在此之前**一条测试都没有**。于是有两件事一直没人发现:
|
|
|
|
- 换 embedding 模型会让检索整体 500:库里是 512 维的本地向量、查询是 1536 维
|
|
的远端向量,cosine 直接 `shapes (1536,) and (512,) not aligned`
|
|
- 存量向量属于哪个模型,代码里没有任何地方记着,所以也无从降级
|
|
|
|
现在这两件事都有守的了。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Chunk
|
|
from app.memory import embeddings as emb
|
|
from app.memory import kb
|
|
from app.memory.embeddings import hybrid_rank, to_blob
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _local_embedder():
|
|
"""每个用例都从本地 embedder 起步,别让上一个用例的 configure 漏过来。"""
|
|
emb.configure("local")
|
|
yield
|
|
emb.configure("local")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 切块
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_short_paragraphs_stay_whole() -> None:
|
|
blocks = kb.chunk_text("第一段。\n\n第二段。\n\n第三段。")
|
|
assert blocks and all("第" in b for b in blocks)
|
|
|
|
|
|
def test_a_long_paragraph_is_cut_on_sentence_boundaries() -> None:
|
|
"""命中之后读起来要是完整的句子,不能拦腰截断。"""
|
|
text = "。".join(f"这是第{i}句话,讲了一点内容" for i in range(120)) + "。"
|
|
blocks = kb.chunk_text(text, target=200, overlap=0)
|
|
assert len(blocks) > 1
|
|
# 除了最后一块,其余都该收在句号上
|
|
assert all(b.rstrip().endswith("。") for b in blocks[:-1])
|
|
|
|
|
|
def test_a_sentence_longer_than_the_target_is_hard_cut() -> None:
|
|
"""没有标点可切时也不能无限膨胀,硬切是最后的兜底。"""
|
|
blocks = kb.chunk_text("啊" * 2000, target=300, overlap=0)
|
|
assert len(blocks) > 1 and max(len(b) for b in blocks) <= 300
|
|
|
|
|
|
def test_empty_input_yields_nothing() -> None:
|
|
assert kb.chunk_text("") == [] and kb.chunk_text(" \n\n ") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# hybrid_rank 的归一化边界
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_single_candidate_is_not_scored_zero() -> None:
|
|
"""min-max 归一化在只有一条候选时 hi==lo。
|
|
|
|
返回 0 的话它的合并分恒为 0,会被 min_score 过滤掉——于是新建集合写入
|
|
第一条之后永远搜不到,而"先写一条、下一轮再检索"正是 agent 最常见的用法。
|
|
embeddings.py 里为此写了一段注释,但一直没有测试守着。
|
|
"""
|
|
vec = np.ones(4, dtype=np.float32)
|
|
ranked = hybrid_rank("查询", ["只有这一条"], [vec], vec)
|
|
assert len(ranked) == 1 and ranked[0][1] > 0
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 换 embedder:以前这里直接 500
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def _seed(collection: str, texts: list[str]) -> None:
|
|
async with SessionLocal() as session:
|
|
for t in texts:
|
|
await kb.ingest_document(
|
|
session, collection=collection, title=t[:10], content=t)
|
|
|
|
|
|
def test_stale_vectors_are_recognised() -> None:
|
|
"""存量数据那两列是空的,一律当成对不上——保守但不会拿旧坐标冒充新空间。"""
|
|
assert emb.usable("", 0) is False
|
|
assert emb.usable("openai:text-embedding-3-small", 1536) is False
|
|
assert emb.usable(emb.embedder_id(), emb.embedder_dim()) is True
|
|
|
|
|
|
async def test_ingest_records_who_built_the_vector() -> None:
|
|
await _seed("kbtest_mark", ["平台管理员一共有六个人。"])
|
|
async with SessionLocal() as session:
|
|
from sqlalchemy import select
|
|
row = (await session.execute(
|
|
select(Chunk).where(Chunk.collection == "kbtest_mark"))).scalars().first()
|
|
assert row.embed_model == emb.embedder_id()
|
|
assert row.embed_dim == emb.embedder_dim()
|
|
|
|
|
|
async def test_search_degrades_instead_of_crashing_after_a_model_switch() -> None:
|
|
"""整件事的核心。
|
|
|
|
以前:cosine(1536 维查询, 512 维存量) → ValueError → 检索 500。
|
|
现在:整体退回纯关键词,并把原因说出来。
|
|
"""
|
|
await _seed("kbtest_switch", ["平台管理员一共有六个人。", "商家账户共五个。"])
|
|
|
|
# 把库里的向量伪装成别的模型建的(维度也不同)
|
|
async with SessionLocal() as session:
|
|
from sqlalchemy import select
|
|
rows = list((await session.execute(
|
|
select(Chunk).where(Chunk.collection == "kbtest_switch"))).scalars())
|
|
for r in rows:
|
|
r.embedding = to_blob(np.ones(1536, dtype=np.float32))
|
|
r.embed_model = "openai:text-embedding-3-small"
|
|
r.embed_dim = 1536
|
|
await session.commit()
|
|
|
|
notes: list[str] = []
|
|
async with SessionLocal() as session:
|
|
hits = await kb.search(session, collection="kbtest_switch", query="管理员",
|
|
limit=5, on_degrade=notes.append)
|
|
|
|
assert hits, "退回关键词也要能搜到东西,而不是空手而归"
|
|
assert notes, "少了一半能力却不吭声,用户只会觉得最近搜得不准"
|
|
assert "重建索引" in notes[0] and "2/2" in notes[0]
|
|
|
|
|
|
async def test_reindex_restores_hybrid_search() -> None:
|
|
await _seed("kbtest_reindex", ["平台管理员一共有六个人。"])
|
|
async with SessionLocal() as session:
|
|
from sqlalchemy import select
|
|
row = (await session.execute(
|
|
select(Chunk).where(Chunk.collection == "kbtest_reindex"))).scalars().first()
|
|
row.embed_model, row.embed_dim = "别的模型", 99
|
|
await session.commit()
|
|
|
|
async with SessionLocal() as session:
|
|
assert await kb.stale_count(session, "kbtest_reindex") == 1
|
|
out = await kb.reindex(session, "kbtest_reindex")
|
|
assert out["reindexed"] == 1
|
|
assert await kb.stale_count(session, "kbtest_reindex") == 0
|
|
|
|
notes: list[str] = []
|
|
async with SessionLocal() as session:
|
|
hits = await kb.search(session, collection="kbtest_reindex", query="管理员",
|
|
on_degrade=notes.append)
|
|
assert hits and not notes, "重建之后不该再降级"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 配置 embedder
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_choosing_a_remote_embedder_that_cannot_be_built_says_so(monkeypatch) -> None:
|
|
"""选了远端却静默退回本地哈希向量,用户会以为自己在用语义检索。"""
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
with pytest.raises(emb.EmbedderUnavailable) as e:
|
|
emb.configure("openai", "text-embedding-3-small")
|
|
assert "OPENAI_API_KEY" in str(e.value)
|
|
|
|
|
|
def test_the_local_embedder_is_always_available() -> None:
|
|
emb.configure("local")
|
|
assert emb.embedder_id() == "local-hashing" and emb.embedder_dim() == 512
|