Coverage for src/qdrant_loader/core/chunking/strategy/base_strategy.py: 96%

166 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1"""Base abstract class for chunking strategies.""" 

2 

3from abc import ABC, abstractmethod 

4from typing import TYPE_CHECKING 

5 

6import tiktoken 

7 

8from qdrant_loader.core.document import Document 

9from qdrant_loader.core.text_processing.text_processor import TextProcessor 

10from qdrant_loader.utils.logging import LoggingConfig 

11 

12if TYPE_CHECKING: 

13 from qdrant_loader.config import Settings 

14 

15logger = LoggingConfig.get_logger(__name__) 

16 

17 

18class BaseChunkingStrategy(ABC): 

19 """Base abstract class for all chunking strategies. 

20 

21 This class defines the interface that all chunking strategies must implement. 

22 Each strategy should provide its own implementation of how to split documents 

23 into chunks while preserving their semantic meaning and structure. 

24 """ 

25 

26 # Whether this strategy uses the base TextProcessor (spaCy NER + POS) via 

27 # _process_text/_apply_nlp. Strategies that do their own enrichment (docling) 

28 # set this False so __init__ does not load a spaCy model they never use. 

29 _uses_base_text_processor: bool = True 

30 

31 def __init__( 

32 self, 

33 settings: "Settings", 

34 chunk_size: int | None = None, 

35 chunk_overlap: int | None = None, 

36 ): 

37 """Initialize the chunking strategy. 

38 

39 Args: 

40 settings: Application settings containing configuration for the strategy 

41 chunk_size: Maximum number of tokens per chunk (optional, defaults to settings value) 

42 chunk_overlap: Number of tokens to overlap between chunks (optional, defaults to settings value) 

43 """ 

44 self.settings = settings 

45 self.logger = LoggingConfig.get_logger(self.__class__.__name__) 

46 

47 # Initialize token-based chunking parameters 

48 self.chunk_size = chunk_size or settings.global_config.chunking.chunk_size 

49 self.chunk_overlap = ( 

50 chunk_overlap or settings.global_config.chunking.chunk_overlap 

51 ) 

52 self.tokenizer = settings.global_config.embedding.tokenizer 

53 

54 # Initialize tokenizer based on configuration 

55 if self.tokenizer == "none": 

56 self.encoding = None 

57 else: 

58 try: 

59 self.encoding = tiktoken.get_encoding(self.tokenizer) 

60 except Exception as e: 

61 logger.warning( 

62 "Failed to initialize tokenizer, falling back to simple character counting", 

63 error=str(e), 

64 tokenizer=self.tokenizer, 

65 ) 

66 self.encoding = None 

67 

68 if self.chunk_overlap >= self.chunk_size: 

69 raise ValueError("Chunk overlap must be less than chunk size") 

70 

71 # Master switch for NLP metadata extraction across all strategies. 

72 self._semantic_analysis_enabled = bool( 

73 getattr(settings.global_config.chunking, "enable_semantic_analysis", True) 

74 ) 

75 

76 # Initialize text processor only when NLP metadata extraction is enabled. 

77 self.text_processor = ( 

78 TextProcessor(settings) 

79 if self._semantic_analysis_enabled and self._uses_base_text_processor 

80 else None 

81 ) 

82 

83 def _count_tokens(self, text: str) -> int: 

84 """Count the number of tokens in a text string.""" 

85 if self.encoding is None: 

86 # Fallback to character count if no tokenizer is available 

87 return len(text) 

88 return len(self.encoding.encode(text)) 

89 

90 def _process_text(self, text: str) -> dict: 

91 """Process text using the text processor. 

92 

93 Args: 

94 text: Text to process 

95 

96 Returns: 

97 dict: Processed text features 

98 """ 

99 if self.text_processor is None: 

100 return {"tokens": [], "entities": [], "pos_tags": [], "chunks": []} 

101 

102 return self.text_processor.process_text(text) 

103 

104 def _should_apply_nlp( 

105 self, content: str, file_path: str = "", content_type: str = "" 

106 ) -> bool: 

