知识库页不再把"退回本地哈希"藏起来:配了语义模型却没连上时说实话,也不再劝人重建

本机配的是 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:
ThearchyHelios
2026-09-25 19:09:19 +08:00
parent 3b2c082743
commit 86a0928512
6 changed files with 211 additions and 7 deletions
+7
View File
@@ -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),
+18 -2
View File
@@ -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:
+72
View File
@@ -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 不落库的话,重启后会悄悄退回本地哈希——而库里的向量是
上一个模型建的,检索会整体退回关键词,用户只看到"重启之后搜得不准了"。"""
+2
View File
@@ -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<{
+30 -5
View File
@@ -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 && (
+82
View File
@@ -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=== 会话 ===')
{
// 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库: