Coverage for src/qdrant_loader/core/text_processing/semantic_analyzer.py: 94%

230 statements  

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

1"""Semantic analysis module for text processing.""" 

2 

3from __future__ import annotations 

4 

5import hashlib 

6import logging 

7import threading 

8from dataclasses import dataclass 

9from typing import TYPE_CHECKING, Any 

10 

11from gensim import corpora 

12from gensim.models import LdaModel 

13from gensim.parsing.preprocessing import preprocess_string 

14from qdrant_loader.core.text_processing import spacy_model_cache 

15 

16if TYPE_CHECKING: 

17 from spacy.tokens import Doc 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22def is_meaningful_text(text: str) -> bool: 

23 """Check if text contains meaningful content (letters or digits). 

24 

25 Returns False for text that only contains: 

26 - Punctuation marks: ., #, @, |, -, _, etc. 

27 - Whitespace characters 

28 - Special symbols without semantic meaning (---, ..., |||, etc.) 

29 

30 """ 

31 # Check if text contains at least one alphanumeric character 

32 return any(c.isalnum() for c in text) 

33 

34 

35@dataclass 

36class SemanticAnalysisResult: 

37 """Container for semantic analysis results.""" 

38 

39 entities: list[dict[str, Any]] 

40 pos_tags: list[dict[str, Any]] 

41 dependencies: list[dict[str, Any]] 

42 topics: list[dict[str, Any]] 

43 key_phrases: list[str] 

44 document_similarity: dict[str, float] 

45 

46 

47class SemanticAnalyzer: 

48 """Advanced semantic analysis for text processing.""" 

49 

50 def __init__( 

51 self, 

52 spacy_model: str = "en_core_web_md", 

53 num_topics: int = 5, 

54 passes: int = 10, 

55 min_topic_freq: int = 2, 

56 ): 

57 """Initialize the semantic analyzer. 

58 

59 Args: 

60 spacy_model: Name of the spaCy model to use 

61 num_topics: Number of topics for LDA 

62 passes: Number of passes for LDA training 

63 min_topic_freq: Minimum frequency for topic terms 

64 """ 

65 self.logger = logging.getLogger(__name__) 

66 

67 # Initialize spaCy. Cached and shared across instances -- a 

68 # SemanticAnalyzer is constructed fresh per document (via 

69 # ChunkProcessor), and spacy.load() is too expensive to repeat for 

70 # every one of them. 

71 def _load_nlp(): 

72 import spacy 

73 from spacy.cli.download import download as spacy_download 

74 

75 try: 

76 nlp = spacy.load(spacy_model) 

77 except OSError: 

78 self.logger.info(f"Downloading spaCy model {spacy_model}...") 

79 spacy_download(spacy_model) 

80 nlp = spacy.load(spacy_model) 

81 return nlp 

82 

83 self.nlp = spacy_model_cache.get_or_load( 

84 ("semantic_analyzer", spacy_model), _load_nlp 

85 ) 

86 

87 # Initialize LDA parameters 

88 self.num_topics = num_topics 

89 self.passes = passes 

90 self.min_topic_freq = min_topic_freq 

91 

92 # Initialize LDA model 

93 self.lda_model = None 

94 self.dictionary = None 

95 

96 # Front-loaded (per-document) topic model: trained once over all of a 

97 # document's chunks via fit_topic_model(), then inferred per chunk. Until it 

98 # is fitted, _extract_topics falls back to the legacy single-chunk path. 

99 self._topic_model_fitted = False 

100 # A corpus smaller than this trains a degenerate model, so we skip fitting 

101 # and let those chunks use the per-chunk fallback instead. 

102 self._min_topic_corpus_docs = 3 

103 # How many of a chunk's most-probable topics to surface, and how many terms 

104 # per topic — matched to the legacy producer's output shape. 

105 self._topics_per_chunk = 3 

106 self._topic_top_n = 10 

107 

108 # Cache for processed documents 

109 self._doc_cache: dict = {} 

110 self._doc_cache_lock = threading.Lock() 

111 