107 """Determine if NLP processing should be applied to content. 

108 

109 Args: 

110 content: The content to analyze 

111 file_path: File path for extension-based detection 

112 content_type: Content type if available 

113 

114 Returns: 

115 bool: True if NLP processing would be valuable 

116 """ 

117 # Skip NLP for very large content (performance) 

118 if len(content) > 20000: # 20KB limit 

119 return False 

120 

121 # Get file extension 

122 ext = "" 

123 if file_path and "." in file_path: 

124 ext = f".{file_path.lower().split('.')[-1]}" 

125 

126 # Skip NLP for code files (except comments/docstrings) 

127 code_extensions = { 

128 ".py", 

129 ".pyx", 

130 ".pyi", 

131 ".java", 

132 ".js", 

133 ".jsx", 

134 ".mjs", 

135 ".ts", 

136 ".tsx", 

137 ".go", 

138 ".rs", 

139 ".cpp", 

140 ".cc", 

141 ".cxx", 

142 ".c", 

143 ".h", 

144 ".cs", 

145 ".php", 

146 ".rb", 

147 ".kt", 

148 ".scala", 

149 ".swift", 

150 ".dart", 

151 ".sh", 

152 ".bash", 

153 ".zsh", 

154 ".sql", 

155 ".r", 

156 ".m", 

157 ".pl", 

158 ".lua", 

159 ".vim", 

160 ".asm", 

161 } 

162 if ext in code_extensions: 

163 return False 

164 

165 # Skip NLP for structured data files 

166 structured_extensions = { 

167 ".json", 

168 ".xml", 

169 ".yaml", 

170 ".yml", 

171 ".toml", 

172 ".ini", 

173 ".cfg", 

174 ".conf", 

175 ".csv", 

176 ".tsv", 

177 ".log", 

178 ".properties", 

179 } 

180 if ext in structured_extensions: 

181 return False 

182 

183 # Skip NLP for binary/encoded content 

184 binary_extensions = { 

185 ".pdf", 

186 ".docx", 

187 ".xls", 

188 ".xlsx", 

189 ".pptx", 

190 ".zip", 

191 ".tar", 

192 ".gz", 

193 ".bz2", 

194 ".7z", 

195 ".rar", 

196 ".jpg", 

197 ".jpeg", 

198 ".png", 

199 ".gif", 

200 ".bmp", 

201 ".svg", 

202 ".mp3", 

203 ".mp4", 

204 ".avi", 

205 ".mov", 

206 ".wav", 

207 ".flac", 

208 } 

209 if ext in binary_extensions: 

210 return False 

211 

212 # Apply NLP for documentation and text files 

213 text_extensions = {".md", ".txt", ".rst", ".adoc", ".tex", ".rtf"} 

214 if ext in text_extensions: 

215 return True 

216 

217 # Apply NLP for HTML content (but be selective) 

218 if ext in {".html", ".htm"} or content_type == "html": 

219 return True 

220 

221 # For unknown extensions, check content characteristics 

222 if not ext: 

223 # Skip if content looks like code (high ratio of special characters) 

224 special_chars = sum(1 for c in content if c in "{}[]();,=<>!&|+-*/%^~`") 

225 if len(content) > 0 and special_chars / len(content) > 0.15: 

226 return False 

227 

228 # Skip if content looks like structured data 

229 if content.strip().startswith(("{", "[", "<")) or "=" in content[:100]: 

230 return False 

231 

232 # Default to applying NLP for text-like content 

233 return True 

234 

235 def _extract_nlp_worthy_content(self, content: str, element_type: str = "") -> str: 

236 """Extract only the parts of content that are worth NLP processing. 

237 

238 For code files, this extracts comments and docstrings. 

239 For other files, returns the full content. 

240 

241 Args: 

242 content: The content to process 

243 element_type: Type of code element (if applicable) 

244 

245 Returns: 

246 str: Content suitable for NLP processing 

247 """ 

248 # For code elements, only process comments and docstrings 

249 if element_type in ["comment", "docstring"]: 

250 return content 

251 

252 # For other code elements, extract comments 

253 if element_type in ["function", "method", "class", "module"]: 

