Files
AgentLab/backend/tests/test_kb.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

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