ai-chatbot / app /services /chunking.py
appQQQ's picture
chore: upload app/services/chunking.py
356cfcc verified
Raw
History Blame
9.36 kB
"""层次化分块器.
策略:
1. 优先按 markdown 标题拆分 (H1/H2/H3)
2. 每个 section 内:
- 段间按 token 预算合并 (parent: 1500-2000t)
- 段内进一步切分 (child: 400-512t)
3. 可选: 语义分块 (按 sentence 相似度二次切)
4. 可选: Anthropic-style 上下文预置 (LLM 给每个 chunk 加 context prefix)
输出:
- parents: 大块, 给 LLM 喂上下文用
- children: 小块, 给向量检索用
- 每个 child 带 parent_id, page_no, heading 字段
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import uuid
from dataclasses import dataclass, field
from app.config import settings
from app.llm.base import LLMMessage
from app.services.parsers.base_parser import ParsedDocument
logger = logging.getLogger(__name__)
# 标题正则 (markdown)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
@dataclass
class Chunk:
"""分块结果 (child or parent)."""
id: str
doc_id: str
parent_id: str | None
chunk_index: int
text: str
token_count: int
page_no: int | None = None
heading: str | None = None
context_prefix: str | None = None
created_at: float = field(default_factory=time.time)
def _approx_token_count(s: str) -> int:
"""粗估 token 数. 用 chars/2 当近似, 避免引 tiktoken."""
if not s:
return 0
# 中英文混合: CJK 算 1.5 token/char, 其它算 0.5
cjk = sum(1 for c in s if "一" <= c <= "鿿")
other = len(s) - cjk
return int(cjk * 1.5 + other * 0.5)
def _split_by_headings(markdown: str) -> list[tuple[str, str]]:
"""按 markdown 标题拆分. 返回 [(heading, body), ...].
heading 为 None 表示标题前的引言段.
"""
matches = list(_HEADING_RE.finditer(markdown))
if not matches:
return [(None, markdown)]
chunks: list[tuple[str, str]] = []
# 引言段
pre = markdown[: matches[0].start()].strip()
if pre:
chunks.append((None, pre))
for i, m in enumerate(matches):
heading = m.group(2).strip()
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
body = markdown[start:end].strip()
if body:
chunks.append((heading, body))
return chunks
def _sliding_window(text: str, target: int, overlap: int) -> list[str]:
"""按 token 估值的滑动窗口切分.
简化版: 按段落切, 累积到 target token 就 flush, 与上一段重叠 overlap.
"""
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
if not paragraphs:
return []
pieces: list[str] = []
buf: list[str] = []
buf_tokens = 0
for p in paragraphs:
pt = _approx_token_count(p)
# 单段超过 target, 硬切
if pt > target:
if buf:
pieces.append("\n\n".join(buf))
buf, buf_tokens = [], 0
# 字符级切
step = max(int(target * 2), 200) # target * 2 chars ≈ target tokens
for i in range(0, len(p), step):
pieces.append(p[i : i + step])
continue
if buf_tokens + pt > target and buf:
pieces.append("\n\n".join(buf))
# overlap: 保留最后一段
if overlap > 0 and buf:
last = buf[-1]
last_t = _approx_token_count(last)
if last_t <= overlap:
buf = [last]
buf_tokens = last_t
else:
buf, buf_tokens = [], 0
else:
buf, buf_tokens = [], 0
buf.append(p)
buf_tokens += pt
if buf:
pieces.append("\n\n".join(buf))
return pieces
def _split_by_paragraphs(text: str) -> list[str]:
return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
@dataclass
class ChunkingResult:
parents: list[Chunk]
children: list[Chunk]
def chunk_document(parsed: ParsedDocument, doc_id: str) -> ChunkingResult:
"""对 ParsedDocument 做层次化分块.
步骤:
1. 按 markdown 标题分 section
2. 每个 section 用滑动窗口切 parent (2000t, overlap 200)
3. 每个 parent 内用滑动窗口切 child (512t, overlap 64)
4. 按 page 编号 (按 parent 在 markdown 中的字符位置近似)
"""
md = parsed.markdown
sections = _split_by_headings(md)
page_text = parsed.pages # 可能为空 (Marker)
parents: list[Chunk] = []
children: list[Chunk] = []
chunk_idx = 0
for heading, body in sections:
# 先切 parent
for ptext in _sliding_window(body, target=2000, overlap=200):
pid = uuid.uuid4().hex
ptoks = _approx_token_count(ptext)
# 估算 page_no
page_no = _estimate_page_no(ptext, parsed) if page_text else None
parent = Chunk(
id=pid,
doc_id=doc_id,
parent_id=None,
chunk_index=chunk_idx,
text=ptext,
token_count=ptoks,
page_no=page_no,
heading=heading,
)
parents.append(parent)
chunk_idx += 1
# 切 child
for ctext in _sliding_window(ptext, target=settings.chunk_size, overlap=settings.chunk_overlap):
cid = uuid.uuid4().hex
ctoks = _approx_token_count(ctext)
child = Chunk(
id=cid,
doc_id=doc_id,
parent_id=pid,
chunk_index=chunk_idx,
text=ctext,
token_count=ctoks,
page_no=page_no,
heading=heading,
)
children.append(child)
chunk_idx += 1
return ChunkingResult(parents=parents, children=children)
def _estimate_page_no(snippet: str, parsed: ParsedDocument) -> int | None:
"""在 parsed.pages 里找包含 snippet 片段的页. 简单字符串包含."""
if not parsed.pages:
return None
# 取 snippet 前 50 字符作锚
anchor = snippet[:50].strip()
if not anchor:
return None
for p in parsed.pages:
if anchor in p.text:
return p.page_no
return None
# ========== 上下文预置 (Anthropic-style) ==========
_CONTEXT_PROMPT_TEMPLATE = (
"你是一名文档分块上下文标注助手。给定一段来自文档的 chunk (用 <chunk> 包裹) "
"以及文档标题, 请用 1-2 句中文描述该 chunk 在整篇文档中的上下文, 帮助后续检索时理解其含义。\n\n"
"要求:\n"
"- 简洁, 不超过 80 字\n"
"- 包含: 这是什么类型的内容 (定义 / 例子 / 数据 / 结论 / 步骤等), 在文档哪个章节\n"
"- 不要重复 chunk 原内容\n"
"- 仅输出描述本身, 不要加任何前缀\n\n"
"文档标题: {doc_title}\n"
"所属章节: {heading}\n"
"<chunk>\n{chunk}\n</chunk>\n\n"
"上下文描述:"
)
async def contextualize_chunks(
chunks: list[Chunk],
*,
doc_title: str = "未知文档",
max_concurrency: int = 4,
) -> list[Chunk]:
"""为每个 chunk 生成 context_prefix. Anthropic-style.
优化:
- 并发限流 (max_concurrency), 不爆 LLM 限流
- 失败容错: 单条失败不中断, 留 prefix=None
"""
if not settings.contextual_retrieval or not chunks:
return chunks
try:
from app.llm.factory import get_llm
except Exception as e: # noqa: BLE001
logger.warning("LLM not available for contextual retrieval: %s", e)
return chunks
sem = asyncio.Semaphore(max_concurrency)
async def _one(c: Chunk) -> None:
if c.context_prefix:
return
async with sem:
try:
llm = get_llm()
prompt = _CONTEXT_PROMPT_TEMPLATE.format(
doc_title=doc_title,
heading=c.heading or "无",
chunk=c.text[:1200], # 限长, 防 LLM token 爆
)
resp = await llm.chat(
messages=[
LLMMessage(role="system", content="你是上下文标注助手。"),
LLMMessage(role="user", content=prompt),
],
temperature=0.2,
max_tokens=160,
)
prefix = (resp.content or "").strip().strip('"').strip("'")
if len(prefix) > 200:
prefix = prefix[:200]
c.context_prefix = prefix or None
except Exception as e: # noqa: BLE001
logger.warning("Contextual retrieval failed for chunk %s: %s", c.id[:8], e)
await asyncio.gather(*[_one(c) for c in chunks])
n_ok = sum(1 for c in chunks if c.context_prefix)
logger.info("Contextualized %d/%d chunks", n_ok, len(chunks))
return chunks
def chunk_to_embed_text(c: Chunk) -> str:
"""向量化用的文本: context_prefix + heading + body."""
parts: list[str] = []
if c.context_prefix:
parts.append(f"[Context: {c.context_prefix}]")
if c.heading:
parts.append(f"[Section: {c.heading}]")
parts.append(c.text)
return "\n".join(parts)