254 return self._extract_comments_and_docstrings(content) 

255 

256 # For non-code content, return as-is 

257 return content 

258 

259 def _extract_comments_and_docstrings(self, code_content: str) -> str: 

260 """Extract comments and docstrings from code content. 

261 

262 Args: 

263 code_content: Code content to extract from 

264 

265 Returns: 

266 str: Extracted comments and docstrings 

267 """ 

268 extracted_text = [] 

269 lines = code_content.split("\n") 

270 

271 in_multiline_comment = False 

272 in_docstring = False 

273 docstring_delimiter = None 

274 

275 for line in lines: 

276 stripped = line.strip() 

277 

278 # Python/Shell style comments 

279 if stripped.startswith("#"): 

280 comment = stripped[1:].strip() 

281 if comment: # Skip empty comments 

282 extracted_text.append(comment) 

283 

284 # C/Java/JS style single line comments 

285 elif "//" in stripped: 

286 comment_start = stripped.find("//") 

287 comment = stripped[comment_start + 2 :].strip() 

288 if comment: 

289 extracted_text.append(comment) 

290 

291 # C/Java/JS style multiline comments 

292 elif "/*" in stripped and not in_multiline_comment: 

293 in_multiline_comment = True 

294 comment_start = stripped.find("/*") 

295 comment = stripped[comment_start + 2 :] 

296 if "*/" in comment: 

297 comment = comment[: comment.find("*/")] 

298 in_multiline_comment = False 

299 comment = comment.strip() 

300 if comment: 

301 extracted_text.append(comment) 

302 

303 elif in_multiline_comment: 

304 if "*/" in stripped: 

305 comment = stripped[: stripped.find("*/")] 

306 in_multiline_comment = False 

307 else: 

308 comment = stripped 

309 comment = comment.strip("* \t") 

310 if comment: 

311 extracted_text.append(comment) 

312 

313 # Python docstrings 

314 elif ('"""' in stripped or "'''" in stripped) and not in_docstring: 

315 for delimiter in ['"""', "'''"]: 

316 if delimiter in stripped: 

317 in_docstring = True 

318 docstring_delimiter = delimiter 

319 start_idx = stripped.find(delimiter) 

320 docstring_content = stripped[start_idx + 3 :] 

321 

322 # Check if docstring ends on same line 

323 if delimiter in docstring_content: 

324 end_idx = docstring_content.find(delimiter) 

325 docstring_text = docstring_content[:end_idx].strip() 

326 if docstring_text: 

327 extracted_text.append(docstring_text) 

328 in_docstring = False 

329 docstring_delimiter = None 

330 else: 

331 if docstring_content.strip(): 

332 extracted_text.append(docstring_content.strip()) 

333 break 

334 

335 elif in_docstring and docstring_delimiter: 

336 if docstring_delimiter in stripped: 

337 end_idx = stripped.find(docstring_delimiter) 

338 docstring_text = stripped[:end_idx].strip() 

339 if docstring_text: 

340 extracted_text.append(docstring_text) 

341 in_docstring = False 

342 docstring_delimiter = None 

343 else: 

344 if stripped: 

345 extracted_text.append(stripped) 

346 

347 return "\n".join(extracted_text) 

348 

349 def _create_chunk_document( 

350 self, 

351 original_doc: Document, 

352 chunk_content: str, 

353 chunk_index: int, 

354 total_chunks: int, 

355 skip_nlp: bool = False, 

356 ) -> Document: 

357 """Create a new document for a chunk with enhanced metadata. 

358 

359 Args: 

360 original_doc: Original document 

361 chunk_content: Content of the chunk 

362 chunk_index: Index of the chunk 

363 total_chunks: Total number of chunks 

364 skip_nlp: Whether to skip expensive NLP processing 

365 

366 Returns: 

367 Document: New document instance for the chunk 

368 """ 

369 # Create enhanced metadata 

370 metadata = original_doc.metadata.copy() 

371 metadata.update( 

372 { 

373 "chunk_index": chunk_index, 

374 "total_chunks": total_chunks, 

375 } 

376 ) 

