Download src/ingestion/semantic_splitter.py from NextGenTech/AutomatedSemanticDiscovery: direct link, hf CLI and curl.
- Browser
- Download file 5.97 kB
-
https://huggingface.co/spaces/NextGenTech/AutomatedSemanticDiscovery/resolve/main/src/ingestion/semantic_splitter.py
- Command line
-
hf download hf://spaces/NextGenTech/AutomatedSemanticDiscovery/src/ingestion/semantic_splitter.py
-
curl -L -o semantic_splitter.py https://huggingface.co/spaces/NextGenTech/AutomatedSemanticDiscovery/resolve/main/src/ingestion/semantic_splitter.py
5.97 kB
| import re | |
| import numpy as np | |
| import nltk | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| from dotenv import load_dotenv | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| # Carico l'ambiente. Su HF Spaces andrà a pescare dai secrets, in locale dal .env | |
| load_dotenv() | |
| class ActivaSemanticSplitter: | |
| def __init__(self, model_name="sentence-transformers/all-MiniLM-L6-v2", batch_size=32): | |
| self.batch_size = batch_size | |
| print("🔄 Inizializzazione HuggingFace Embedding Engine...") | |
| # Scelto MiniLM-L6: per questo prototipo ci serve un modello veloce e leggero in RAM | |
| # che non faccia da collo di bottiglia durante l'ingestion massiva di documenti. | |
| try: | |
| self.embedding_model = HuggingFaceEmbeddings(model_name=model_name) | |
| print("✅ Modello caricato correttamente.") | |
| except Exception as e: | |
| print(f"❌ Errore caricamento modello: {e}") | |
| raise e | |
| # Check preventivo sui tokenizer. | |
| try: | |
| nltk.data.find('tokenizers/punkt') | |
| nltk.data.find('tokenizers/punkt_tab') | |
| except LookupError: | |
| print("⬇️ Download risorse NLTK...") | |
| nltk.download('punkt', quiet=True) | |
| nltk.download('punkt_tab', quiet=True) | |
| def _split_sentences(self, text): | |
| # La pulizia base. Fondamentale per i testi estratti da vecchi OCR o documenti sporchi. | |
| text = text.strip() | |
| try: | |
| # Recupero il tokenizer dell'italiano. Evito sent_tokenize() puro perché è una black box | |
| # e mi serve poter iniettare eccezioni custom per la punteggiatura. | |
| try: | |
| tokenizer = nltk.data.load('tokenizers/punkt/italian.pickle') | |
| except: | |
| # Fallback di sicurezza se il path del pickle salta | |
| from nltk.tokenize.punkt import PunktSentenceTokenizer | |
| tokenizer = PunktSentenceTokenizer() | |
| # --- LISTA ECCEZIONI ABBREVIAZIONI --- | |
| # Evito che il chunker mi spezzi la frase a metà quando incontra "pag." o "art." | |
| # cosa che distruggerebbe il senso semantico prima ancora di passare all'LLM. | |
| custom_abbrevs = ['sec', 's', 'prof', 'dott', 'avv', 'pag', 'fig', 'nr', 'art'] | |
| for abbr in custom_abbrevs: | |
| tokenizer._params.abbrev_types.add(abbr) | |
| sentences = tokenizer.tokenize(text) | |
| except ImportError: | |
| print("⚠️ NLTK non installato. Fallback su Regex semplice.") | |
| sentences = re.split(r'(?<=[.?!])\s+', text) | |
| except Exception as e: | |
| print(f"⚠️ Errore NLTK ({e}). Fallback su Regex.") | |
| sentences = re.split(r'(?<=[.?!])\s+', text) | |
| # Filtro via il rumore di fondo (stringhe troppo corte o spazi rimasti appesi) | |
| return [s.strip() for s in sentences if len(s.strip()) > 5] | |
| def combine_sentences(self, sentences, buffer_size=1): | |
| # Sliding window per dare contesto: embeddare una frase singola tipo "Di conseguenza." non ha senso vettoriale. | |
| # Le affianco la frase prima e quella dopo per "spalmare" il significato | |
| # ed evitare che una frase breve sballi il calcolo del coseno. | |
| combined = [] | |
| for i in range(len(sentences)): | |
| start = max(0, i - buffer_size) | |
| end = min(len(sentences), i + 1 + buffer_size) | |
| combined_context = " ".join(sentences[start:end]) | |
| combined.append(combined_context) | |
| return combined | |
| def calculate_cosine_distances(self, sentences): | |
| # Embeddo tutto in batch. Se arrivano malloppi enormi da estrarre non voglio saturare la memoria. | |
| embeddings = [] | |
| total = len(sentences) | |
| for i in range(0, total, self.batch_size): | |
| batch = sentences[i : i + self.batch_size] | |
| batch_embeddings = self.embedding_model.embed_documents(batch) | |
| embeddings.extend(batch_embeddings) | |
| # Calcolo le distanze sequenziali tra la frase N e la frase N+1 | |
| distances = [] | |
| for i in range(len(embeddings) - 1): | |
| similarity = cosine_similarity([embeddings[i]], [embeddings[i+1]])[0][0] | |
| # Inverto la similarità in distanza (0 = concetti identici, 1 = cambio totale di argomento) | |
| distance = 1.0 - similarity | |
| distances.append(distance) | |
| return distances, embeddings | |
| def create_chunks(self, text, percentile_threshold=95): | |
| single_sentences = self._split_sentences(text) | |
| if not single_sentences: | |
| return [], [], 0 | |
| combined_sentences = self.combine_sentences(single_sentences) | |
| distances, _ = self.calculate_cosine_distances(combined_sentences) | |
| if not distances: | |
| # Testo troppo breve per essere splittato, lo tengo intero | |
| return [text], [], 0 | |
| # Calcolo la soglia di taglio dinamicamente in base alle variazioni semantiche del documento stesso. | |
| threshold = np.percentile(distances, percentile_threshold) | |
| # Individuo i "punti di rottura" dove l'argomento cambia radicalmente | |
| indices_above_thresh = [i for i, x in enumerate(distances) if x > threshold] | |
| chunks = [] | |
| start_index = 0 | |
| breakpoints = indices_above_thresh + [len(single_sentences)] | |
| # Ricostruisco i paragrafi unendo le frasi originali (non quelle col buffer) | |
| # delimitandole dai punti di rottura che abbiamo appena trovato. | |
| for i in breakpoints: | |
| end_index = i + 1 | |
| chunk_text = " ".join(single_sentences[start_index:end_index]) | |
| if len(chunk_text) > 20: # Salto micro-frammenti spazzatura (es. singole parole o punteggiatura) | |
| chunks.append(chunk_text) | |
| start_index = end_index | |
| return chunks, distances, threshold |