112 def _build_cache_key( 

113 self, text: str, doc_id: str | None, include_enhanced: bool 

114 ) -> tuple[str, bool, str] | None: 

115 """Build a cache key that includes a content fingerprint. 

116 

117 Including a fingerprint prevents stale cache hits when the same doc_id 

118 is reused with different content. 

119 """ 

120 if not doc_id: 

121 return None 

122 

123 text_fingerprint = hashlib.sha256(text.encode("utf-8")).hexdigest() 

124 return (doc_id, include_enhanced, text_fingerprint) 

125 

126 def analyze_text( 

127 self, 

128 text: str, 

129 doc_id: str | None = None, 

130 include_enhanced: bool = False, 

131 ) -> SemanticAnalysisResult: 

132 """Perform comprehensive semantic analysis on text. 

133 

134 Args: 

135 text: Text to analyze 

136 doc_id: Optional document ID for caching 

137 include_enhanced: Whether to compute enhanced NLP fields 

138 (pos_tags, dependencies, document_similarity) 

139 

140 Returns: 

141 SemanticAnalysisResult containing all analysis results 

142 """ 

143 # Check cache 

144 cache_key = self._build_cache_key(text, doc_id, include_enhanced) 

145 

146 # Protected read 

147 with self._doc_cache_lock: 

148 cached = self._doc_cache.get(cache_key) if cache_key else None 

149 

150 if cached is not None: 

151 if include_enhanced: 

152 # Compute similarity OUTSIDE the lock (can be slow) 

153 doc_similarity = self._calculate_document_similarity( 

154 text, doc_id=doc_id 

155 ) 

156 refreshed = SemanticAnalysisResult( 

157 entities=cached.entities, 

158 pos_tags=cached.pos_tags, 

159 dependencies=cached.dependencies, 

160 topics=cached.topics, 

161 key_phrases=cached.key_phrases, 

162 document_similarity=doc_similarity, 

163 ) 

164 # Protected write-back 

165 with self._doc_cache_lock: 

166 self._doc_cache[cache_key] = refreshed 

167 return refreshed 

168 return cached 

169 

170 # Process with spaCy 

171 doc = self.nlp(text) 

172 

173 # Extract entities with linking 

174 entities = self._extract_entities(doc) 

175 

176 if include_enhanced: 

177 # Get part-of-speech tags 

178 pos_tags = self._get_pos_tags(doc) 

179 

180 # Get dependency parse 

181 dependencies = self._get_dependencies(doc) 

182 else: 

183 pos_tags = [] 

184 dependencies = [] 

185 

186 # Extract topics 

187 topics = self._extract_topics(text) 

188 

189 # Extract key phrases 

190 key_phrases = self._extract_key_phrases(doc) 

191 

192 # Calculate document similarity 

193 doc_similarity = ( 

194 self._calculate_document_similarity(text, doc_id=doc_id) 

195 if include_enhanced 

196 else {} 

197 ) 

198 

199 # Create result 

200 result = SemanticAnalysisResult( 

201 entities=entities, 

202 pos_tags=pos_tags, 

203 dependencies=dependencies, 

204 topics=topics, 

205 key_phrases=key_phrases, 

206 document_similarity=doc_similarity, 

207 ) 

208 

209 # Protected write 

210 if cache_key: 

211 with self._doc_cache_lock: 

212 self._doc_cache[cache_key] = result 

213 

214 return result 

215 

216 def _extract_entities(self, doc: Doc) -> list[dict[str, Any]]: 

217 """Extract named entities with linking, filtering garbage entities. 

218 

219 Filters out entities that: 

220 - Only contain punctuation/symbols (., #, |, etc.) 

221 - Don't have any alphanumeric characters 

222 - Are just whitespace 

223 

224 Args: 

225 doc: spaCy document 

226 

227 Returns: 

228 List of entity dictionaries with linking information 

229 """ 

230 entities = [] 

231 for ent in doc.ents: 

232 # Filter entities that only contain punctuation/symbols 

233 if not is_meaningful_text(ent.text): 

234 continue 

235 

236 # Get entity context 

237 start_sent = ent.sent.start 