377 

378 # Smart NLP decision based on content type and characteristics 

379 file_path = original_doc.metadata.get("file_name", "") or original_doc.source 

380 content_type = original_doc.content_type or "" 

381 element_type = metadata.get("element_type", "") 

382 

383 # For converted files, use the converted content type instead of original file extension 

384 conversion_method = metadata.get("conversion_method") 

385 if conversion_method == "markitdown": 

386 # File was converted to markdown, so treat it as markdown for NLP purposes 

387 file_path = "converted.md" # Use .md extension for NLP decision 

388 content_type = "md" 

389 

390 nlp_applicable = self._should_apply_nlp(chunk_content, file_path, content_type) 

391 

392 should_apply_nlp = ( 

393 self._semantic_analysis_enabled 

394 and not skip_nlp 

395 and len(chunk_content) <= 10000 # Size limit 

396 and total_chunks <= 50 # Chunk count limit 

397 and nlp_applicable 

398 ) 

399 

400 if not should_apply_nlp: 

401 # Skip NLP processing 

402 skip_reason = "performance_optimization" 

403 if not self._semantic_analysis_enabled: 

404 skip_reason = "semantic_analysis_disabled" 

405 elif len(chunk_content) > 10000: 

406 skip_reason = "chunk_too_large" 

407 elif total_chunks > 50: 

408 skip_reason = "too_many_chunks" 

409 elif not nlp_applicable: 

410 skip_reason = "content_type_inappropriate" 

411 

412 metadata.update( 

413 { 

414 "entities": [], 

415 "pos_tags": [], 

416 "nlp_skipped": True, 

417 "skip_reason": skip_reason, 

418 } 

419 ) 

420 else: 

421 try: 

422 # For code content, only process comments/docstrings 

423 nlp_content = self._extract_nlp_worthy_content( 

424 chunk_content, element_type 

425 ) 

426 

427 if nlp_content.strip(): 

428 # Process the NLP-worthy content 

429 processed = self._process_text(nlp_content) 

430 metadata.update( 

431 { 

432 "entities": processed["entities"], 

433 "pos_tags": processed["pos_tags"], 

434 "nlp_skipped": False, 

435 "nlp_content_extracted": len(nlp_content) 

436 < len(chunk_content), 

437 "nlp_content_ratio": ( 

438 len(nlp_content) / len(chunk_content) 

439 if chunk_content 

440 else 0 

441 ), 

442 } 

443 ) 

444 else: 

445 # No NLP-worthy content found 

446 metadata.update( 

447 { 

448 "entities": [], 

449 "pos_tags": [], 

450 "nlp_skipped": True, 

451 "skip_reason": "no_nlp_worthy_content", 

452 } 

453 ) 

454 except Exception as e: 

455 self.logger.warning( 

456 f"NLP processing failed for chunk {chunk_index}: {e}" 

457 ) 

458 metadata.update( 

459 { 

460 "entities": [], 

461 "pos_tags": [], 

462 "nlp_skipped": True, 

463 "skip_reason": "nlp_error", 

464 } 

465 ) 

466 

467 return Document( 

468 content=chunk_content, 

469 metadata=metadata, 

470 source=original_doc.source, 

471 source_type=original_doc.source_type, 

472 url=original_doc.url, 

473 title=original_doc.title, 

474 content_type=original_doc.content_type, 

475 ) 

476 

477 @abstractmethod 

478 def chunk_document(self, document: Document) -> list[Document]: 

479 """Split a document into chunks while preserving metadata. 

480 

481 This method should: 

482 1. Split the document content into appropriate chunks 

483 2. Preserve all metadata from the original document 

484 3. Add chunk-specific metadata (e.g., chunk index, total chunks) 

485 4. Return a list of new Document instances 

486 

487 Args: 

488 document: The document to chunk 

489 

490 Returns: 

491 List of chunked documents with preserved metadata 

492 

493 Raises: 

494 NotImplementedError: If the strategy doesn't implement this method 

495 """ 

496 raise NotImplementedError( 

497 "Chunking strategy must implement chunk_document method" 

498 )