From 901d840b86e5d6746d10394391452b6d5bafe8bd Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 19 Sep 2026 22:58:01 +0800 Subject: [PATCH] =?UTF-8?q?Word=20/=20PDF=20=E7=9C=9F=E7=9A=84=E4=BC=A0?= =?UTF-8?q?=E5=BE=97=E8=BF=9B=E6=9D=A5=E4=BA=86=EF=BC=8C=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E5=88=87=E5=BC=80=E4=B9=9F=E8=BF=98=E8=AF=BB=E5=BE=97=E6=87=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮做了解析,但那条路在产品上够不着: - environment.yml 装的是 [microvm,dev,db],没有 docs——新机器起来,传 PDF 只会得到一句"要先装解析库" - 文件选择框的 accept 里压根没有 .pdf / .docx,点上传根本选不中这些文件 两处都补上。表格类(xlsx)故意不放进 accept:它进知识库会被切成文本, 聚合算不了、数字也无法回指,那条路该走数据源。 装上依赖实测各格式:Word(含表格)、PDF(含中文,扫描件会明说要先 OCR)、 HTML、txt/md/csv/json 都通;Excel 和 PPT 不认得,报错说得清楚。 **切块对表格的处理是这次的主要工作。** 一份 60 行的 Word 表格切出来长这样: 块1 首行 | 上海 | 172151 | 13% 门店名被切没了,也没有表头——召回到这块,模型看到的是一列没有主语的数字。 两个毛病: 1. 重叠按字符切(buf[-120:]),会从行中间断开。改成只在行边界取, 整段没有换行就宁可不要重叠 2. 表格横跨多块时,除第一块外都没有列名。改成开新块时把表头补在最前面 第二条我第一版写成了 tail+header+block,表头被夹在重叠的那几行后面成了中间 一行——"表头在不在"查出来是 True,看着修好了,块的开头其实仍然是一串没有 主语的数。测试里专门有一条盯着表头必须领头,就是为这个。 端到端验过:60 行表格的 Word 传上去切成 3 段,搜"门店销售额"召回的片段 第一行就是"门店 | 城市 | 销售额 | 同比"。 --- backend/app/memory/kb.py | 57 ++++++++++++++--- backend/tests/test_kb.py | 92 ++++++++++++++++++++++++++++ environment.yml | 4 +- frontend/src/pages/KnowledgePage.tsx | 6 +- 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/backend/app/memory/kb.py b/backend/app/memory/kb.py index a24f0db5..4f3ed72b 100644 --- a/backend/app/memory/kb.py +++ b/backend/app/memory/kb.py @@ -50,25 +50,66 @@ def chunk_text(text: str, target: int = _TARGET, overlap: int = _OVERLAP) -> lis 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: - if buf: - merged.append(buf) - tail = buf[-overlap:] if overlap else "" - buf = f"{tail}\n{block}" if tail else block - else: - merged.append(block) - buf = "" + 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, *, diff --git a/backend/tests/test_kb.py b/backend/tests/test_kb.py index 7b34e37f..54961ee4 100644 --- a/backend/tests/test_kb.py +++ b/backend/tests/test_kb.py @@ -351,3 +351,95 @@ async def test_one_rebuild_covers_both_stores() -> None: after = (await c.get("/api/kb/embedding")).json() assert after["stale_chunks"] == 0 and after["stale_memories"] == 0 + + +# -------------------------------------------------------------------------- +# 表格横跨多块时还读不读得懂 +# -------------------------------------------------------------------------- + + +def _table_doc(rows: int = 60) -> str: + """一份表格长到必然被切开的文档。""" + body = ["门店 | 城市 | 销售额 | 同比"] + body += [f"门店{i:02d} | 上海 | {100000 + i * 3137} | {i - 10}%" for i in range(rows)] + return "季度报告\n\n" + "\n\n".join(body) + + +def test_a_split_table_keeps_its_header_in_every_chunk() -> None: + """一张几十行的表会横跨好几块,除了第一块其余都只有数据行。 + + 「上海 | 172151 | 13%」这样的片段召回出来,模型没法知道这几个数是什么。 + 表头才几十个字,每块带一份换来的是这块单独拿出来也读得懂。 + """ + chunks = kb.chunk_text(_table_doc()) + assert len(chunks) > 1, "语料不够长,没切开就测不到这件事" + for i, c in enumerate(chunks[1:], 1): + assert "门店 | 城市 | 销售额 | 同比" in c, f"块{i} 丢了表头" + + +def test_the_header_leads_the_chunk_not_buried_in_it() -> None: + """表头要在最前面。 + + 第一版写成 tail+header+block,表头被夹在重叠的那几行后面成了中间一行, + 块的开头仍然是一串没有主语的数——查 has_header 是 True,看起来修好了, + 实际没有。 + """ + chunks = kb.chunk_text(_table_doc()) + for i, c in enumerate(chunks[1:], 1): + assert c.splitlines()[0].startswith("门店 | 城市"), ( + f"块{i} 的第一行是「{c.splitlines()[0][:30]}」,表头没领头") + + +def test_overlap_never_severs_a_row() -> None: + """重叠原来按字符切(buf[-120:]),会从行中间断开。 + + 实测切出来的块开头是「| 上海 | 172151 | 13%」——门店名被切没了, + 剩一列没有主语的数字。 + """ + for c in kb.chunk_text(_table_doc()): + first = c.splitlines()[0].lstrip() + assert not first.startswith("|"), f"首行被腰斩:{first[:40]}" + + +def test_plain_prose_is_unaffected_by_the_table_handling() -> None: + """表格那套不能把普通文档搞坏。""" + text = "\n\n".join(f"第{i}段。" + "内容" * 60 for i in range(8)) + chunks = kb.chunk_text(text) + assert len(chunks) > 1 + assert not any(c.startswith("门店") for c in chunks) + # 正文一个字都不能少 + assert all(f"第{i}段。" in "".join(chunks) for i in range(8)) + + +async def test_a_word_document_with_a_table_survives_the_whole_pipeline() -> None: + """解析 → 切块 → 检索,端到端拿回来的片段要能读懂。""" + docx = pytest.importorskip("docx") + + import io + + from app.memory.parsing import extract + + d = docx.Document() + d.add_heading("门店运营手册", 0) + d.add_paragraph("平台管理员拥有 platform 级别角色。") + t = d.add_table(rows=1, cols=4) + for i, h in enumerate(("门店", "城市", "销售额", "同比")): + t.cell(0, i).text = h + for i in range(60): + row = t.add_row() + for j, v in enumerate((f"门店{i:02d}", "上海", str(100000 + i * 3137), f"{i - 10}%")): + row.cells[j].text = v + buf = io.BytesIO() + d.save(buf) + + text = extract(buf.getvalue(), "手册.docx") + assert "平台管理员" in text and "门店00 | 上海" in text + + async with SessionLocal() as session: + await kb.ingest_document(session, collection="docx_e2e", title="手册", content=text) + hits = await kb.search(session, collection="docx_e2e", query="门店 销售额", limit=3) + + assert hits + # 命中的片段里必须能看出这些数是什么 + assert any("门店 | 城市 | 销售额" in h["content"] for h in hits), ( + "召回的片段全都没有表头,拿回去也读不懂") diff --git a/environment.yml b/environment.yml index 80608c6a..1b0d7198 100644 --- a/environment.yml +++ b/environment.yml @@ -15,4 +15,6 @@ dependencies: # [microvm] 带上独立内核的沙箱后端(约 35MB wheel,首次运行还要下载 # 约 50MB 运行时,外加一次 OCI 镜像拉取)。不需要就把方括号去掉, # 沙箱会退回 Seatbelt —— 代价是内存限额在 macOS 上限不住。 - - -e ./backend[microvm,dev,db] + # [docs] 让 PDF / Word 传得进知识库。约十几兆,但少了它上传这类文件 + # 只会得到一句"要先装解析库"——而界面上并不会拦着你去传 + - -e ./backend[microvm,dev,db,docs] diff --git a/frontend/src/pages/KnowledgePage.tsx b/frontend/src/pages/KnowledgePage.tsx index d071e1d2..71dd742a 100644 --- a/frontend/src/pages/KnowledgePage.tsx +++ b/frontend/src/pages/KnowledgePage.tsx @@ -185,7 +185,11 @@ function KbTab() { - upload(e.target.files)} />