Files
AgentLab/backend/app/memory/kb.py

407 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Chunk, Document
from app.memory import inverted
from app.memory.embeddings import (
default_alpha, embed_text, embed_texts, embedder_dim, embedder_id, from_blob,
hybrid_contrib, to_blob, usable,
)
_TARGET = 800
_OVERLAP = 120
#: 每批送多少段去算向量。远端接口单次批量有上限(OpenAI 是 2048),而且
#: 一次把上万段全发出去,要么超时要么被限流,中途失败还没有断点
_EMBED_BATCH = 64
def chunk_text(text: str, target: int = _TARGET, overlap: int = _OVERLAP) -> list[str]:
"""按语义边界切块。
先按空行分段,段落塞不下再按句子切,实在超长才硬切。
这样切出来的块不会把一句话拦腰截断,检索命中后读起来是完整的。
"""
text = text.replace("\r\n", "\n").strip()
if not text:
return []
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
blocks: list[str] = []
for para in paragraphs:
if len(para) <= target:
blocks.append(para)
continue
sentences = re.split(r"(?<=[。!?.!?\n])\s*", para)
current = ""
for sentence in sentences:
if not sentence:
continue
if len(current) + len(sentence) <= target:
current += sentence
else:
if current:
blocks.append(current)
while len(sentence) > target:
blocks.append(sentence[:target])
sentence = sentence[target:]
current = sentence
if current:
blocks.append(current)
# 把相邻小块合并到接近目标长度,并留一点重叠保住上下文。
#
# 表格要特殊照顾:一张几十行的表会横跨好几块,而除了第一块,其余都只有
# 数据行没有列名——「上海 | 172151 | 13%」这样的片段召回出来,模型没法
# 知道这几个数是什么。所以开新块时把表头补在前面。
merged: list[str] = []
buf = ""
header = "" # 当前这张表的表头
prev_is_row = False
for block in blocks:
is_row = bool(_TABLE_ROW.match(block))
if is_row and not prev_is_row:
header = block.strip() # 一段表格的第一行就是表头
elif not is_row:
header = "" # 离开表格了
prev_is_row = is_row
if len(buf) + len(block) + 1 <= target:
buf = f"{buf}\n{block}" if buf else block
continue
if buf:
merged.append(buf)
tail = _overlap_tail(buf, overlap) if overlap else ""
# 新块要从表格中间开始,就先补一份表头。它才几十个字,
# 换来的是这块单独拿出来也读得懂
lead = ""
if is_row and header and header not in tail:
lead = f"{header}\n"
# 表头必须在最前面。写成 tail+lead 的话它会夹在重叠的那几行后面,
# 变成表格中间的一行——块的开头仍然是一串没有主语的数
buf = f"{lead}{tail}{block}"
else:
merged.append(block)
buf = ""
if buf:
merged.append(buf)
return [m.strip() for m in merged if m.strip()]
def _overlap_tail(buf: str, overlap: int) -> str:
"""取重叠部分,但只在行边界上取。
原来是 buf[-overlap:] 按字符切,于是表格会从行中间断开——实测一份 Word
文档切出来的第二块开头是「| 上海 | 172151 | 13%」,门店名被切没了。
一列没有主语的数字,召回到了也读不懂。
"""
if not buf or overlap <= 0:
return ""
tail = buf[-overlap:]
cut = tail.find("\n")
# 整段都没换行就整个丢掉:宁可不要重叠,也不要半行
return tail[cut + 1:] + "\n" if cut >= 0 else ""
#: 看起来像表格行:含有单元格分隔符。docx 抽出来用 " | " 拼,Markdown 表格同形
_TABLE_ROW = re.compile(r"^\s*\|?[^|\n]*\|")
async def ingest_document(
session: AsyncSession,
*,
collection: str,
title: str,
content: str,
source: str = "",
mime: str = "text/plain",
meta: dict[str, Any] | None = None,
on_progress: Any = None,
) -> Document:
doc = await create_document(
session, collection=collection, title=title, content=content,
source=source, mime=mime, meta=meta, status="ready",
)
await process_document(session, doc, on_progress=on_progress)
return doc
async def create_document(
session: AsyncSession,
*,
collection: str,
title: str,
content: str,
source: str = "",
mime: str = "text/plain",
meta: dict[str, Any] | None = None,
status: str = "ready",
) -> Document:
"""只建文档行,不切块不算向量。
拆出来是为了让上传能立刻返回:切块 + 算向量在后台跑,一个 10MB 文档配上
远端 embedding 要几十分钟,压在 HTTP 请求里必然超时。
"""
doc = Document(
collection=collection,
title=title or source or "未命名文档",
source=source,
mime=mime,
content=content,
meta=meta or {},
status=status,
)
session.add(doc)
await session.commit()
await session.refresh(doc)
return doc
async def process_document(
session: AsyncSession, doc: Document, *, on_progress: Any = None
) -> Document:
"""切块、算向量、建倒排。可以对着一个已存在的文档行反复跑(会先清掉旧的)。"""
from sqlalchemy import delete as sa_delete
old_ids = list((await session.execute(
select(Chunk.id).where(Chunk.document_id == doc.id))).scalars())
if old_ids:
await inverted.drop_chunk_terms(session, old_ids)
await session.execute(sa_delete(Chunk).where(Chunk.document_id == doc.id))
collection, content = doc.collection, doc.content
pieces = chunk_text(content)
model_id, dim = embedder_id(), embedder_dim()
# 一批算完就落盘。分批本身是必须的——原来 embed_texts(pieces) 一次全发,
# 本地哈希只是慢,换成远端模型就是必挂(一个 10MB 文档近八千段,塞不下)。
#
# 每批都 commit 还有第二个好处:进度能被别人看见。试过用另一个 session
# 单独写进度,但 SQLite 只允许一个写者,主事务开着的时候那次写会被堵住,
# 界面上整个过程都是空的、只在结束时闪一下。
for i in range(0, len(pieces), _EMBED_BATCH):
part = pieces[i:i + _EMBED_BATCH]
vectors = await embed_texts(part)
made: list[Chunk] = []
for offset, (piece, vec) in enumerate(zip(part, vectors)):
chunk = Chunk(
document_id=doc.id,
collection=collection,
ordinal=i + offset,
content=piece,
embedding=to_blob(vec),
embed_model=model_id,
embed_dim=dim,
meta={"title": doc.title},
)
session.add(chunk)
made.append(chunk)
await session.flush() # 先拿到 chunk.id,倒排行要引用它
for chunk in made:
await inverted.index_chunk(session, chunk)
done = min(i + _EMBED_BATCH, len(pieces))
doc.meta = {**(doc.meta or {}), "progress": {"done": done, "total": len(pieces)}}
await session.commit()
if on_progress:
on_progress(done, len(pieces))
doc.chunk_count = len(pieces)
doc.status = "ready"
doc.error = ""
await session.commit()
await session.refresh(doc)
return doc
async def search(
session: AsyncSession,
*,
collection: str | None,
query: str,
limit: int = 5,
alpha: float | None = None,
on_degrade: Any = None,
) -> list[dict[str, Any]]:
"""混合检索。
存量向量和当前 embedder 对不上时**整个查询退回纯关键词**,并通过
on_degrade 把原因交出去。不把对不上的那些当 0 分:那会让它们静默沉底,
用户看到的是结果莫名其妙变差,而不是"这里有一批索引该重建了"。
在此之前这里连维度都不查,换个 embedder 直接 ValueError
(shapes (1536,) and (512,) not aligned),整个检索 500。
"""
# 没显式指定就跟着 embedder 走——写死 0.5 是在给一个没有语义能力的信号
# 一半权重,实测会把 hit@1 从 88% 拉到 81%
if alpha is None:
alpha = default_alpha()
# 先用倒排把候选收窄。以前这里是把整个集合载入内存、再在 Python 里重建
# 一遍 BM25 倒排——每次查询都重新分词一遍所有片段。
#
# 多捞一些(limit 的 8 倍):倒排只按关键词收,最终名次还要混进向量那一路,
# 卡得太紧会把"关键词一般但语义很近"的那些提前筛掉。
rows: list[Chunk] = []
kw_scores: list[float] | None = None
picked = await inverted.candidates(
session, collection=collection, query=query, limit=max(limit * 8, 50))
if picked:
found = dict(
(c.id, c) for c in (await session.execute(
select(Chunk).where(Chunk.id.in_([cid for cid, _ in picked])))).scalars())
pairs = [(found[cid], sc) for cid, sc in picked if cid in found]
rows = [c for c, _ in pairs]
# 倒排算好的分要带下去。在候选子集上重算 BM25,df 和平均长度是另一套数
kw_scores = [sc for _, sc in pairs]
if not rows:
# 一个查询词都没命中,或者这批片段还没建倒排(存量数据升级上来就是这样)。
# 退回全表扫,慢但不会漏——宁可慢,也不要因为索引没建好就说"没搜到"
stmt = select(Chunk)
if collection:
stmt = stmt.where(Chunk.collection == collection)
rows = list((await session.execute(stmt)).scalars())
kw_scores = None # 全表扫这条路照旧自己算
if not rows:
return []
# 按 id 定序再排。hybrid_rank 用的是稳定排序,分数并列时保留输入顺序——
# 而倒排那条路和全表扫那条路喂进去的顺序天生不同,同样的查询会因为走了
# 哪条路给出不同的名次。并列本来就该有个确定的先后,用 id 就够了
if kw_scores is not None:
paired = sorted(zip(rows, kw_scores), key=lambda t: t[0].id)
rows = [r for r, _ in paired]
kw_scores = [s2 for _, s2 in paired]
else:
rows = sorted(rows, key=lambda r: r.id)
stale = [r for r in rows if not usable(r.embed_model, r.embed_dim)]
if stale:
if on_degrade:
names = sorted({r.embed_model or "未知模型" for r in stale})
# 报的是整个集合里要重建多少条,不是本次候选里有几条——候选被倒排
# 收窄过,用它会把问题说小,而用户要照着做的恰恰是"重建多少"
total_stale = await stale_count(session, collection)
on_degrade(
f"{collection or '全部集合'}里有 {total_stale} 条向量由"
f"「{'、'.join(names)}」建立,与当前的「{embedder_id()}」不一致,"
f"本次已退回纯关键词检索。重建索引后恢复。"
)
alpha = 0.0 # 纯关键词
vectors: list[Any] = [None] * len(rows)
query_vec = await embed_text(query)
else:
query_vec = await embed_text(query)
vectors = [from_blob(r.embedding, embedder_dim()) for r in rows]
ranked = hybrid_contrib(
query,
[r.content for r in rows],
vectors,
query_vec,
alpha=alpha,
keyword_scores=kw_scores,
)
out: list[dict[str, Any]] = []
for hit in ranked[:limit]:
row = rows[hit.index]
score = round(hit.score, 4)
# 关键词那份用减法得出:各自四舍五入之后再相加,会和总分差出一个末位
by_vec = min(round(hit.contrib["vector"], 4), score)
out.append(
{
"chunk_id": row.id,
"document_id": row.document_id,
"title": (row.meta or {}).get("title", ""),
"ordinal": row.ordinal,
"content": row.content,
"score": score,
"signals": {k: round(v, 4) for k, v in hit.signals.items()},
# 两路各贡献了多少分,两者之和等于 score。调试台据此画贡献条
"contrib": {"vector": by_vec, "keyword": round(score - by_vec, 4)},
}
)
return out
async def reindex(
session: AsyncSession, collection: str | None = None, *, on_progress: Any = None,
) -> dict[str, Any]:
"""用当前 embedder 重算向量。
换了 embedding 模型之后唯一的恢复手段。按批做:一次几千条 chunk 全塞给
远端 embedding 接口,要么超时要么被限流,而中途失败又没有断点。
on_progress(已完成, 总数) 每批提交之后回调一次。
"""
stmt = select(Chunk)
if collection:
stmt = stmt.where(Chunk.collection == collection)
rows = list((await session.execute(stmt)).scalars())
if not rows:
return {"collection": collection, "reindexed": 0, "embedder": embedder_id()}
model_id, dim = embedder_id(), embedder_dim()
batch = _EMBED_BATCH
for i in range(0, len(rows), batch):
part = rows[i:i + batch]
vectors = await embed_texts([r.content for r in part])
for row, vec in zip(part, vectors):
row.embedding = to_blob(vec)
row.embed_model = model_id
row.embed_dim = dim
await session.commit()
if on_progress:
on_progress(min(i + batch, len(rows)), len(rows))
# 倒排一起重建:两者都是"存量数据升级"的恢复手段,分成两个按钮只会漏点一个
await inverted.rebuild(session, collection)
return {"collection": collection, "reindexed": len(rows), "embedder": model_id}
async def stale_count(session: AsyncSession, collection: str | None = None) -> int:
"""有多少条向量和当前 embedder 对不上。知识库页据此提示要不要重建。
用 SQL 数而不是把片段全载进来再在 Python 里过一遍——这个数在检索降级时
也要取,那条路径上不该再来一次全表扫。
"""
from sqlalchemy import func, or_
stmt = select(func.count(Chunk.id)).where(
or_(Chunk.embed_model != embedder_id(), Chunk.embed_dim != embedder_dim()))
if collection:
stmt = stmt.where(Chunk.collection == collection)
return int((await session.execute(stmt)).scalar_one() or 0)
async def delete_document(session: AsyncSession, doc_id: str) -> bool:
doc = await session.get(Document, doc_id)
if not doc:
return False
chunk_ids = list((await session.execute(
select(Chunk.id).where(Chunk.document_id == doc_id))).scalars())
await inverted.drop_chunk_terms(session, chunk_ids)
await session.delete(doc)
await session.commit()
return True
async def list_collections(session: AsyncSession) -> list[dict[str, Any]]:
rows = await session.execute(
select(
Document.collection,
func.count(Document.id),
func.sum(Document.chunk_count),
).group_by(Document.collection)
)
# sum 在 PostgreSQL 上给的是 numeric(驱动交回 Decimal),SQLite 给整数:一律转成 int
return [
{"collection": name, "documents": int(docs), "chunks": int(chunks or 0)}
for name, docs, chunks in rows
]