238 end_sent = ent.sent.end 

239 context = doc[start_sent:end_sent].text 

240 

241 # Get entity description 

242 description = self.nlp.vocab.strings[ent.label_] 

243 

244 # Get related entities (also filter meaningless ones) 

245 related = [] 

246 for token in ent.sent: 

247 if token.ent_type_ and token.text != ent.text: 

248 # Only add related entities with meaningful text 

249 if is_meaningful_text(token.text): 

250 related.append( 

251 { 

252 "text": token.text, 

253 "type": token.ent_type_, 

254 "relation": token.dep_, 

255 } 

256 ) 

257 

258 entities.append( 

259 { 

260 "text": ent.text, 

261 "label": ent.label_, 

262 "start": ent.start_char, 

263 "end": ent.end_char, 

264 "description": description, 

265 "context": context, 

266 "related_entities": related, 

267 } 

268 ) 

269 

270 return entities 

271 

272 def _get_pos_tags(self, doc: Doc) -> list[dict[str, Any]]: 

273 """Get part-of-speech tags with detailed information, filtering noise tokens. 

274 

275 Filters out multiple types of noise: 

276 - Whitespace tokens (is_space=True) 

277 - Punctuation tokens (is_punct=True) 

278 - Symbol-only tokens without alphanumeric content (e.g., ---, ..., |||) 

279 

280 This is especially important for Excel tables and structured data. 

281 

282 Args: 

283 doc: spaCy document 

284 

285 Returns: 

286 List of POS tag dictionaries (excluding spaces, punctuation, and symbols) 

287 """ 

288 pos_tags = [] 

289 for token in doc: 

290 # Skip whitespace and punctuation - they pollute metadata 

291 if token.is_space or token.is_punct: 

292 continue 

293 

294 # Also skip tokens with no meaningful content (e.g., ---, ...) 

295 # This catches edge cases where spaCy doesn't mark as punct 

296 if not is_meaningful_text(token.text): 

297 continue 

298 

299 pos_tags.append( 

300 { 

301 "text": token.text, 

302 "pos": token.pos_, 

303 "tag": token.tag_, 

304 "lemma": token.lemma_, 

305 "is_stop": token.is_stop, 

306 } 

307 ) 

308 return pos_tags 

309 

310 def _get_dependencies(self, doc: Doc) -> list[dict[str, Any]]: 

311 """Get dependency parse information with filtering. 

312 

313 Filters out: 

314 - Whitespace tokens (is_space=True) 

315 - Punctuation tokens (is_punct=True) 

316 - Symbol-only tokens without alphanumeric content 

317 - Children that are punctuation or meaningless symbols 

318 

319 Args: 

320 doc: spaCy document 

321 

322 Returns: 

323 List of dependency dictionaries (excluding noise tokens) 

324 """ 

325 dependencies = [] 

326 for token in doc: 

327 # Skip whitespace and punctuation tokens 

328 if token.is_space or token.is_punct: 

329 continue 

330 

331 # Skip tokens with no meaningful content (e.g., ---, ...) 

332 if not is_meaningful_text(token.text): 

333 continue 

334 

335 # Filter children to only include meaningful tokens 

336 meaningful_children = [ 

337 child.text 

338 for child in token.children 

339 if not child.is_space 

340 and not child.is_punct 

341 and is_meaningful_text(child.text) 

342 ] 

343 

344 dependencies.append( 

345 { 

346 "text": token.text, 

347 "dep": token.dep_, 

348 "head": token.head.text, 

349 "head_pos": token.head.pos_, 

350 "children": meaningful_children, 

351 } 

352 ) 

353 return dependencies 

354 

355 def fit_topic_model(self, texts: list[str]) -> None: 

356 """Train one document-level LDA over all of a document's chunk texts. 

357 

358 Front-loading the model once lets :meth:`_extract_topics` *infer* each 

359 chunk's topics against a shared corpus model, instead of training a 

360 degenerate single-document LDA per chunk. When the corpus is too small to be 

361 meaningful the model is left unfitted, so those chunks fall back to the 

362 legacy per-chunk path. 

363 

364 Args: 

365 texts: The contents of every chunk produced for one document. 

366 """ 

