appQQQ commited on
Commit
356cfcc
·
verified ·
1 Parent(s): 966ee18

chore: upload app/services/chunking.py

Browse files
Files changed (1) hide show
  1. app/services/chunking.py +291 -0
app/services/chunking.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """层次化分块器.
2
+
3
+ 策略:
4
+ 1. 优先按 markdown 标题拆分 (H1/H2/H3)
5
+ 2. 每个 section 内:
6
+ - 段间按 token 预算合并 (parent: 1500-2000t)
7
+ - 段内进一步切分 (child: 400-512t)
8
+ 3. 可选: 语义分块 (按 sentence 相似度二次切)
9
+ 4. 可选: Anthropic-style 上下文预置 (LLM 给每个 chunk 加 context prefix)
10
+
11
+ 输出:
12
+ - parents: 大块, 给 LLM 喂上下文用
13
+ - children: 小块, 给向量检索用
14
+ - 每个 child 带 parent_id, page_no, heading 字段
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import re
21
+ import time
22
+ import uuid
23
+ from dataclasses import dataclass, field
24
+
25
+ from app.config import settings
26
+ from app.llm.base import LLMMessage
27
+ from app.services.parsers.base_parser import ParsedDocument
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # 标题正则 (markdown)
33
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
34
+
35
+
36
+ @dataclass
37
+ class Chunk:
38
+ """分块结果 (child or parent)."""
39
+
40
+ id: str
41
+ doc_id: str
42
+ parent_id: str | None
43
+ chunk_index: int
44
+ text: str
45
+ token_count: int
46
+ page_no: int | None = None
47
+ heading: str | None = None
48
+ context_prefix: str | None = None
49
+ created_at: float = field(default_factory=time.time)
50
+
51
+
52
+ def _approx_token_count(s: str) -> int:
53
+ """粗估 token 数. 用 chars/2 当近似, 避免引 tiktoken."""
54
+ if not s:
55
+ return 0
56
+ # 中英文混合: CJK 算 1.5 token/char, 其它算 0.5
57
+ cjk = sum(1 for c in s if "一" <= c <= "鿿")
58
+ other = len(s) - cjk
59
+ return int(cjk * 1.5 + other * 0.5)
60
+
61
+
62
+ def _split_by_headings(markdown: str) -> list[tuple[str, str]]:
63
+ """按 markdown 标题拆分. 返回 [(heading, body), ...].
64
+
65
+ heading 为 None 表示标题前的引言段.
66
+ """
67
+ matches = list(_HEADING_RE.finditer(markdown))
68
+ if not matches:
69
+ return [(None, markdown)]
70
+
71
+ chunks: list[tuple[str, str]] = []
72
+ # 引言段
73
+ pre = markdown[: matches[0].start()].strip()
74
+ if pre:
75
+ chunks.append((None, pre))
76
+
77
+ for i, m in enumerate(matches):
78
+ heading = m.group(2).strip()
79
+ start = m.end()
80
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
81
+ body = markdown[start:end].strip()
82
+ if body:
83
+ chunks.append((heading, body))
84
+ return chunks
85
+
86
+
87
+ def _sliding_window(text: str, target: int, overlap: int) -> list[str]:
88
+ """按 token 估值的滑动窗口切分.
89
+
90
+ 简化版: 按段落切, 累积到 target token 就 flush, 与上一段重叠 overlap.
91
+ """
92
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
93
+ if not paragraphs:
94
+ return []
95
+
96
+ pieces: list[str] = []
97
+ buf: list[str] = []
98
+ buf_tokens = 0
99
+ for p in paragraphs:
100
+ pt = _approx_token_count(p)
101
+ # 单段超过 target, 硬切
102
+ if pt > target:
103
+ if buf:
104
+ pieces.append("\n\n".join(buf))
105
+ buf, buf_tokens = [], 0
106
+ # 字符级切
107
+ step = max(int(target * 2), 200) # target * 2 chars ≈ target tokens
108
+ for i in range(0, len(p), step):
109
+ pieces.append(p[i : i + step])
110
+ continue
111
+ if buf_tokens + pt > target and buf:
112
+ pieces.append("\n\n".join(buf))
113
+ # overlap: 保留最后一段
114
+ if overlap > 0 and buf:
115
+ last = buf[-1]
116
+ last_t = _approx_token_count(last)
117
+ if last_t <= overlap:
118
+ buf = [last]
119
+ buf_tokens = last_t
120
+ else:
121
+ buf, buf_tokens = [], 0
122
+ else:
123
+ buf, buf_tokens = [], 0
124
+ buf.append(p)
125
+ buf_tokens += pt
126
+ if buf:
127
+ pieces.append("\n\n".join(buf))
128
+ return pieces
129
+
130
+
131
+ def _split_by_paragraphs(text: str) -> list[str]:
132
+ return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
133
+
134
+
135
+ @dataclass
136
+ class ChunkingResult:
137
+ parents: list[Chunk]
138
+ children: list[Chunk]
139
+
140
+
141
+ def chunk_document(parsed: ParsedDocument, doc_id: str) -> ChunkingResult:
142
+ """对 ParsedDocument 做层次化分块.
143
+
144
+ 步骤:
145
+ 1. 按 markdown 标题分 section
146
+ 2. 每个 section 用滑动窗口切 parent (2000t, overlap 200)
147
+ 3. 每个 parent 内用滑动窗口切 child (512t, overlap 64)
148
+ 4. 按 page 编号 (按 parent 在 markdown 中的字符位置近似)
149
+ """
150
+ md = parsed.markdown
151
+ sections = _split_by_headings(md)
152
+ page_text = parsed.pages # 可能为空 (Marker)
153
+
154
+ parents: list[Chunk] = []
155
+ children: list[Chunk] = []
156
+ chunk_idx = 0
157
+
158
+ for heading, body in sections:
159
+ # 先切 parent
160
+ for ptext in _sliding_window(body, target=2000, overlap=200):
161
+ pid = uuid.uuid4().hex
162
+ ptoks = _approx_token_count(ptext)
163
+ # 估算 page_no
164
+ page_no = _estimate_page_no(ptext, parsed) if page_text else None
165
+ parent = Chunk(
166
+ id=pid,
167
+ doc_id=doc_id,
168
+ parent_id=None,
169
+ chunk_index=chunk_idx,
170
+ text=ptext,
171
+ token_count=ptoks,
172
+ page_no=page_no,
173
+ heading=heading,
174
+ )
175
+ parents.append(parent)
176
+ chunk_idx += 1
177
+
178
+ # 切 child
179
+ for ctext in _sliding_window(ptext, target=settings.chunk_size, overlap=settings.chunk_overlap):
180
+ cid = uuid.uuid4().hex
181
+ ctoks = _approx_token_count(ctext)
182
+ child = Chunk(
183
+ id=cid,
184
+ doc_id=doc_id,
185
+ parent_id=pid,
186
+ chunk_index=chunk_idx,
187
+ text=ctext,
188
+ token_count=ctoks,
189
+ page_no=page_no,
190
+ heading=heading,
191
+ )
192
+ children.append(child)
193
+ chunk_idx += 1
194
+
195
+ return ChunkingResult(parents=parents, children=children)
196
+
197
+
198
+ def _estimate_page_no(snippet: str, parsed: ParsedDocument) -> int | None:
199
+ """在 parsed.pages 里找包含 snippet 片段的页. 简单字符串包含."""
200
+ if not parsed.pages:
201
+ return None
202
+ # 取 snippet 前 50 字符作锚
203
+ anchor = snippet[:50].strip()
204
+ if not anchor:
205
+ return None
206
+ for p in parsed.pages:
207
+ if anchor in p.text:
208
+ return p.page_no
209
+ return None
210
+
211
+
212
+ # ========== 上下文预置 (Anthropic-style) ==========
213
+ _CONTEXT_PROMPT_TEMPLATE = (
214
+ "你是一名文档分块上下文标注助手。给定一段来自文档的 chunk (用 <chunk> 包裹) "
215
+ "以及文档标题, 请用 1-2 句中文描述该 chunk 在整篇文档中的上下文, 帮助后续检索时理解其含义。\n\n"
216
+ "要求:\n"
217
+ "- 简洁, 不超过 80 字\n"
218
+ "- 包含: 这是什么类型的内容 (定义 / 例子 / 数据 / 结论 / 步骤等), 在文档哪个章节\n"
219
+ "- 不要重复 chunk 原内容\n"
220
+ "- 仅输出描述本身, 不要加任何前缀\n\n"
221
+ "文档标题: {doc_title}\n"
222
+ "所属章节: {heading}\n"
223
+ "<chunk>\n{chunk}\n</chunk>\n\n"
224
+ "上下文描述:"
225
+ )
226
+
227
+
228
+ async def contextualize_chunks(
229
+ chunks: list[Chunk],
230
+ *,
231
+ doc_title: str = "未知文档",
232
+ max_concurrency: int = 4,
233
+ ) -> list[Chunk]:
234
+ """为每个 chunk 生成 context_prefix. Anthropic-style.
235
+
236
+ 优化:
237
+ - 并发限流 (max_concurrency), 不爆 LLM 限流
238
+ - 失败容错: 单条失败不中断, 留 prefix=None
239
+ """
240
+ if not settings.contextual_retrieval or not chunks:
241
+ return chunks
242
+
243
+ try:
244
+ from app.llm.factory import get_llm
245
+ except Exception as e: # noqa: BLE001
246
+ logger.warning("LLM not available for contextual retrieval: %s", e)
247
+ return chunks
248
+
249
+ sem = asyncio.Semaphore(max_concurrency)
250
+
251
+ async def _one(c: Chunk) -> None:
252
+ if c.context_prefix:
253
+ return
254
+ async with sem:
255
+ try:
256
+ llm = get_llm()
257
+ prompt = _CONTEXT_PROMPT_TEMPLATE.format(
258
+ doc_title=doc_title,
259
+ heading=c.heading or "无",
260
+ chunk=c.text[:1200], # 限长, 防 LLM token 爆
261
+ )
262
+ resp = await llm.chat(
263
+ messages=[
264
+ LLMMessage(role="system", content="你是上下文标注助手。"),
265
+ LLMMessage(role="user", content=prompt),
266
+ ],
267
+ temperature=0.2,
268
+ max_tokens=160,
269
+ )
270
+ prefix = (resp.content or "").strip().strip('"').strip("'")
271
+ if len(prefix) > 200:
272
+ prefix = prefix[:200]
273
+ c.context_prefix = prefix or None
274
+ except Exception as e: # noqa: BLE001
275
+ logger.warning("Contextual retrieval failed for chunk %s: %s", c.id[:8], e)
276
+
277
+ await asyncio.gather(*[_one(c) for c in chunks])
278
+ n_ok = sum(1 for c in chunks if c.context_prefix)
279
+ logger.info("Contextualized %d/%d chunks", n_ok, len(chunks))
280
+ return chunks
281
+
282
+
283
+ def chunk_to_embed_text(c: Chunk) -> str:
284
+ """向量化用的文本: context_prefix + heading + body."""
285
+ parts: list[str] = []
286
+ if c.context_prefix:
287
+ parts.append(f"[Context: {c.context_prefix}]")
288
+ if c.heading:
289
+ parts.append(f"[Section: {c.heading}]")
290
+ parts.append(c.text)
291
+ return "\n".join(parts)