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)} />