367 try: 

368 processed = [preprocess_string(text) for text in texts] 

369 processed = [tokens for tokens in processed if len(tokens) >= 5] 

370 

371 if len(processed) < self._min_topic_corpus_docs: 

372 self.logger.debug( 

373 "Corpus too small to fit a document-level topic model " 

374 f"({len(processed)} usable chunks); using per-chunk fallback" 

375 ) 

376 self.dictionary = None 

377 self.lda_model = None 

378 self._topic_model_fitted = False 

379 return 

380 

381 dictionary = corpora.Dictionary(processed) 

382 corpus = [dictionary.doc2bow(tokens) for tokens in processed] 

383 self.dictionary = dictionary 

384 self.lda_model = LdaModel( 

385 corpus, 

386 num_topics=min(self.num_topics, len(processed)), 

387 passes=self.passes, 

388 id2word=dictionary, 

389 random_state=42, # For reproducibility 

390 alpha=0.1, # Fixed positive value for document-topic density 

391 eta=0.01, # Fixed positive value for topic-word density 

392 ) 

393 self._topic_model_fitted = True 

394 except Exception as e: 

395 self.logger.warning( 

396 f"Topic model fit failed; using per-chunk fallback: {e}", 

397 exc_info=True, 

398 ) 

399 self.dictionary = None 

400 self.lda_model = None 

401 self._topic_model_fitted = False 

402 

403 def _extract_topics(self, text: str) -> list[dict[str, Any]]: 

404 """Extract topics for one chunk. 

405 

406 Uses the front-loaded document-level model when one has been fitted (see 

407 :meth:`fit_topic_model`), inferring the chunk's dominant topics against it 

408 without retraining. Otherwise falls back to the legacy per-chunk model. 

409 

410 Args: 

411 text: Text to analyze 

412 

413 Returns: 

414 List of topic dictionaries: 

415 ``{"id", "terms": [{"term", "weight"}], "coherence"}``. 

416 """ 

417 try: 

418 # Preprocess text 

419 processed_text = preprocess_string(text) 

420 

421 # Skip topic extraction for very short texts 

422 if len(processed_text) < 5: 

423 self.logger.debug("Text too short for topic extraction") 

424 return [ 

425 { 

426 "id": 0, 

427 "terms": [{"term": "general", "weight": 1.0}], 

428 "coherence": 0.5, 

429 } 

430 ] 

431 

432 # A document-level model was front-loaded: infer this chunk's topics 

433 # against it, without retraining or mutating the shared model. 

434 if ( 

435 self._topic_model_fitted 

436 and self.lda_model is not None 

437 and self.dictionary is not None 

438 ): 

439 return self._infer_chunk_topics(processed_text) 

440 

441 # Unfitted (the markdown path, or a corpus too small to fit): legacy 

442 # per-chunk model. Degenerate by construction, but retained so behavior 

443 # for unfitted callers is unchanged. 

444 temp_dictionary = corpora.Dictionary([processed_text]) 

445 corpus = [temp_dictionary.doc2bow(processed_text)] 

446 

447 # Create a fresh LDA model for this specific text 

448 current_lda_model = LdaModel( 

449 corpus, 

450 num_topics=min( 

451 self.num_topics, len(processed_text) // 2 

452 ), # Ensure reasonable topic count 

453 passes=self.passes, 

454 id2word=temp_dictionary, 

455 random_state=42, # For reproducibility 

456 alpha=0.1, # Fixed positive value for document-topic density 

457 eta=0.01, # Fixed positive value for topic-word density 

458 ) 

459 

460 # Get topics 

461 topics = [] 

462 for topic_id, topic in current_lda_model.print_topics(): 

463 # Parse topic terms 

464 terms = [] 

465 for term in topic.split("+"): 

466 try: 

467 weight, word = term.strip().split("*") 

468 terms.append({"term": word.strip('"'), "weight": float(weight)}) 

469 except ValueError: 

470 # Skip malformed terms 

