mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
知识库页不再把"退回本地哈希"藏起来:配了语义模型却没连上时说实话,也不再劝人重建
本机配的是 LM Studio 上的 text-embedding-qwen3-embedding-4b。服务启动时它没起, load_setting 连不上,整个进程就一直用本地哈希——它后来起了也不会自己连上,原因只在 服务端日志里留一句。页面按"配的是不是 local"判断,于是: - 不标"没有语义能力",看着一切正常,检索却一直是纯关键词; - 高亮"重建索引(73 段)"——那些"对不上"的向量正是 qwen3 建好的,一点就拿哈希 覆盖掉,连上之后还得再重建一遍。 改法: 1. 后端记下保存的配置为什么没生效(embeddings.unavailable_reason),状态接口多报 fallback / fallback_reason:配的是语义模型、实际在用本地哈希。重新配置成功就清掉。 2. 页面按实际生效的 embedder(has_semantics)标"没有语义能力"。退回时说清配的是哪个、 在哪、为什么没连上,给「重新连接」——发的就是保存着的那份配置,服务起来后点它即可, 不用重启;"重建索引"收起,存量提示改口成先别重建。明确选了本地哈希的照旧:标没有 语义能力,重建按钮还在。 验证:test_kb 新增一条,走完"启动连不上 → 状态里报退回并带原因 → 重新连上后消失 → 明确选本地不算退回"。check-ui 新增一组伪造状态接口的断言(退回 / 重新连接 / 明确选 本地,写请求一律拦下);把页面换回旧版本跑,这组挂 8 项。后端 471 passed,tsc 与 check-decode / guards / stream / ui 全绿;本机状态接口现在如实报出退回和原因。
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 不落库的话,重启后会悄悄退回本地哈希——而库里的向量是
|
||||
上一个模型建的,检索会整体退回关键词,用户只看到"重启之后搜得不准了"。"""
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 }: {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-faint">向量模型</span>
|
||||
<span className="mono text-[11px]">{info.embedder} · {info.dim} 维</span>
|
||||
{info.kind === 'local' && (
|
||||
{/* 看实际在用的那个,不看配的是什么 */}
|
||||
{info.has_semantics === false && (
|
||||
<span className="text-[10.5px]" style={{ color: 'var(--warn)' }}>没有语义能力</span>
|
||||
)}
|
||||
<button className="btn btn-sm btn-ghost" disabled={busy}
|
||||
@@ -124,12 +130,28 @@ function EmbedderBar({ collection, onChanged }: {
|
||||
{editing ? '收起' : '换一个'}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
{stale > 0 && (
|
||||
{stale > 0 && !fallback && (
|
||||
<button className="btn btn-sm btn-primary" disabled={busy} onClick={rebuild}>
|
||||
重建索引({stale} 段)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{fallback && (
|
||||
<div className="mt-2 rounded-md border p-2 text-[11px]" style={{ borderColor: 'var(--warn)' }}>
|
||||
<p style={{ color: 'var(--warn)' }}>
|
||||
配置的是 {info.model}({info.base_url || 'api.openai.com'}),但没连上,
|
||||
现在实际用的是本地哈希向量:检索只认字面,搜不出同义表达。
|
||||
</p>
|
||||
{info.fallback_reason && <p className="mt-1 text-faint">{info.fallback_reason}</p>}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<button className="btn btn-sm btn-primary" disabled={busy}
|
||||
onClick={() => void pick('openai', info.model, info.base_url, '重新连上')}>
|
||||
重新连接
|
||||
</button>
|
||||
<span className="text-faint">服务起来之后点这里,不用重启。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
<button className="btn btn-sm w-full justify-start" disabled={busy}
|
||||
@@ -175,7 +197,10 @@ function EmbedderBar({ collection, onChanged }: {
|
||||
{staleChunks && staleMemories ? '、' : ''}
|
||||
{staleMemories ? ` ${staleMemories} 条记忆` : ''}
|
||||
的向量是用别的模型建的,和当前模型对不上——检索/召回这些内容时
|
||||
会退回纯关键词(搜不出同义表达)。重建索引后恢复。
|
||||
会退回纯关键词(搜不出同义表达)。
|
||||
{fallback
|
||||
? '先别重建:现在重建用的是本地哈希,只会把它们覆盖掉,连上之后还得再重建一遍。'
|
||||
: '重建索引后恢复。'}
|
||||
</p>
|
||||
)}
|
||||
{unindexed > 0 && staleChunks + staleMemories === 0 && (
|
||||
|
||||
@@ -229,6 +229,88 @@ console.log('\n=== 向量模型设置 ===')
|
||||
await page.close()
|
||||
}
|
||||
|
||||
console.log('\n=== 向量模型:配了语义模型却退回本地 ===')
|
||||
{
|
||||
// 配的是本机 LM Studio 上的模型,服务启动时它没起,进程就一直用本地哈希。
|
||||
// 以前页面按"配的是不是 local"判断:不说没有语义能力,还高亮"重建索引"——
|
||||
// 那些"对不上"的向量正是那个模型建好的,一点就被哈希覆盖。真实环境撞不撞得上
|
||||
// 这个状态看运气,所以这里伪造状态接口,几种情况各看一遍;写请求一律拦下
|
||||
const embeddingPage = async (initial, onPut) => {
|
||||
let status = initial
|
||||
const page = await ctx.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
const sent = []
|
||||
await page.route('**/api/**', (route) => {
|
||||
const r = route.request()
|
||||
if (new URL(r.url()).pathname.endsWith('/api/kb/embedding')) {
|
||||
if (r.method() === 'GET') return route.fulfill({ json: status })
|
||||
if (r.method() === 'PUT') {
|
||||
sent.push(r.postDataJSON())
|
||||
status = onPut(status)
|
||||
return route.fulfill({ json: {
|
||||
embedder: status.embedder, dim: status.dim,
|
||||
stale_chunks: status.stale_chunks, stale_memories: status.stale_memories } })
|
||||
}
|
||||
}
|
||||
return r.method() === 'GET' ? route.continue() : route.abort()
|
||||
})
|
||||
await page.goto(`${WEB}/knowledge`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(500)
|
||||
return { page, errors, sent, body: () => page.locator('body').innerText() }
|
||||
}
|
||||
const down = {
|
||||
embedder: 'local-hashing', dim: 512, configured: true, kind: 'openai',
|
||||
model: 'text-embedding-qwen3-embedding-4b', base_url: 'http://127.0.0.1:1234/v1',
|
||||
stale_chunks: 68, stale_memories: 5, unindexed_chunks: 0,
|
||||
has_semantics: false, default_alpha: 0, fallback: true,
|
||||
fallback_reason: '用不了 http://127.0.0.1:1234/v1 上的 embedding 模型 '
|
||||
+ 'text-embedding-qwen3-embedding-4b:APIConnectionError: Connection error.。检查服务在不在',
|
||||
}
|
||||
const up = {
|
||||
...down, embedder: 'openai:text-embedding-qwen3-embedding-4b', dim: 2560,
|
||||
stale_chunks: 0, stale_memories: 0, has_semantics: true, default_alpha: 0.5,
|
||||
fallback: false, fallback_reason: '',
|
||||
}
|
||||
|
||||
const a = await embeddingPage(down, () => up)
|
||||
let body = await a.body()
|
||||
check('退回本地时标出没有语义能力', body.includes('没有语义能力'))
|
||||
check('说清配的是哪个、为什么没用上',
|
||||
body.includes('text-embedding-qwen3-embedding-4b') && body.includes('没连上'))
|
||||
check('没连上的原因原样给出来', body.includes('APIConnectionError'))
|
||||
check('不再劝人重建(会把建好的向量覆盖掉)',
|
||||
await a.page.getByRole('button', { name: /重建索引/ }).count() === 0)
|
||||
check('存量提示改口成先别重建', body.includes('先别重建'))
|
||||
|
||||
const reconnect = a.page.getByRole('button', { name: '重新连接' })
|
||||
check('给得出「重新连接」', await reconnect.count() === 1)
|
||||
if (await reconnect.count()) {
|
||||
await reconnect.click()
|
||||
await a.page.waitForTimeout(800)
|
||||
}
|
||||
const req = a.sent[0]
|
||||
check('重新连接发的是保存着的那份配置',
|
||||
req?.kind === 'openai' && req.model === down.model && req.base_url === down.base_url,
|
||||
JSON.stringify(req))
|
||||
body = await a.body()
|
||||
check('连上了说一声', body.includes('已重新连上'))
|
||||
check('连上之后提示消失', !body.includes('没有语义能力') && !body.includes('没连上'))
|
||||
check('没有运行时报错', a.errors.length === 0, a.errors.slice(0, 2).join(' | '))
|
||||
await a.page.close()
|
||||
|
||||
// 明确选了本地哈希是正常情况:照旧标没有语义能力,重建按钮也还在
|
||||
const b = await embeddingPage(
|
||||
{ ...down, kind: 'local', model: '', base_url: '', fallback: false, fallback_reason: '' },
|
||||
(s) => s)
|
||||
body = await b.body()
|
||||
check('明确选了本地哈希:照旧标没有语义能力', body.includes('没有语义能力'))
|
||||
check('……不冒出"没连上"', !body.includes('没连上'))
|
||||
check('……重建按钮还在,正常情况别一起藏了',
|
||||
await b.page.getByRole('button', { name: /重建索引/ }).count() === 1)
|
||||
await b.page.close()
|
||||
}
|
||||
|
||||
console.log('\n=== 会话 ===')
|
||||
{
|
||||
// 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库:
|
||||
|
||||
Reference in New Issue
Block a user