diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index 5914d97a..ee428145 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -239,11 +239,16 @@ async def embedding_status( """当前用的是哪个 embedder,以及有多少条向量已经对不上了。""" from app.memory.embeddings import ( EMBEDDING_SETTING_KEY, default_alpha, embedder_dim, embedder_id, has_semantics, + unavailable_reason, ) from app.db.models import Setting row = await session.get(Setting, EMBEDDING_SETTING_KEY) saved = (row.value if row else {}) or {} + # 配的是语义模型,实际在用本地哈希:保存的配置没生效(多半是启动时本机的模型 + # 服务还没起)。这时"对不上"的存量向量正是那个模型建好的,界面不能再劝人重建—— + # 那等于拿哈希把它们覆盖掉,连上之后还得再重建一遍 + fallback = saved.get("kind") == "openai" and not has_semantics() return { "embedder": embedder_id(), "dim": embedder_dim(), @@ -256,6 +261,8 @@ async def embedding_status( # 的话,用户点完重建还是想不起事,而且不知道为什么 "stale_memories": await store.stale_count(session), "has_semantics": has_semantics(), + "fallback": fallback, + "fallback_reason": (unavailable_reason() or "") if fallback else "", "default_alpha": default_alpha(), # 没建倒排的片段会走全表扫——结果对,但慢 "unindexed_chunks": await inverted.missing_count(session, collection), diff --git a/backend/app/memory/embeddings.py b/backend/app/memory/embeddings.py index cb08c6bf..ec40ff22 100644 --- a/backend/app/memory/embeddings.py +++ b/backend/app/memory/embeddings.py @@ -101,6 +101,11 @@ EMBEDDING_SETTING_KEY = "embedding" #: 进程内缓存。get_embedder 在检索热路径上被反复调用,不该每次都去读库 _cached: tuple[str, Any] | None = None +#: 保存的配置为什么没生效。启动时本机的模型服务还没起就会这样:整个进程一直用 +#: 本地哈希,服务后来起了也不会自己连上。以前只在服务端日志里留一句,界面上看着 +#: 一切正常,检索却一直是纯关键词 +_unavailable: str | None = None + class EmbedderUnavailable(RuntimeError): """配了远端 embedder 但建不起来。message 直接给用户看。""" @@ -128,8 +133,14 @@ def _make(kind: str, model: str, base_url: str = "", *, def configure(kind: str, model: str = "", base_url: str = "") -> None: """切换当前 embedder。设置页保存后调用,也用于测试。建不起来会抛。""" - global _cached + global _cached, _unavailable _cached = (f"{kind}:{model}:{base_url}", _make(kind, model, base_url, strict=True)) + _unavailable = None + + +def unavailable_reason() -> str | None: + """保存的配置没能生效的原因;生效了(或还没试过)是 None。""" + return _unavailable def get_embedder() -> "LocalEmbedder | OpenAIEmbedder": @@ -197,11 +208,16 @@ async def load_setting(session: Any) -> None: """从库里读 embedding 配置并生效。应用启动时调一次。""" from app.db.models import Setting + global _unavailable row = await session.get(Setting, EMBEDDING_SETTING_KEY) saved = (row.value if row else {}) or {} kind = saved.get("kind") if kind: - configure(kind, saved.get("model", ""), saved.get("base_url", "")) + try: + configure(kind, saved.get("model", ""), saved.get("base_url", "")) + except EmbedderUnavailable as e: + _unavailable = str(e) + raise async def embed_text(text: str) -> np.ndarray: diff --git a/backend/tests/test_kb.py b/backend/tests/test_kb.py index 7cb67eb3..d4997697 100644 --- a/backend/tests/test_kb.py +++ b/backend/tests/test_kb.py @@ -581,6 +581,78 @@ async def test_a_dead_endpoint_says_which_address(monkeypatch) -> None: assert "127.0.0.1:9999" in r.json()["detail"] +async def test_a_saved_model_that_did_not_connect_is_reported(monkeypatch) -> None: + """配了本机 LM Studio 上的模型,服务启动时它还没起:进程一直用本地哈希。 + + 以前状态接口只报 local-hashing,页面按"配的是不是 local"判断,于是既不提示 + 没有语义能力,还高亮"重建索引"——那些"对不上"的向量正是那个模型建好的, + 一点就拿哈希覆盖掉。退回要报出来、原因要带上,重新连上之后提示要消失; + 明确选了本地哈希的不算退回。 + """ + import langchain_openai + from httpx import ASGITransport, AsyncClient + + from app.db.models import Setting + from app.main import app + + class _Down: + def __init__(self, **kw): + raise RuntimeError("connection refused") + + class _Up: + def __init__(self, **kw): + pass + + def embed_query(self, text): + return [0.1] * 8 + + # 模块级缓存和库里的配置都是整个测试会话共用的,结束时还原 + monkeypatch.setattr(emb, "_cached", None) + monkeypatch.setattr(emb, "_unavailable", None) + saved = {"kind": "openai", "model": "text-embedding-qwen3-embedding-4b", + "base_url": "http://127.0.0.1:1234/v1"} + async with SessionLocal() as session: + row = await session.get(Setting, emb.EMBEDDING_SETTING_KEY) + before = dict(row.value) if row else None + if row: + row.value = saved + else: + session.add(Setting(key=emb.EMBEDDING_SETTING_KEY, value=saved)) + await session.commit() + + try: + monkeypatch.setattr(langchain_openai, "OpenAIEmbeddings", _Down) + async with SessionLocal() as session: + with pytest.raises(emb.EmbedderUnavailable): + await emb.load_setting(session) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + down = (await c.get("/api/kb/embedding")).json() + assert down["embedder"] == "local-hashing" and down["has_semantics"] is False + assert down["fallback"] is True, "配了语义模型却在用哈希,状态里得说出来" + assert "127.0.0.1:1234" in down["fallback_reason"], down["fallback_reason"] + + # 服务起来之后点「重新连接」:发的就是保存着的那份配置 + monkeypatch.setattr(langchain_openai, "OpenAIEmbeddings", _Up) + assert (await c.put("/api/kb/embedding", json=saved)).status_code == 200 + up = (await c.get("/api/kb/embedding")).json() + assert up["has_semantics"] is True + assert up["fallback"] is False and up["fallback_reason"] == "" + + await c.put("/api/kb/embedding", json={"kind": "local"}) + local = (await c.get("/api/kb/embedding")).json() + assert local["has_semantics"] is False and local["fallback"] is False + finally: + async with SessionLocal() as session: + row = await session.get(Setting, emb.EMBEDDING_SETTING_KEY) + if before is None: + await session.delete(row) + else: + row.value = before + await session.commit() + + async def test_the_endpoint_is_remembered_across_restarts(client=None) -> None: """base_url 不落库的话,重启后会悄悄退回本地哈希——而库里的向量是 上一个模型建的,检索会整体退回关键词,用户只看到"重启之后搜得不准了"。""" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4e4ab4ee..68d8a35c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -222,6 +222,8 @@ export const api = { embedder: string; dim: number; configured: boolean kind: string; model: string; base_url: string stale_chunks: number; stale_memories: number; unindexed_chunks: number + // has_semantics 看的是实际在用的那个;fallback = 配了语义模型却退回了本地哈希 + has_semantics: boolean; fallback: boolean; fallback_reason: string }>(`/kb/embedding${collection ? `?collection=${collection}` : ''}`), setEmbedding: (body: { kind: string; model?: string; base_url?: string }) => put<{ diff --git a/frontend/src/pages/KnowledgePage.tsx b/frontend/src/pages/KnowledgePage.tsx index 16fa54a0..e8154ba1 100644 --- a/frontend/src/pages/KnowledgePage.tsx +++ b/frontend/src/pages/KnowledgePage.tsx @@ -67,15 +67,20 @@ function EmbedderBar({ collection, onChanged }: { // 把倒排一起建了。按钮只认"向量对不上"的话,这些片段永远等不到人来点 const unindexed = info.unindexed_chunks ?? 0 const stale = staleChunks + staleMemories + unindexed + // 配了语义模型、实际在用本地哈希:保存的配置没生效(多半是启动时本机的模型服务 + // 还没起),服务后来起了也不会自己连上。以前这里按"配的是不是 local"判断,于是 + // 既不说没有语义能力,还高亮"重建索引"——那些"对不上"的向量正是那个模型建好的, + // 一点就拿哈希覆盖掉,连上之后还得再重建一遍 + const fallback = !!info.fallback - const pick = async (kind: string, m = '', url = '') => { + const pick = async (kind: string, m = '', url = '', done = '切到') => { setBusy(true) try { const out = await api.kb.setEmbedding({ kind, model: m, base_url: url }) // 换模型之后维度多半变了,存量向量全部作废——这件事要当场说, // 而不是等用户发现"最近搜得不准" const stale = (out.stale_chunks ?? 0) + (out.stale_memories ?? 0) - toast(`已切到 ${out.embedder}(${out.dim} 维)` + toast(`已${done} ${out.embedder}(${out.dim} 维)` + (stale ? `,${stale} 条存量向量需要重建` : ''), 'ok') setEditing(false) await refresh() @@ -116,7 +121,8 @@ function EmbedderBar({ collection, onChanged }: {
+ 配置的是 {info.model}({info.base_url || 'api.openai.com'}),但没连上, + 现在实际用的是本地哈希向量:检索只认字面,搜不出同义表达。 +
+ {info.fallback_reason &&{info.fallback_reason}
} +