471 continue 

472 

473 topics.append( 

474 { 

475 "id": topic_id, 

476 "terms": terms, 

477 "coherence": self._calculate_topic_coherence(terms), 

478 } 

479 ) 

480 

481 return ( 

482 topics 

483 if topics 

484 else [ 

485 { 

486 "id": 0, 

487 "terms": [{"term": "general", "weight": 1.0}], 

488 "coherence": 0.5, 

489 } 

490 ] 

491 ) 

492 

493 except Exception as e: 

494 self.logger.warning(f"Topic extraction failed: {e}", exc_info=True) 

495 # Return fallback topic 

496 return [ 

497 { 

498 "id": 0, 

499 "terms": [{"term": "general", "weight": 1.0}], 

500 "coherence": 0.5, 

501 } 

502 ] 

503 

504 def _infer_chunk_topics(self, processed_text: list[str]) -> list[dict[str, Any]]: 

505 """Infer a chunk's dominant topics against the front-loaded model. 

506 

507 Args: 

508 processed_text: The chunk's preprocessed tokens. 

509 

510 Returns: 

511 The chunk's most-probable topics, in the legacy producer's shape. An 

512 empty bag-of-words (all tokens out of the trained vocabulary) still 

513 yields the model's topics ranked by the prior distribution. 

514 """ 

515 bow = self.dictionary.doc2bow(processed_text) 

516 distribution = self.lda_model.get_document_topics(bow, minimum_probability=0.0) 

517 ranked = sorted(distribution, key=lambda pair: pair[1], reverse=True) 

518 

519 topics: list[dict[str, Any]] = [] 

520 for topic_id, _probability in ranked[: self._topics_per_chunk]: 

521 terms = [ 

522 {"term": word, "weight": float(weight)} 

523 for word, weight in self.lda_model.show_topic( 

524 topic_id, topn=self._topic_top_n 

525 ) 

526 ] 

527 topics.append( 

528 { 

529 "id": int(topic_id), 

530 "terms": terms, 

531 "coherence": self._calculate_topic_coherence(terms), 

532 } 

533 ) 

534 

535 return ( 

536 topics 

537 if topics 

538 else [ 

539 { 

540 "id": 0, 

541 "terms": [{"term": "general", "weight": 1.0}], 

542 "coherence": 0.5, 

543 } 

544 ] 

545 ) 

546 

547 def _extract_key_phrases(self, doc: Doc) -> list[str]: 

548 """Extract key phrases from text. 

549 

550 Args: 

551 doc: spaCy document 

552 

553 Returns: 

554 List of key phrases 

555 """ 

556 key_phrases = [] 

557 

558 # Extract noun phrases 

559 for chunk in doc.noun_chunks: 

560 if len(chunk.text.split()) >= 2: # Only multi-word phrases 

561 key_phrases.append(chunk.text) 

562 

563 # Extract named entities 

564 for ent in doc.ents: 

565 if ent.label_ in ["ORG", "PRODUCT", "WORK_OF_ART", "LAW"]: 

566 key_phrases.append(ent.text) 

567 

568 return list(set(key_phrases)) # Remove duplicates 

569 

570 def _calculate_document_similarity( 

571 self, text: str, doc_id: str | None = None 

572 ) -> dict[str, float]: 

573 """Calculate similarity with other processed documents. 

574 

575 Args: 

576 text: Text to compare 

577 doc_id: Optional current document ID to exclude from results 

578 

579 Returns: 

580 Dictionary of document similarities 

581 """ 

582 similarities = {} 

583 skipped_ids = {doc_id} if doc_id else set() 

584 

585 doc = self.nlp(text) 

586 

587 # Check if the model has word vectors 

588 has_vectors = self.nlp.vocab.vectors_length > 0 

589 

590 with self._doc_cache_lock: 

591 cached_items = list(self._doc_cache.items()) 

592 

593 for cache_key, cached_result in cached_items: 

594 cached_doc_id = cache_key[0] if isinstance(cache_key, tuple) else cache_key 

595 if cached_doc_id is None or cached_doc_id in skipped_ids: 

