From 2552df6d75145268de55867fdc1a4f3c6800139c Mon Sep 17 00:00:00 2001 From: suhan <72542107+suhan42@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:14 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(qa):=20=E4=BF=AE=E5=A4=8D=20Markdown=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=BC=8F=E9=97=AE=E7=AD=94=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E7=BC=BA=E9=99=B7=E5=B9=B6=E8=A1=A5=E5=BD=95?= =?UTF-8?q?=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA 分块解析器原本不识别 # Q: / ## 问题: 这类带前缀标题,导致此类文档无法被正确切分问答对;同时缺乏超长 chunk 切分,单条问答可能超过 embedding 上下文上限。 --- .../chunking/ragflow_like/parsers/qa.py | 200 ++++++++++++++++-- backend/package/yuxi/models/embed.py | 15 ++ docs/develop-guides/changelog.md | 2 + 3 files changed, 198 insertions(+), 19 deletions(-) diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py index bf10ab47c..7115e86ec 100644 --- a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py @@ -122,6 +122,7 @@ def _md_question_level(line: str) -> tuple[int, str]: def _extract_pairs_from_markdown_headings(markdown_content: str) -> list[tuple[str, str]]: + """标题提取:按 Markdown 标题层级提取问答对,标题下的内容作为答案,标题本身作为问题。""" lines = (markdown_content or "").splitlines() if not lines: return [] @@ -166,28 +167,59 @@ def _extract_pairs_from_markdown_headings(markdown_content: str) -> list[tuple[s return pairs -def _extract_pairs_by_prefix(lines: list[str]) -> list[tuple[str, str]]: +def _extract_pairs_by_prefix(markdown_content: str) -> list[tuple[str, str]]: + """前缀提取:按行首 Q/A 前缀提取问答对,标题行剥掉 # 后按前缀判断,非问答前缀标题仅作分节符结束当前问答对。""" pairs: list[tuple[str, str]] = [] question = "" answer_lines: list[str] = [] - for line in lines: - if re.match(r"^(Q|Question|问|问题)\s*[::]", line, flags=re.IGNORECASE): - if question: - pairs.append((question, "\n".join(answer_lines))) - question = re.sub(r"^(Q|Question|问|问题)\s*[::]", "", line, flags=re.IGNORECASE).strip() + heading_re = re.compile(r"^#{1,6}\s*") + question_re = re.compile(r"^(?:Q|Question|问|问题)\s*[::]\s*(.*)$", flags=re.IGNORECASE) + answer_re = re.compile(r"^(?:A|Answer|答|回答)\s*[::]\s*(.*)$", flags=re.IGNORECASE) + code_block = False + + def flush_pair() -> None: + nonlocal question, answer_lines + if question: + pairs.append((question, "\n".join(answer_lines))) + question = "" answer_lines = [] + + for line in (markdown_content or "").splitlines(): + # 代码块内容(含围栏行)只可能是答案正文,不参与问答边界判断 + if line.strip().startswith("```"): + code_block = not code_block + if question: + answer_lines.append(line) + continue + if code_block: + if question: + answer_lines.append(line) continue - if re.match(r"^(A|Answer|答|回答)\s*[::]", line, flags=re.IGNORECASE): - answer_lines.append(re.sub(r"^(A|Answer|答|回答)\s*[::]", "", line, flags=re.IGNORECASE).strip()) + heading_match = heading_re.match(line) + text = line[heading_match.end() :] if heading_match else line + + q_match = question_re.match(text) + if q_match: + flush_pair() + question = q_match.group(1).strip() + continue + + a_match = answer_re.match(text) + if a_match: + answer_lines.append(a_match.group(1).strip()) + continue + + if heading_match: + # 非问答前缀的标题是结构性分节,结束当前问答对且标题本身不进入答案,避免附录等内容污染上一对问答 + flush_pair() continue if question: answer_lines.append(line) - if question: - pairs.append((question, "\n".join(answer_lines))) + flush_pair() return [(q.strip(), a.strip()) for q, a in pairs if q.strip() and a.strip()] @@ -210,7 +242,127 @@ def _dedupe_pairs(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]: return res +# QA chunk 字符数硬上限:bge_m3 等模型限制 4096 tokens,中/英/数字混合内容按保守字符数兜底。 +_QA_CHUNK_MAX_CHARS = 4000 +_QA_QUESTION_PREFIXES = ("问题:", "Question: ") +_QA_ANSWER_PREFIXES = ("回答:", "Answer: ") + + +def _split_qa_prefix(text: str, prefixes: tuple[str, ...]) -> tuple[str, str]: + """从 QA chunk 的半段文本中拆分出前缀和正文。""" + for prefix in prefixes: + if text.startswith(prefix): + return prefix, text[len(prefix) :].strip() + return "", text.strip() + + +def _hard_split_text(text: str, max_chars: int) -> list[str]: + """按固定字符数硬切,过滤空白片段。""" + return [text[i : i + max_chars] for i in range(0, len(text), max_chars) if text[i : i + max_chars].strip()] + + +def _split_answer_by_paragraphs(answer: str, max_chars: int) -> list[str]: + """优先按段落切分答案,单段落仍超长则按行、再超长则硬切。""" + paragraphs = [p.strip() for p in answer.split("\n\n") if p.strip()] + if not paragraphs: + return [answer] if answer.strip() else [] + + result: list[str] = [] + current = "" + for p in paragraphs: + if len(p) > max_chars: + if current: + result.append(current) + current = "" + result.extend(_split_answer_by_lines(p, max_chars)) + continue + if current and len(current) + 2 + len(p) > max_chars: + result.append(current) + current = p + else: + current = f"{current}\n\n{p}" if current else p + if current: + result.append(current) + return result + + +def _split_answer_by_lines(answer: str, max_chars: int) -> list[str]: + """按行切分答案,单行仍超长则硬切。""" + lines = [line.strip() for line in answer.splitlines() if line.strip()] + if not lines: + return [answer] if answer.strip() else [] + + result: list[str] = [] + current = "" + for line in lines: + if len(line) > max_chars: + if current: + result.append(current) + current = "" + result.extend(_hard_split_text(line, max_chars)) + continue + if current and len(current) + 1 + len(line) > max_chars: + result.append(current) + current = line + else: + current = f"{current}\n{line}" if current else line + if current: + result.append(current) + return result + + +def _split_long_qa_chunks(chunks: list[str], max_chars: int = _QA_CHUNK_MAX_CHARS) -> list[str]: + """对超长 QA chunk 保留问题、切分答案,避免单条超过 embedding 上下文上限。""" + if max_chars <= 0: + return [c.strip() for c in chunks if c and c.strip()] + + result: list[str] = [] + for chunk in chunks: + text = (chunk or "").strip() + if not text: + continue + if len(text) <= max_chars: + result.append(text) + continue + + parts = text.split("\t", 1) + if len(parts) != 2: + # 非标准 QA 格式,直接硬切兜底 + result.extend(_hard_split_text(text, max_chars)) + continue + + q_part, a_part = parts + q_prefix, q_body = _split_qa_prefix(q_part, _QA_QUESTION_PREFIXES) + a_prefix, a_body = _split_qa_prefix(a_part, _QA_ANSWER_PREFIXES) + if not q_body or not a_body: + result.extend(_hard_split_text(text, max_chars)) + continue + + if len(q_prefix) + len(q_body) + len(a_prefix) + 1 >= max_chars: + # 问题本身已超限,保留问题切答案只会产出 1 字符答案的碎片且仍超限,改为整条硬切 + result.extend(_hard_split_text(text, max_chars)) + continue + + # 预留问题部分 + 前缀 + 制表符占位 + max_answer_chars = max_chars - len(q_prefix) - len(q_body) - len(a_prefix) - 1 + for sub_answer in _split_answer_by_paragraphs(a_body, max_answer_chars): + result.append(f"{q_prefix}{q_body}\t{a_prefix}{sub_answer}") + + return result + + def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str, Any] | None = None) -> list[str]: + """QA 分块策略:按文件后缀选择下列提取器的子集,去重后统一渲染为 问题:xxx\\t回答:yyy 文本。 + + 提取器全集(按优先级编号;各后缀只走其中子集,见分支行内注释): + 1. 行首 Q/A 前缀:按行首 Q/A 前缀切问答边界,标题行先剥 # 再匹配;`# Q:`/`# 问题:` 这类带前缀的标题被识别为问题,纯 `# 标题`(无 Q/问题 前缀)仅作分节符结束当前问答对、不进答案也不作问题。 + 2. Markdown 标题:标题作问题、标题下内容作答案;仅当 1. 整轮未命中时兜底(用于纯 `# 标题` 风格的 FAQ 文档)。 + 3. Markdown 表格:按 | 分隔两列表格作 Q/A 对;md/markdown/mdx/docx/csv/无后缀与 1./2. 叠加,xlsx 在 1./2. 落空后才尝试,txt 不走表格。 + 4. 分隔符:按 tab/comma 切两列;csv 固定执行,xlsx/无后缀在前面落空时兜底,txt 中作为最高优先级先于 1.。 + 5. 1-4 全部落空时,按每两行一组兜底构成问答对。 + 6. 超长 chunk 保留问题、按段落/行逐级切分答案,避免单条超过 embedding 上下文上限。 + 7. 问题本身超限等无法保留结构时,对超长 chunk 按字符数硬切并过滤空白片段,保证单条不超上限。 + """ parser_config = parser_config or {} eng = str(parser_config.get("language", "Chinese")).lower() == "english" @@ -221,28 +373,36 @@ def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str lines = [line for line in (markdown_content or "").splitlines() if line.strip()] pairs: list[tuple[str, str]] = [] + # 各分支的提取器组合按后缀分发,编号对应 docstring 中的策略步骤 if suffix in {".xlsx", ".xls"}: + # 3. 表格提取,无命中退到 4. 分隔符提取 pairs.extend(_extract_pairs_from_markdown_tables(markdown_content)) if not pairs: delimiter = _guess_delimiter(lines) pairs.extend(_extract_pairs_with_delimiter(lines, delimiter)) elif suffix == ".csv": + # 3. 表格提取与 4. 分隔符(csv 解析)固定执行 pairs.extend(_extract_pairs_from_markdown_tables(markdown_content)) delimiter = "\t" if any("\t" in line for line in lines) else "," pairs.extend(_extract_pairs_from_csv(lines, delimiter)) elif suffix == ".txt": + # 4. 分隔符优先(兼容 Q: 问题\tA: 答案 整行格式),无命中退到 1. 行首前缀 delimiter = _guess_delimiter(lines) pairs.extend(_extract_pairs_with_delimiter(lines, delimiter)) - elif suffix in {".md", ".markdown", ".mdx"}: - pairs.extend(_extract_pairs_from_markdown_headings(markdown_content)) - pairs.extend(_extract_pairs_from_markdown_tables(markdown_content)) - elif suffix == ".docx": - pairs.extend(_extract_pairs_from_markdown_headings(markdown_content)) + if not pairs: + pairs.extend(_extract_pairs_by_prefix(markdown_content)) + elif suffix in {".md", ".markdown", ".mdx", ".docx"}: + # 1. 行首前缀优先;2. 前缀未命中时标题提取兜底;3. 表格提取叠加 + pairs.extend(_extract_pairs_by_prefix(markdown_content)) + if not pairs: + pairs.extend(_extract_pairs_from_markdown_headings(markdown_content)) pairs.extend(_extract_pairs_from_markdown_tables(markdown_content)) else: - pairs.extend(_extract_pairs_from_markdown_headings(markdown_content)) + # 1. 行首前缀 →(空)2. 标题提取兜底;3. 表格叠加;仍为空退到 4. 分隔符 + pairs.extend(_extract_pairs_by_prefix(markdown_content)) + if not pairs: + pairs.extend(_extract_pairs_from_markdown_headings(markdown_content)) pairs.extend(_extract_pairs_from_markdown_tables(markdown_content)) - pairs.extend(_extract_pairs_by_prefix(lines)) if not pairs: delimiter = _guess_delimiter(lines) pairs.extend(_extract_pairs_with_delimiter(lines, delimiter)) @@ -250,11 +410,13 @@ def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str pairs = _dedupe_pairs(pairs) if not pairs and lines: - # 最后兜底:把内容按 2 行一组构成问答 + # 5. 最后兜底:把内容按 2 行一组构成问答 for i in range(0, len(lines), 2): q = lines[i] a = lines[i + 1] if i + 1 < len(lines) else "" if q.strip() and a.strip(): pairs.append((q, a)) - return [_to_qa_chunk(q, a, eng=eng) for q, a in pairs] + chunks = [_to_qa_chunk(q, a, eng=eng) for q, a in pairs] + # 6/7. 超长 chunk 限长:保留问题切答案,问题本身超限时整条硬切 + return _split_long_qa_chunks(chunks) diff --git a/backend/package/yuxi/models/embed.py b/backend/package/yuxi/models/embed.py index e3961bba1..50670970d 100644 --- a/backend/package/yuxi/models/embed.py +++ b/backend/package/yuxi/models/embed.py @@ -175,9 +175,23 @@ def _extract_embeddings(result: dict) -> list[list[float]]: raise ValueError(f"Embedding failed: Invalid response format {result}") return [item["embedding"] for item in result["data"]] + @staticmethod + def _log_long_inputs(message: list[str] | str, threshold: int = 4000) -> None: + """调试辅助:打印超过字符阈值的 embedding 输入内容。""" + messages = [message] if isinstance(message, str) else message + for idx, text in enumerate(messages): + if text and len(text) > threshold: + logger.warning( + f"超长 embedding 输入 index={idx}, len={len(text)}, " + f"content_head={text[:200]!r}, content_tail={text[-200:]!r}" + ) + def encode(self, message: list[str] | str) -> list[list[float]]: payload = self.build_payload(message) retry_index = 0 + + self._log_long_inputs(message) + while True: try: response = requests.post(self.base_url, json=payload, headers=self.headers, timeout=60) @@ -200,6 +214,7 @@ def encode(self, message: list[str] | str) -> list[list[float]]: async def aencode(self, message: list[str] | str) -> list[list[float]]: payload = self.build_payload(message) + self._log_long_inputs(message) async with httpx.AsyncClient() as client: retry_index = 0 while True: diff --git a/docs/develop-guides/changelog.md b/docs/develop-guides/changelog.md index 0f3c84279..f01d7060b 100644 --- a/docs/develop-guides/changelog.md +++ b/docs/develop-guides/changelog.md @@ -112,6 +112,8 @@ v0.7.2.beta1 包含不可逆的数据与文件布局迁移,主要影响历史 - 侧边栏对话列表新增 Thread 运行状态:以 `AgentRun` 为事实来源,后端把每个线程最新顶层 chat/resume Run 聚合成 `thread_status`(进行中显示 loading、已终态未查看显示 ready 点、已查看或无 Run 为 done),列表接口一次窗口查询完成聚合、不逐项请求;`Conversation` 新增 `last_viewed_run_id` 持久化查看边界,`POST /api/chat/thread/{id}/viewed` 幂等标记已读。前端打开线程、当前线程收到终态/中断事件时自动标记已读,发送或恢复 Run 时置为 loading,侧边栏可见时低频轮询刷新后台线程状态;历史线程上线时按各自最新顶层 Run 一次性回填为已读,新建线程写入未读哨兵避免回填误清新产生的未读点。 - 修复 Thread 运行状态两个正确性问题:终态/中断事件仅在事件线程等于当前打开线程时才自动标记已读,后台线程完成保留 ready 点直至用户打开;无 chat/resume Run 的历史会话(agent_call / agent_evaluation 调用、从未对话过的线程)在回填时写入未读哨兵,使回填探测条件收敛为 false,避免每次启动都重复对 `agent_runs` 做全表聚合。 - 优化侧边栏对话列表操作渐隐:`.actions-mask` 三态(默认/悬浮/激活)渐隐统一为线性延伸至操作按钮左边缘(距右缘 28px)再转为实色,修复悬浮时渐隐铺满整条遮罩导致按钮下方文字残留鬼影、以及三处渐隐宽度不一致的问题。 +- QA 分块重写 `_extract_pairs_by_prefix`:接收完整 markdown 文本并识别代码块围栏;标题行先剥 `#` 再按 Q/A 前缀判断,`# Q:`、`## 问题:` 与裸 `Q:` 走同一前缀路径,纯 `#` 标题仅作分节符、不进答案。`.md/.markdown/.mdx/.docx` 合并分支:前缀提取优先 → 标题提取兜底 → 表格叠加;`.txt` 改为分隔符优先 + 前缀兜底。新增超长 chunk 切分(>4000 字符):保留问题按段落/行逐级切分答案,问题本身超限时整条硬切。 +- embed.py 在 `encode`/`aencode` 请求前以 `logger.warning` 打印超过 4000 字符输入的 index、长度和首尾 200 字符预览,便于定位具体哪条 chunk 超长触发 embedding 失败。 ## v0.7.1 (2026-07-17) From 378792dcb290ef2b7169e3a4716e3ef10eaa47b7 Mon Sep 17 00:00:00 2001 From: suhan <72542107+suhan42@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:33 +0800 Subject: [PATCH 2/5] =?UTF-8?q?style(qa):=20=E4=BF=AE=E5=A4=8D=20chunk=5Fm?= =?UTF-8?q?arkdown=20docstring=20=E8=B6=85=E9=95=BF=E8=A1=8C=EF=BC=88E501?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/knowledge/chunking/ragflow_like/parsers/qa.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py index 7115e86ec..cc8a225a6 100644 --- a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py @@ -355,9 +355,11 @@ def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str """QA 分块策略:按文件后缀选择下列提取器的子集,去重后统一渲染为 问题:xxx\\t回答:yyy 文本。 提取器全集(按优先级编号;各后缀只走其中子集,见分支行内注释): - 1. 行首 Q/A 前缀:按行首 Q/A 前缀切问答边界,标题行先剥 # 再匹配;`# Q:`/`# 问题:` 这类带前缀的标题被识别为问题,纯 `# 标题`(无 Q/问题 前缀)仅作分节符结束当前问答对、不进答案也不作问题。 + 1. 行首 Q/A 前缀:按行首 Q/A 前缀切问答边界,标题行先剥 # 再匹配;`# Q:`/`# 问题:` 这类带前缀的标题被识别为问题, + 纯 `# 标题`(无 Q/问题 前缀)仅作分节符结束当前问答对、不进答案也不作问题。 2. Markdown 标题:标题作问题、标题下内容作答案;仅当 1. 整轮未命中时兜底(用于纯 `# 标题` 风格的 FAQ 文档)。 - 3. Markdown 表格:按 | 分隔两列表格作 Q/A 对;md/markdown/mdx/docx/csv/无后缀与 1./2. 叠加,xlsx 在 1./2. 落空后才尝试,txt 不走表格。 + 3. Markdown 表格:按 | 分隔两列表格作 Q/A 对;md/markdown/mdx/docx/csv/无后缀与 1./2. 叠加, + xlsx 在 1./2. 落空后才尝试,txt 不走表格。 4. 分隔符:按 tab/comma 切两列;csv 固定执行,xlsx/无后缀在前面落空时兜底,txt 中作为最高优先级先于 1.。 5. 1-4 全部落空时,按每两行一组兜底构成问答对。 6. 超长 chunk 保留问题、按段落/行逐级切分答案,避免单条超过 embedding 上下文上限。 From 814b070bf840f9c54890f2700fcd7b92e989267c Mon Sep 17 00:00:00 2001 From: suhan <72542107+suhan42@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:21:46 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(qa):=20=E4=BF=AE=E5=A4=8D=20QA=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=BE=B9=E7=95=8C=E4=B8=8E=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E5=88=87=E5=88=86=E7=BC=BA=E9=99=B7=E5=90=8E=E7=BB=AD=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chunking/ragflow_like/parsers/qa.py | 55 ++++--- backend/package/yuxi/models/embed.py | 5 +- .../test/unit/test_qa_chunk_length_limit.py | 136 ++++++++++++++++++ backend/test/unit/test_qa_prefix_parsing.py | 99 +++++++++++++ docs/develop-guides/changelog.md | 4 +- .../2026-08-27-qa-chunk-length-limit.md | 51 +++++++ 6 files changed, 327 insertions(+), 23 deletions(-) create mode 100644 backend/test/unit/test_qa_chunk_length_limit.py create mode 100644 backend/test/unit/test_qa_prefix_parsing.py create mode 100644 docs/develop-guides/decisions/implemented/2026-08-27-qa-chunk-length-limit.md diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py index cc8a225a6..e059d9c74 100644 --- a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py @@ -121,6 +121,18 @@ def _md_question_level(line: str) -> tuple[int, str]: return len(match.group(0)), line.lstrip("#").lstrip() +def _update_fence_state(line: str, fence: str) -> str: + """按行更新代码围栏状态:``` 与 ~~~ 各自成对开关,异类围栏行不关闭当前块。""" + stripped = line.strip() + if stripped.startswith("```") or stripped.startswith("~~~"): + marker = stripped[:3] + if not fence: + return marker + if marker == fence: + return "" + return fence + + def _extract_pairs_from_markdown_headings(markdown_content: str) -> list[tuple[str, str]]: """标题提取:按 Markdown 标题层级提取问答对,标题下的内容作为答案,标题本身作为问题。""" lines = (markdown_content or "").splitlines() @@ -131,15 +143,14 @@ def _extract_pairs_from_markdown_headings(markdown_content: str) -> list[tuple[s last_answer = "" question_stack: list[str] = [] level_stack: list[int] = [] - code_block = False + fence = "" for line in lines: - if line.strip().startswith("```"): - code_block = not code_block + fence = _update_fence_state(line, fence) question_level = 0 question = "" - if not code_block: + if not fence: question_level, question = _md_question_level(line) if not question_level or question_level > 6: @@ -176,7 +187,7 @@ def _extract_pairs_by_prefix(markdown_content: str) -> list[tuple[str, str]]: heading_re = re.compile(r"^#{1,6}\s*") question_re = re.compile(r"^(?:Q|Question|问|问题)\s*[::]\s*(.*)$", flags=re.IGNORECASE) answer_re = re.compile(r"^(?:A|Answer|答|回答)\s*[::]\s*(.*)$", flags=re.IGNORECASE) - code_block = False + fence = "" def flush_pair() -> None: nonlocal question, answer_lines @@ -186,13 +197,11 @@ def flush_pair() -> None: answer_lines = [] for line in (markdown_content or "").splitlines(): - # 代码块内容(含围栏行)只可能是答案正文,不参与问答边界判断 - if line.strip().startswith("```"): - code_block = not code_block - if question: - answer_lines.append(line) - continue - if code_block: + stripped = line.strip() + is_fence_line = stripped.startswith("```") or stripped.startswith("~~~") + fence = _update_fence_state(line, fence) + # 围栏行与代码块内容行只可能是答案正文,不参与问答边界判断 + if is_fence_line or fence: if question: answer_lines.append(line) continue @@ -208,7 +217,10 @@ def flush_pair() -> None: a_match = answer_re.match(text) if a_match: - answer_lines.append(a_match.group(1).strip()) + # 答案必须归属活跃问题:问题尚未出现时的前言 A: 行直接忽略, + # 避免孤儿文本被拼进后续真实问答对 + if question: + answer_lines.append(a_match.group(1).strip()) continue if heading_match: @@ -288,7 +300,8 @@ def _split_answer_by_paragraphs(answer: str, max_chars: int) -> list[str]: def _split_answer_by_lines(answer: str, max_chars: int) -> list[str]: """按行切分答案,单行仍超长则硬切。""" - lines = [line.strip() for line in answer.splitlines() if line.strip()] + # 仅 strip 用于判空,保留原始行:缩进对围栏代码块与嵌套列表有语义 + lines = [line for line in answer.splitlines() if line.strip()] if not lines: return [answer] if answer.strip() else [] @@ -325,13 +338,18 @@ def _split_long_qa_chunks(chunks: list[str], max_chars: int = _QA_CHUNK_MAX_CHAR result.append(text) continue - parts = text.split("\t", 1) - if len(parts) != 2: + # 结构分隔符是紧邻已知答案前缀的 tab:问题本身可能含 tab,按首个任意 tab 切会截断问题 + sep_pos = -1 + for marker in ("\t回答:", "\tAnswer: "): + sep_pos = text.find(marker) + if sep_pos != -1: + break + if sep_pos == -1: # 非标准 QA 格式,直接硬切兜底 result.extend(_hard_split_text(text, max_chars)) continue - q_part, a_part = parts + q_part, a_part = text[:sep_pos], text[sep_pos + 1 :] q_prefix, q_body = _split_qa_prefix(q_part, _QA_QUESTION_PREFIXES) a_prefix, a_body = _split_qa_prefix(a_part, _QA_ANSWER_PREFIXES) if not q_body or not a_body: @@ -356,7 +374,8 @@ def chunk_markdown(filename: str, markdown_content: str, parser_config: dict[str 提取器全集(按优先级编号;各后缀只走其中子集,见分支行内注释): 1. 行首 Q/A 前缀:按行首 Q/A 前缀切问答边界,标题行先剥 # 再匹配;`# Q:`/`# 问题:` 这类带前缀的标题被识别为问题, - 纯 `# 标题`(无 Q/问题 前缀)仅作分节符结束当前问答对、不进答案也不作问题。 + 纯 `# 标题`(无 Q/问题 前缀)仅作分节符结束当前问答对、不进答案也不作问题;``` 与 ~~~ 围栏(同标记成对) + 圈出的代码块整体只作答案正文,块内形似 Q:/A:/标题的行不切边界。 2. Markdown 标题:标题作问题、标题下内容作答案;仅当 1. 整轮未命中时兜底(用于纯 `# 标题` 风格的 FAQ 文档)。 3. Markdown 表格:按 | 分隔两列表格作 Q/A 对;md/markdown/mdx/docx/csv/无后缀与 1./2. 叠加, xlsx 在 1./2. 落空后才尝试,txt 不走表格。 diff --git a/backend/package/yuxi/models/embed.py b/backend/package/yuxi/models/embed.py index 50670970d..003ffc216 100644 --- a/backend/package/yuxi/models/embed.py +++ b/backend/package/yuxi/models/embed.py @@ -177,13 +177,12 @@ def _extract_embeddings(result: dict) -> list[list[float]]: @staticmethod def _log_long_inputs(message: list[str] | str, threshold: int = 4000) -> None: - """调试辅助:打印超过字符阈值的 embedding 输入内容。""" + """调试辅助:记录超过字符阈值的 embedding 输入位置与长度,不输出内容以免泄露用户数据。""" messages = [message] if isinstance(message, str) else message for idx, text in enumerate(messages): if text and len(text) > threshold: logger.warning( - f"超长 embedding 输入 index={idx}, len={len(text)}, " - f"content_head={text[:200]!r}, content_tail={text[-200:]!r}" + f"超长 embedding 输入 index={idx}, len={len(text)}" ) def encode(self, message: list[str] | str) -> list[list[float]]: diff --git a/backend/test/unit/test_qa_chunk_length_limit.py b/backend/test/unit/test_qa_chunk_length_limit.py new file mode 100644 index 000000000..bda06f50b --- /dev/null +++ b/backend/test/unit/test_qa_chunk_length_limit.py @@ -0,0 +1,136 @@ +"""QA parser 超长 chunk 限长切分(_split_long_qa_chunks)的回归测试。 + +验收主张:chunk_markdown 产出的任意单条 chunk 不超过 _QA_CHUNK_MAX_CHARS, +避免超过 bge_m3 等 embedding 模型的 4096 token 上下文上限;切分时保留问题、 +只切答案,保证每条 chunk 仍是完整问答语义。 +""" + +import importlib.util +from pathlib import Path + +_PKG = Path(__file__).resolve().parents[2] / "package" + + +def _load_qa_parser(): + """按文件路径隔离加载 qa parser;其仅依赖标准库,无需注册 sys.modules。""" + spec = importlib.util.spec_from_file_location( + "qa_parser_under_test", + _PKG / "yuxi/knowledge/chunking/ragflow_like/parsers/qa.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +qa = _load_qa_parser() + +# embedding 上下文上限的保守字符兜底(对应 bge_m3 4096 token),独立于实现常量, +# 避免用被测对象的默认值充当断言边界形成自我引用 oracle +_EMBEDDING_CHAR_LIMIT = 4000 +_QUESTION = "问题:" + "这是一个问题" * 5 # 远低于上限,切分后可原样保留 + + +def _long_chunk(answer_body: str) -> str: + return f"{_QUESTION}\t回答:{answer_body}" + + +def _split(chunks: list[str]) -> list[str]: + """显式传入限长值,单测只验证切分逻辑本身,不依赖实现默认常量。""" + return qa._split_long_qa_chunks(chunks, max_chars=_EMBEDDING_CHAR_LIMIT) + + +class TestSplitLongQaChunks: + def test_short_chunks_pass_through(self): + chunks = ["问题:短问题\t回答:短答案", "Question: q\tAnswer: a"] + assert _split(chunks) == [c.strip() for c in chunks] + + def test_blank_chunks_filtered(self): + assert _split(["", " ", "问题:q\t回答:a"]) == ["问题:q\t回答:a"] + + def test_long_answer_split_by_paragraphs_keeps_question(self): + body = "\n\n".join(f"段落{i}内容。" * 300 for i in range(3)) + result = _split([_long_chunk(body)]) + assert len(result) > 1 + for chunk in result: + assert len(chunk) <= _EMBEDDING_CHAR_LIMIT + # 每条子 chunk 保留完整问题与前缀,维持问答语义 + assert chunk.startswith(f"{_QUESTION}\t回答:") + # 答案内容在切分结果中完整保留 + joined = "".join(c.split("\t回答:", 1)[1] for c in result) + for i in range(3): + assert f"段落{i}内容。" in joined + + def test_single_long_paragraph_falls_back_to_lines(self): + body = "\n".join(f"第{i}行" + "内容" * 100 for i in range(30)) + result = _split([_long_chunk(body)]) + assert len(result) > 1 + for chunk in result: + assert len(chunk) <= _EMBEDDING_CHAR_LIMIT + assert chunk.startswith(f"{_QUESTION}\t回答:") + + def test_line_split_preserves_code_indentation(self): + # 缩进对围栏代码块有语义:按行切分不得剥离前导空白 + code = ["```python", "def handler():", " if ready:", " return compute()", "```"] + filler = [f"说明行{i}:" + "内容" * 100 for i in range(30)] + body = "\n".join(filler[:15] + code + filler[15:]) + result = _split([_long_chunk(body)]) + assert len(result) > 1 + joined = "\n".join(result) + for line in code: + assert line in joined + + def test_structureless_long_answer_hard_split(self): + result = _split([_long_chunk("答" * 9000)]) + assert len(result) >= 3 + for chunk in result: + assert len(chunk) <= _EMBEDDING_CHAR_LIMIT + assert chunk.startswith(f"{_QUESTION}\t回答:") + + def test_oversized_question_falls_back_to_hard_split(self): + # 问题本身已接近上限时,保留问题切答案只会产出 1 字符答案碎片,应整条硬切 + chunk = "问题:" + "超" * 5000 + "\t回答:答案" + result = _split([chunk]) + assert len(result) > 1 + assert all(len(c) <= _EMBEDDING_CHAR_LIMIT for c in result) + + def test_non_standard_chunk_hard_split(self): + result = _split(["无结构文本" * 1000]) + assert len(result) > 1 + assert all(len(c) <= _EMBEDDING_CHAR_LIMIT for c in result) + + def test_question_containing_tab_kept_whole(self): + # 问题本身含 tab:结构分隔符是紧邻答案前缀的 tab,不是首个任意 tab + question = "问题:什么是\t缩进风格?" + result = _split([question + "\t回答:" + "答案内容。" * 1500]) + assert len(result) > 1 + for chunk in result: + assert len(chunk) <= _EMBEDDING_CHAR_LIMIT + assert chunk.startswith(question + "\t回答:") + + def test_tab_chunk_without_answer_marker_hard_split(self): + # 含 tab 但无已知答案前缀的 chunk 仍属非标准格式,硬切兜底 + result = _split(["左列\t右列" * 1000]) + assert len(result) > 1 + assert all(len(c) <= _EMBEDDING_CHAR_LIMIT for c in result) + + def test_zero_max_chars_only_strips(self): + assert qa._split_long_qa_chunks([" 问题:q\t回答:a "], max_chars=0) == ["问题:q\t回答:a"] + + +class TestChunkMarkdownLengthCap: + """端到端验收:真实 QA 文档经 chunk_markdown 后任意 chunk 不超 embedding 上限。""" + + _LONG_MD = "Q: 高频问题\nA: " + "长答案内容。" * 1500 + + def test_all_chunks_within_embedding_limit(self): + # 走实现默认常量:锁定「默认上限不超 embedding 承诺」这一工程主张, + # 若 _QA_CHUNK_MAX_CHARS 被调大到超过 4000,本断言会失败 + chunks = qa.chunk_markdown("faq.md", self._LONG_MD) + assert len(chunks) > 1 + assert all(len(c) <= _EMBEDDING_CHAR_LIMIT for c in chunks) + assert all(c.startswith("问题:高频问题\t回答:") for c in chunks) + + def test_input_actually_triggers_split(self): + # 负向校验:同一输入若缺少限长步骤必然超限,证明上面的端到端断言能捕获 guard 回归 + raw = qa._to_qa_chunk("高频问题", "长答案内容。" * 1500) + assert len(raw) > _EMBEDDING_CHAR_LIMIT diff --git a/backend/test/unit/test_qa_prefix_parsing.py b/backend/test/unit/test_qa_prefix_parsing.py new file mode 100644 index 000000000..c7c1103d3 --- /dev/null +++ b/backend/test/unit/test_qa_prefix_parsing.py @@ -0,0 +1,99 @@ +"""QA parser 前缀/标题提取的代码围栏边界回归测试。 + +验收主张:答案中的围栏代码块(``` 或 ~~~,同标记成对)是答案正文, +块内形似 Q:/A: 或 Markdown 标题的行不得被解析为真实问答边界。 +""" + +import importlib.util +from pathlib import Path + +_PKG = Path(__file__).resolve().parents[2] / "package" + + +def _load_qa_parser(): + """按文件路径隔离加载 qa parser;其仅依赖标准库,无需注册 sys.modules。""" + spec = importlib.util.spec_from_file_location( + "qa_parser_under_test", + _PKG / "yuxi/knowledge/chunking/ragflow_like/parsers/qa.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +qa = _load_qa_parser() + +_TILDE_MD = "Q: 如何配置?\nA: 参考示例:\n~~~\nQ: 注释里的文本\nA: 不是真实问答\n~~~\n完成后重启。" + + +class TestPrefixFenceBoundary: + def test_tilde_fence_content_stays_in_answer(self): + # 核心回归:tilde 围栏内的 Q:/A: 行不得拆出虚构问答对 + pairs = qa._extract_pairs_by_prefix(_TILDE_MD) + assert len(pairs) == 1 + q, a = pairs[0] + assert q == "如何配置?" + assert "注释里的文本" in a + assert "完成后重启。" in a + + def test_backtick_fence_content_stays_in_answer(self): + pairs = qa._extract_pairs_by_prefix(_TILDE_MD.replace("~~~", "```")) + assert len(pairs) == 1 + assert "注释里的文本" in pairs[0][1] + + def test_fence_with_info_string(self): + md = "Q: 配置?\nA: 示例:\n~~~python\nQ: 注释\n~~~\n完。" + pairs = qa._extract_pairs_by_prefix(md) + assert len(pairs) == 1 + assert "Q: 注释" in pairs[0][1] + + def test_mismatched_fence_does_not_close_block(self): + # ``` 行不关闭 ~~~ 块;块内 Q: 行仍属答案 + md = "Q: 配置?\nA: 开始\n~~~\n```\nQ: 仍在块内\n~~~\nQ: 真实问题2\nA: 答案2" + pairs = qa._extract_pairs_by_prefix(md) + assert len(pairs) == 2 + assert "仍在块内" in pairs[0][1] + assert pairs[1] == ("真实问题2", "答案2") + + def test_unclosed_fence_absorbs_rest_into_answer(self): + md = "Q: 配置?\nA: 开始\n~~~\nQ: 后面全在块内\nA: 也是" + pairs = qa._extract_pairs_by_prefix(md) + assert len(pairs) == 1 + assert "后面全在块内" in pairs[0][1] + + def test_heading_inside_tilde_fence_not_treated_as_question(self): + # 标题提取路径:tilde 块内的 # 行不识别为标题 + md = "# 安装\n步骤一\n~~~\n# 注释不是标题\n~~~\n步骤二" + pairs = qa._extract_pairs_from_markdown_headings(md) + assert len(pairs) == 1 + q, a = pairs[0] + assert q == "安装" + assert "注释不是标题" in a + assert "步骤二" in a + + def test_chunk_markdown_end_to_end_tilde_fence(self): + chunks = qa.chunk_markdown("faq.md", _TILDE_MD) + assert len(chunks) == 1 + assert chunks[0].startswith("问题:如何配置?\t回答:") + assert "注释里的文本" in chunks[0] + + +class TestOrphanAnswer: + """答案前缀行必须归属活跃问题,前言中的孤儿答案不得污染后续问答对。""" + + def test_orphan_answer_before_first_question_discarded(self): + md = "Answer: 孤儿前言\n# Q: 真实问题\nA: 真实答案" + pairs = qa._extract_pairs_by_prefix(md) + assert pairs == [("真实问题", "真实答案")] + + def test_orphan_answer_between_pairs_discarded(self): + # 上一对已结束(分节标题 flush)、新问题未出现时,孤儿答案同样忽略 + md = "Q: 问题一\nA: 答案一\n# 分节\nA: 孤儿\nQ: 问题二\nA: 答案二" + pairs = qa._extract_pairs_by_prefix(md) + assert pairs == [("问题一", "答案一"), ("问题二", "答案二")] + + def test_multiple_answer_lines_within_question_kept(self): + # 活跃问题下的多个 A: 行仍全部归入答案(修复不改变正常路径) + md = "Q: 问题\nA: 第一行\nA: 第二行" + pairs = qa._extract_pairs_by_prefix(md) + assert pairs == [("问题", "第一行\n第二行")] diff --git a/docs/develop-guides/changelog.md b/docs/develop-guides/changelog.md index f01d7060b..5de5d6283 100644 --- a/docs/develop-guides/changelog.md +++ b/docs/develop-guides/changelog.md @@ -112,8 +112,8 @@ v0.7.2.beta1 包含不可逆的数据与文件布局迁移,主要影响历史 - 侧边栏对话列表新增 Thread 运行状态:以 `AgentRun` 为事实来源,后端把每个线程最新顶层 chat/resume Run 聚合成 `thread_status`(进行中显示 loading、已终态未查看显示 ready 点、已查看或无 Run 为 done),列表接口一次窗口查询完成聚合、不逐项请求;`Conversation` 新增 `last_viewed_run_id` 持久化查看边界,`POST /api/chat/thread/{id}/viewed` 幂等标记已读。前端打开线程、当前线程收到终态/中断事件时自动标记已读,发送或恢复 Run 时置为 loading,侧边栏可见时低频轮询刷新后台线程状态;历史线程上线时按各自最新顶层 Run 一次性回填为已读,新建线程写入未读哨兵避免回填误清新产生的未读点。 - 修复 Thread 运行状态两个正确性问题:终态/中断事件仅在事件线程等于当前打开线程时才自动标记已读,后台线程完成保留 ready 点直至用户打开;无 chat/resume Run 的历史会话(agent_call / agent_evaluation 调用、从未对话过的线程)在回填时写入未读哨兵,使回填探测条件收敛为 false,避免每次启动都重复对 `agent_runs` 做全表聚合。 - 优化侧边栏对话列表操作渐隐:`.actions-mask` 三态(默认/悬浮/激活)渐隐统一为线性延伸至操作按钮左边缘(距右缘 28px)再转为实色,修复悬浮时渐隐铺满整条遮罩导致按钮下方文字残留鬼影、以及三处渐隐宽度不一致的问题。 -- QA 分块重写 `_extract_pairs_by_prefix`:接收完整 markdown 文本并识别代码块围栏;标题行先剥 `#` 再按 Q/A 前缀判断,`# Q:`、`## 问题:` 与裸 `Q:` 走同一前缀路径,纯 `#` 标题仅作分节符、不进答案。`.md/.markdown/.mdx/.docx` 合并分支:前缀提取优先 → 标题提取兜底 → 表格叠加;`.txt` 改为分隔符优先 + 前缀兜底。新增超长 chunk 切分(>4000 字符):保留问题按段落/行逐级切分答案,问题本身超限时整条硬切。 -- embed.py 在 `encode`/`aencode` 请求前以 `logger.warning` 打印超过 4000 字符输入的 index、长度和首尾 200 字符预览,便于定位具体哪条 chunk 超长触发 embedding 失败。 +- QA 分块重写 `_extract_pairs_by_prefix`:接收完整 markdown 文本并识别 ` ``` ` 与 `~~~` 代码围栏(同标记成对),围栏块内形似 `Q:`/`A:`/标题的行不切边界;标题行先剥 `#` 再按 Q/A 前缀判断,`# Q:`、`## 问题:` 与裸 `Q:` 走同一前缀路径,纯 `#` 标题仅作分节符、不进答案;问题出现前的孤儿 `A:`/`Answer:` 行直接忽略,不再拼入后续问答对。`.md/.markdown/.mdx/.docx` 合并分支:前缀提取优先 → 标题提取兜底 → 表格叠加;`.txt` 改为分隔符优先 + 前缀兜底。新增超长 chunk 切分(>4000 字符):保留问题按段落/行逐级切分答案(行切分保留代码缩进),问答分隔定位紧邻答案前缀的 tab(问题本身含 tab 不被截断),问题本身超限时整条硬切。 +- embed.py 在 `encode`/`aencode` 请求前以 `logger.warning` 记录超过 4000 字符输入的 index 与长度,便于定位具体哪条 chunk 超长触发 embedding 失败;日志不输出 chunk 内容,避免知识库文本进入应用日志。 ## v0.7.1 (2026-07-17) diff --git a/docs/develop-guides/decisions/implemented/2026-08-27-qa-chunk-length-limit.md b/docs/develop-guides/decisions/implemented/2026-08-27-qa-chunk-length-limit.md new file mode 100644 index 000000000..2853c6b71 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-08-27-qa-chunk-length-limit.md @@ -0,0 +1,51 @@ +# QA 前缀解析边界与超长限长切分 + +状态:implemented +类型:bug-fix +Owner:backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py + +## 问题 + +QA 分块解析器存在四个影响索引质量的缺陷: + +1. `# Q: xxx` / `## 问题: xxx` 这类带问答前缀的 Markdown 标题不被识别,标题剥 `#` 前无法匹配行首 Q/A 前缀,导致此类 FAQ 文档的问答边界丢失。 +2. 渲染后的 QA chunk 没有任何长度上限。目标 embedding 模型 bge_m3 的上下文为 4096 tokens,超长单条问答在索引时会被服务端截断或报错,且截断点不可控。 +3. 代码围栏只识别反引号围栏,不识别 Markdown 同样允许的波浪号围栏。答案中的 `~~~` 块内若有形似 `Q:`/`A:` 或 `# 标题` 的行,会被前缀提取和标题提取误切为虚构问答对。 +4. 前缀提取中 `A:`/`Answer:` 行在问题出现前被无条件记入答案缓冲,而 `flush_pair()` 在无活跃问题时不清空缓冲。文件前言里的孤儿答案行会被拼接到其后首个真实问答对的答案前,污染提取结果。 + +## 决策 + +1. 行首前缀匹配前先把标题行剥为纯文本,使 `# Q:` / `## 问题:` 与裸 `Q:` / `问题:` 走同一边界识别。 +2. `chunk_markdown` 输出的所有 QA chunk 统一经 `_split_long_qa_chunks` 限长:超过 `_QA_CHUNK_MAX_CHARS = 4000` 字符的 chunk 保留完整问题与前缀,只切答案;答案按段落 → 行 → 固定字符硬切逐级降级,问题本身超限或 chunk 非标准 QA 格式时整条硬切,保证任意单条不超上限。问答分隔定位紧邻已知答案前缀(`\t回答:` / `\tAnswer: `)的 tab,而非首个任意 tab,问题本身含 tab 时不被截断。按行切分时 strip 仅用于判空,重组保留每行原始缩进,避免破坏围栏代码块与嵌套列表的语义。 +3. 4000 是面向 bge_m3 4096 token 上限的保守字符数兜底:中/英/数字混合内容按接近 1 token/字的最坏情况预留缓冲,不引入 tokenizer 依赖。 +4. embed 侧配套 `_log_long_inputs` 调试日志,只记录超限输入的 index 与长度,不输出内容,避免知识库文本进入应用日志。 +5. 围栏状态由 `_update_fence_state` 统一追踪,` ``` ` 与 `~~~` 各自成对开关,异类围栏行不关闭当前块;前缀提取与标题提取两处共用同一状态机。围栏行与块内行只作答案正文,不参与问答边界与标题识别。 +6. 答案前缀行只在存在活跃问题时记入答案缓冲,与普通文本行的守卫一致;问题出现前的孤儿 `A:`/`Answer:` 行直接忽略。 + +## 替代方案 + +- 按 tokenizer 精确计数切分:引入 tokenizer 依赖并与具体模型耦合,而 qa.py 目前仅依赖标准库,且 embedding 模型由部署配置决定,parser 层无法确知远端词表。仓库现有 `count_tokens` 是正则近似(英文单词/数字/CJK 单字),完全忽略 emoji 与其他符号,对 byte-fallback 多 token 场景反而低估,不构成更可靠的 oracle。拒绝,字符数兜底与仓库「近似计数、避免引入额外依赖」的既定取舍一致。 +- 在 embedding 调用处截断超长输入:会静默丢失答案后半段且切断问答结构,检索语义受损,拒绝。 +- 复用 general parser 的 token 限长:通用切分不识别 QA 结构,会把问题与答案拆进不同 chunk,破坏每条 chunk 的问答完整性,拒绝。 +- 把上限提到 8000+ 字符逼近 token 上限:中文内容 token 密度高,超限时失败点转移到模型服务端,不可观察,拒绝。 +- 围栏沿用统一布尔翻转(不区分围栏类型):`~~~` 块内的 ` ``` ` 行会提前关闭代码块,混合围栏文档重新出现误切,拒绝。 + +## 后果 + +- 模型可见 chunk 内容变化:超长答案被拆为多条 chunk,每条重复完整问题。检索命中任一子 chunk 都能带出问题,但单条内的答案可能不完整,由召回多条补偿。 +- 重复问题占用每条子 chunk 的 embedding 容量,这是保留问答语义付出的代价。 +- 4000 是启发式保守值而非精确 token 换算;更换 embedding 模型或上游 token 上限变化时需要重估该常量。 +- 已知限制:字符数不严格等于 token 数。byte-fallback tokenizer 下 emoji 或罕见 Unicode 单字符可占多个 token,含大量此类字符的 chunk 即使 ≤4000 字符仍可能超 4096 token。此时 embedding API 显式报错(非 429/可重试状态码),索引任务失败可见,不会静默截断;`_log_long_inputs` 记录的 index/len 可辅助定位。严格闭合要求加载与部署模型一致的 tokenizer,暂不接受该依赖。 +- 已知限制:行级切分点不感知围栏状态,超长单段落中的代码块可能跨 chunk(开闭围栏分离),影响命中文案的渲染完整性,不影响 embedding 向量语义;围栏感知切分的状态机复杂度与该场景的触发频率不成比例,暂不引入。 +- 本记录由 PR review 补录:变更本身小而完整、已随修复生效且无待裁决替代,按规范直接写 `implemented`,未先建 `proposed`。 + +## 验证 + +- 新增 `backend/test/unit/test_qa_chunk_length_limit.py`:13 个用例覆盖短 chunk 透传、空白过滤、段落/行/硬切三级降级、行切分保留代码缩进、问题含 tab 时完整保留、无答案前缀 tab chunk 硬切、问题超限硬切、非标准格式硬切、`max_chars<=0` 语义,以及 `chunk_markdown` 端到端「任意 chunk ≤ 4000 字符且保留问题」的验收主张。断言边界硬编码 4000(对应 bge_m3 4096 token 的保守字符兜底),不引用实现常量 `_QA_CHUNK_MAX_CHARS`,避免自我引用 oracle。本地容器与 pytest 不可用,用 stdlib `importlib` 隔离加载 runner 执行,13 passed。 +- 负向验证:在同一进程内 monkeypatch 移除限长步骤后,端到端测试输入产出单条 9011 字符 chunk,`test_all_chunks_within_embedding_limit` 在正确原因上失败;将默认常量调大到 8000 时产出 8000 字符 chunk,端到端断言同样失败;恢复 guard 后通过。 +- `test_input_actually_triggers_split` 常驻断言测试输入本身超限,防止输入缩水导致端到端断言退化为恒真。 +- 新增 `backend/test/unit/test_qa_prefix_parsing.py`:10 个用例覆盖 tilde/backtick 围栏边界、带 info string 围栏、混合围栏不提前关闭、未闭合围栏吞并剩余、标题提取路径围栏、端到端 `chunk_markdown` 只产一对、首问题前/分节后的孤儿答案被忽略、活跃问题下多 A: 行仍完整归入。同一 stdlib runner 执行,10 passed;两个 QA 测试文件合计 23 passed。 +- 围栏负向验证:monkeypatch 回退为只识别反引号的旧状态机后,评论场景输入产出 2 对虚构问答,`test_tilde_fence_content_stays_in_answer` 在正确原因上失败;回退行级 strip 后缩进代码行丢失,`test_line_split_preserves_code_indentation` 在正确原因上失败;回退首个 tab 分隔后含 tab 问题被截断为 tab 前部分,`test_question_containing_tab_kept_whole` 在正确原因上失败;回退孤儿答案守卫后前言被拼入真实答案,`test_orphan_answer_before_first_question_discarded` 在正确原因上失败。 +- dispatcher 回归:用修复后的 qa.py 复跑 `test_qa_chunking_from_markdown_headings` 的标题风格 FAQ 场景,产出与预期一致;`test_ragflow_like_chunking.py` 依赖完整包环境,待容器内补跑。 +- embed 日志修复(只记 index/len):AST 语法解析与 `git diff --check` 通过。 +- 待 PR 环境补跑:`docker compose exec api uv run --group test pytest test/unit/test_qa_chunk_length_limit.py test/unit/test_qa_prefix_parsing.py test/unit/plugins/test_ragflow_like_chunking.py`。 From 484c250a2c81ca68a89318b1685c86f1b90b4eb1 Mon Sep 17 00:00:00 2001 From: suhan <72542107+suhan42@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:36:04 +0800 Subject: [PATCH 4/5] =?UTF-8?q?style(qa):=20ruff=20format=20=E9=87=8D?= =?UTF-8?q?=E5=86=99=20qa.py=20=E4=B8=8E=20embed.py=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20CI=20=E6=A0=BC=E5=BC=8F=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/knowledge/chunking/ragflow_like/parsers/qa.py | 3 +-- backend/package/yuxi/models/embed.py | 4 +--- backend/test/unit/test_qa_chunk_length_limit.py | 6 ++---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py index e059d9c74..0194abcec 100644 --- a/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py +++ b/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/qa.py @@ -217,8 +217,7 @@ def flush_pair() -> None: a_match = answer_re.match(text) if a_match: - # 答案必须归属活跃问题:问题尚未出现时的前言 A: 行直接忽略, - # 避免孤儿文本被拼进后续真实问答对 + # 答案必须归属活跃问题:问题尚未出现时的前言 A: 行直接忽略,避免孤儿文本被拼进后续真实问答对 if question: answer_lines.append(a_match.group(1).strip()) continue diff --git a/backend/package/yuxi/models/embed.py b/backend/package/yuxi/models/embed.py index 003ffc216..4ea30e718 100644 --- a/backend/package/yuxi/models/embed.py +++ b/backend/package/yuxi/models/embed.py @@ -181,9 +181,7 @@ def _log_long_inputs(message: list[str] | str, threshold: int = 4000) -> None: messages = [message] if isinstance(message, str) else message for idx, text in enumerate(messages): if text and len(text) > threshold: - logger.warning( - f"超长 embedding 输入 index={idx}, len={len(text)}" - ) + logger.warning(f"超长 embedding 输入 index={idx}, len={len(text)}") def encode(self, message: list[str] | str) -> list[list[float]]: payload = self.build_payload(message) diff --git a/backend/test/unit/test_qa_chunk_length_limit.py b/backend/test/unit/test_qa_chunk_length_limit.py index bda06f50b..9b9bd1a5a 100644 --- a/backend/test/unit/test_qa_chunk_length_limit.py +++ b/backend/test/unit/test_qa_chunk_length_limit.py @@ -24,8 +24,7 @@ def _load_qa_parser(): qa = _load_qa_parser() -# embedding 上下文上限的保守字符兜底(对应 bge_m3 4096 token),独立于实现常量, -# 避免用被测对象的默认值充当断言边界形成自我引用 oracle +# embedding 上下文上限的保守字符兜底(对应 bge_m3 4096 token),独立于实现常量以避免自我引用 oracle _EMBEDDING_CHAR_LIMIT = 4000 _QUESTION = "问题:" + "这是一个问题" * 5 # 远低于上限,切分后可原样保留 @@ -123,8 +122,7 @@ class TestChunkMarkdownLengthCap: _LONG_MD = "Q: 高频问题\nA: " + "长答案内容。" * 1500 def test_all_chunks_within_embedding_limit(self): - # 走实现默认常量:锁定「默认上限不超 embedding 承诺」这一工程主张, - # 若 _QA_CHUNK_MAX_CHARS 被调大到超过 4000,本断言会失败 + # 走实现默认常量:锁定「默认上限不超 embedding 承诺」这一工程主张,_QA_CHUNK_MAX_CHARS 被调过 4000 时本断言失败 chunks = qa.chunk_markdown("faq.md", self._LONG_MD) assert len(chunks) > 1 assert all(len(c) <= _EMBEDDING_CHAR_LIMIT for c in chunks) From b09f32dd82d6ccbd60b995201b6a1a95bf436e5e Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 28 Aug 2026 11:40:08 +0800 Subject: [PATCH 5/5] refactor: Implement retry logic for encode and aencode methods Refactor encode and aencode methods to handle requests with retries and error logging. --- backend/package/yuxi/models/embed.py | 112 +++++++++++++-------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/backend/package/yuxi/models/embed.py b/backend/package/yuxi/models/embed.py index 4ea30e718..138fffe0e 100644 --- a/backend/package/yuxi/models/embed.py +++ b/backend/package/yuxi/models/embed.py @@ -120,6 +120,62 @@ def __init__(self, **kwargs) -> None: def build_payload(self, message: list[str] | str) -> dict: return {"model": self.model, "input": message} + def encode(self, message: list[str] | str) -> list[list[float]]: + payload = self.build_payload(message) + retry_index = 0 + + self._log_long_inputs(message) + + while True: + try: + response = requests.post(self.base_url, json=payload, headers=self.headers, timeout=60) + response.raise_for_status() + return self._extract_embeddings(response.json()) + except requests.RequestException as e: + retry = self._prepare_retry( + message, + retry_index=retry_index, + response=getattr(e, "response", None), + error=e, + ) + if retry: + retry_index, delay = retry + time.sleep(delay) + continue + + logger.error(f"Embedding request failed: {e}, {payload}") + raise ValueError(f"Embedding request failed: {e}") + + async def aencode(self, message: list[str] | str) -> list[list[float]]: + payload = self.build_payload(message) + self._log_long_inputs(message) + async with httpx.AsyncClient() as client: + retry_index = 0 + while True: + try: + response = await client.post(self.base_url, json=payload, headers=self.headers, timeout=60) + response.raise_for_status() + return self._extract_embeddings(response.json()) + except httpx.HTTPStatusError as e: + retry = self._prepare_retry( + message, + retry_index=retry_index, + response=e.response, + error=e, + ) + if retry: + retry_index, delay = retry + await asyncio.sleep(delay) + continue + raise + except httpx.RequestError as e: + retry = self._prepare_retry(message, retry_index=retry_index, error=e) + if retry: + retry_index, delay = retry + await asyncio.sleep(delay) + continue + raise ValueError(f"Embedding async request failed: {e}, {payload}, {self.base_url=}") + @staticmethod def _retry_delay_seconds(retry_index: int, retry_after: str | None = None) -> float: if retry_after: @@ -183,62 +239,6 @@ def _log_long_inputs(message: list[str] | str, threshold: int = 4000) -> None: if text and len(text) > threshold: logger.warning(f"超长 embedding 输入 index={idx}, len={len(text)}") - def encode(self, message: list[str] | str) -> list[list[float]]: - payload = self.build_payload(message) - retry_index = 0 - - self._log_long_inputs(message) - - while True: - try: - response = requests.post(self.base_url, json=payload, headers=self.headers, timeout=60) - response.raise_for_status() - return self._extract_embeddings(response.json()) - except requests.RequestException as e: - retry = self._prepare_retry( - message, - retry_index=retry_index, - response=getattr(e, "response", None), - error=e, - ) - if retry: - retry_index, delay = retry - time.sleep(delay) - continue - - logger.error(f"Embedding request failed: {e}, {payload}") - raise ValueError(f"Embedding request failed: {e}") - - async def aencode(self, message: list[str] | str) -> list[list[float]]: - payload = self.build_payload(message) - self._log_long_inputs(message) - async with httpx.AsyncClient() as client: - retry_index = 0 - while True: - try: - response = await client.post(self.base_url, json=payload, headers=self.headers, timeout=60) - response.raise_for_status() - return self._extract_embeddings(response.json()) - except httpx.HTTPStatusError as e: - retry = self._prepare_retry( - message, - retry_index=retry_index, - response=e.response, - error=e, - ) - if retry: - retry_index, delay = retry - await asyncio.sleep(delay) - continue - raise - except httpx.RequestError as e: - retry = self._prepare_retry(message, retry_index=retry_index, error=e) - if retry: - retry_index, delay = retry - await asyncio.sleep(delay) - continue - raise ValueError(f"Embedding async request failed: {e}, {payload}, {self.base_url=}") - def get_embedding_model_info_by_id(model_id: str) -> dict: info = model_cache.get_model_info(model_id)