596 continue 

597 

598 # Check if cached_result has entities and the first entity has context 

599 if not cached_result.entities or not cached_result.entities[0].get( 

600 "context" 

601 ): 

602 continue 

603 

604 cached_doc = self.nlp(cached_result.entities[0]["context"]) 

605 

606 if has_vectors: 

607 # Use spaCy's built-in similarity which uses word vectors 

608 similarity = doc.similarity(cached_doc) 

609 else: 

610 # Use alternative similarity calculation for models without word vectors 

611 # This avoids the spaCy warning about missing word vectors 

612 similarity = self._calculate_alternative_similarity(doc, cached_doc) 

613 

614 similarities[cached_doc_id] = float(similarity) 

615 skipped_ids.add(cached_doc_id) 

616 

617 return similarities 

618 

619 def _calculate_alternative_similarity(self, doc1: Doc, doc2: Doc) -> float: 

620 """Calculate similarity for models without word vectors. 

621 

622 Uses token overlap and shared entities as similarity metrics. 

623 

624 Args: 

625 doc1: First document 

626 doc2: Second document 

627 

628 Returns: 

629 Similarity score between 0 and 1 

630 """ 

631 # Extract lemmatized tokens (excluding stop words and punctuation) 

632 tokens1 = { 

633 token.lemma_.lower() 

634 for token in doc1 

635 if not token.is_stop and not token.is_punct and token.is_alpha 

636 } 

637 tokens2 = { 

638 token.lemma_.lower() 

639 for token in doc2 

640 if not token.is_stop and not token.is_punct and token.is_alpha 

641 } 

642 

643 # Calculate token overlap (Jaccard similarity) 

644 if not tokens1 and not tokens2: 

645 return 1.0 # Both empty 

646 if not tokens1 or not tokens2: 

647 return 0.0 # One empty 

648 

649 intersection = len(tokens1.intersection(tokens2)) 

650 union = len(tokens1.union(tokens2)) 

651 token_similarity = intersection / union if union > 0 else 0.0 

652 

653 # Extract named entities 

654 entities1 = {ent.text.lower() for ent in doc1.ents} 

655 entities2 = {ent.text.lower() for ent in doc2.ents} 

656 

657 # Calculate entity overlap 

658 entity_similarity = 0.0 

659 if entities1 or entities2: 

660 entity_intersection = len(entities1.intersection(entities2)) 

661 entity_union = len(entities1.union(entities2)) 

662 entity_similarity = ( 

663 entity_intersection / entity_union if entity_union > 0 else 0.0 

664 ) 

665 

666 # Combine token and entity similarities (weighted average) 

667 # Token similarity gets more weight as it's more comprehensive 

668 combined_similarity = 0.7 * token_similarity + 0.3 * entity_similarity 

669 

670 return combined_similarity 

671 

672 def _calculate_topic_coherence(self, terms: list[dict[str, Any]]) -> float: 

673 """Calculate topic coherence score. 

674 

675 Args: 

676 terms: List of topic terms with weights 

677 

678 Returns: 

679 Coherence score between 0 and 1 

680 """ 

681 # Simple coherence based on term weights 

682 weights = [term["weight"] for term in terms] 

683 return sum(weights) / len(weights) if weights else 0.0 

684 

685 def clear_cache(self): 

686 """Clear instance-owned caches and model state.""" 

687 with self._doc_cache_lock: 

688 self._doc_cache.clear() 

689 

690 # Reset topic model state for this analyzer instance. 

691 self._topic_model_fitted = False 

692 self.lda_model = None 

693 self.dictionary = None 

694 

695 logger.debug("Semantic analyzer instance caches cleared") 

696 

697 def shutdown(self): 

698 """Shutdown the semantic analyzer and release all resources. 

699 

700 This method should be called when the analyzer is no longer needed 

701 to ensure proper cleanup of all resources. 

702 """ 

703 self.clear_cache() 

704 

705 if hasattr(self, "nlp"): 

706 del self.nlp 

707 

708 logger.debug("Semantic analyzer shutdown completed")