CoolFace
Apppublic

nvtitan/graphRAG

sourceHugging Facemitupdated 10mo agoView on Hugging Face
0likes
gemini_extractor.py613 linesDownload Raw Back to root
1"""2Gemini-based Knowledge Graph Extraction3Simple LLM-powered extraction using Google Gemini (cheapest option)4"""5from typing import List, Dict, Any, Optional6from loguru import logger7from models import Chunk, CanonicalTriple, RelationType8from config import settings9import json10import asyncio11 12 13class GeminiExtractor:14    """15    Extract key nodes and relationships using Gemini LLM16    Simple, cost-effective approach for knowledge graph generation17    """18 19    def __init__(self, llm_service=None):20        """Initialize Gemini extractor"""21        logger.info("Initializing GeminiExtractor")22 23        # Import litellm for API calls24        try:25            import litellm26            self.litellm = litellm27 28            # Configure litellm for Gemini29            self.model_name = f"gemini/{settings.gemini_model}"30            self.api_key = settings.gemini_api_key31 32            logger.info(f"✓ GeminiExtractor initialized with model: {self.model_name}")33 34        except ImportError as e:35            logger.error("litellm not installed. Install with: pip install litellm")36            raise RuntimeError("litellm required for Gemini") from e37 38        # Comprehensive list of generic terms to REJECT39        self.generic_stopwords = {40            # Generic nouns41            'system', 'systems', 'data', 'information', 'value', 'values',42            'method', 'methods', 'approach', 'approaches', 'technique', 'techniques',43            'result', 'results', 'study', 'studies', 'paper', 'papers',44            'section', 'sections', 'figure', 'figures', 'table', 'tables',45            'example', 'examples', 'case', 'cases', 'type', 'types',46            'way', 'ways', 'thing', 'things', 'part', 'parts',47            'model', 'models', 'framework', 'frameworks',  # Too generic unless specific48            'process', 'processes', 'analysis', 'problem', 'problems',49            'solution', 'solutions', 'set', 'sets', 'group', 'groups',50            'element', 'elements', 'component', 'components',51            'feature', 'features', 'property', 'properties',52            'aspect', 'aspects', 'factor', 'factors', 'parameter', 'parameters',53            'concept', 'concepts', 'idea', 'ideas', 'theory', 'theories',54            'field', 'fields', 'area', 'areas', 'domain', 'domains',55            'task', 'tasks', 'goal', 'goals', 'objective', 'objectives',56            'input', 'inputs', 'output', 'outputs', 'function', 'functions',57            'operation', 'operations', 'step', 'steps', 'stage', 'stages',58            'phase', 'phases', 'level', 'levels', 'layer', 'layers',59            'number', 'numbers', 'amount', 'amounts', 'size', 'sizes',60            'performance', 'accuracy', 'quality', 'efficiency',61            'document', 'documents', 'text', 'texts', 'word', 'words',62            'sentence', 'sentences', 'paragraph', 'paragraphs',63            'item', 'items', 'object', 'objects', 'entity', 'entities',64            'relation', 'relations', 'relationship', 'relationships',65 66            # Generic verbs/actions67            'use', 'uses', 'using', 'used', 'usage',68            'apply', 'applies', 'applying', 'applied', 'application', 'applications',69            'work', 'works', 'working', 'worked',70            'provide', 'provides', 'providing', 'provided',71            'show', 'shows', 'showing', 'shown',72            'present', 'presents', 'presenting', 'presented', 'presentation',73 74            # Generic adjectives75            'new', 'novel', 'existing', 'current', 'previous',76            'different', 'similar', 'same', 'other', 'another',77            'various', 'several', 'multiple', 'single',78            'important', 'significant', 'main', 'key', 'major',79            'good', 'better', 'best', 'high', 'low',80            'large', 'small', 'big', 'little',81 82            # Research-specific generic terms83            'experiment', 'experiments', 'evaluation', 'evaluations',84            'test', 'tests', 'testing', 'validation',85            'comparison', 'comparisons', 'benchmark', 'benchmarks',86            'baseline', 'baselines', 'metric', 'metrics',87            'dataset', 'datasets', 'corpus', 'corpora',88 89            # Time/sequence terms90            'time', 'times', 'period', 'periods', 'year', 'years',91            'first', 'second', 'third', 'last', 'final',92            'next', 'previous', 'current', 'recent',93 94            # Common prepositions/articles (shouldn't appear but just in case)95            'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',96 97            # Additional generic ML/AI terms (too broad)98            'neural network', 'deep learning', 'machine learning',99            'training', 'testing', 'prediction', 'classification',100            'regression', 'clustering', 'optimization',101            'network', 'networks', 'algorithm', 'algorithms',102            'learning', 'training data', 'test data',103            'feature extraction', 'preprocessing',104            'hyperparameter', 'hyperparameters',105            'loss', 'error', 'gradient',106        }107 108    async def extract_from_chunks(109        self,110        chunks: List[Chunk],111        use_llm: bool = True112    ) -> List[CanonicalTriple]:113        """114        Extract knowledge graph - PER PAGE with HARD CAP of 2 concepts per page115 116        Args:117            chunks: List of text chunks118            use_llm: Always True for Gemini extraction119 120        Returns:121            List of canonical triples122        """123        logger.info(f"\n{'='*80}")124        logger.info(f"{'GEMINI PER-PAGE EXTRACTION - 2 CONCEPTS MAX PER PAGE':^80}")125        logger.info(f"{'='*80}")126 127        all_triples = []128 129        # Filter text chunks130        text_chunks = [c for c in chunks if c.type.value in ["paragraph", "code"]]131 132        if not text_chunks:133            logger.warning("No text chunks to process")134            return []135 136        # GROUP CHUNKS BY PAGE137        from collections import defaultdict138        chunks_by_page = defaultdict(list)139        for chunk in text_chunks:140            page_num = chunk.page_number or 0141            chunks_by_page[page_num].append(chunk)142 143        logger.info(f"Processing {len(chunks_by_page)} pages in PARALLEL")144 145        # ⚡ PARALLEL PROCESSING: Create tasks for all pages146        tasks = []147        page_numbers = []148        for page_num in sorted(chunks_by_page.keys()):149            page_chunks = chunks_by_page[page_num]150            combined_text = "\n\n".join([chunk.text for chunk in page_chunks])151 152            logger.info(f"📄 PAGE {page_num}: {len(page_chunks)} chunks, {len(combined_text)} chars")153 154            # Create async task for this page155            tasks.append(self._extract_with_gemini(combined_text, page_num))156            page_numbers.append(page_num)157 158        # Execute all Gemini calls in parallel159        logger.info(f"\n🚀 Launching {len(tasks)} parallel Gemini API calls...")160        import time161        start_time = time.time()162 163        results = await asyncio.gather(*tasks, return_exceptions=True)164 165        elapsed = time.time() - start_time166        logger.info(f"✓ All {len(tasks)} Gemini calls completed in {elapsed:.2f}s (parallel)")167        logger.info(f"  Average: {elapsed/len(tasks):.2f}s per page (would be {elapsed*len(tasks):.2f}s sequential)")168 169        # Process results170        for page_num, page_triples in zip(page_numbers, results):171            if isinstance(page_triples, Exception):172                logger.error(f"  ❌ Page {page_num} failed: {page_triples}")173                continue174 175            if page_triples:176                all_triples.extend(page_triples)177                logger.info(f"  ✓ Page {page_num}: Extracted {len(page_triples)} triples")178                for t in page_triples:179                    relation_value = t.relation.value if hasattr(t.relation, 'value') else t.relation180                    logger.info(f"    → {t.subject_label} --[{relation_value}]--> {t.object_label}")181            else:182                logger.warning(f"  ⚠️ Page {page_num}: NO TRIPLES EXTRACTED!")183 184        # Summary185        unique_concepts = set()186        concepts_by_page = {}187        for triple in all_triples:188            unique_concepts.add(triple.subject_label)189            unique_concepts.add(triple.object_label)190            page = triple.page_number191            if page not in concepts_by_page:192                concepts_by_page[page] = set()193            concepts_by_page[page].add(triple.subject_label)194            concepts_by_page[page].add(triple.object_label)195 196        logger.info(f"\n{'='*80}")197        logger.info(f"{'EXTRACTION SUMMARY':^80}")198        logger.info(f"{'='*80}")199        logger.info(f"Pages processed: {len(chunks_by_page)}")200        logger.info(f"Total triples: {len(all_triples)}")201        logger.info(f"Unique concepts: {len(unique_concepts)} (max {len(chunks_by_page) * 2})")202 203        if len(all_triples) == 0:204            logger.error(f"\n❌❌❌ CRITICAL ERROR: ZERO TRIPLES EXTRACTED! ❌❌❌")205            logger.error(f"This means:")206            logger.error(f"  - Either Gemini returned no concepts")207            logger.error(f"  - Or all concepts were rejected by filters")208            logger.error(f"  - Or there was an API error")209            logger.error(f"Check the logs above for details!")210        else:211            logger.info(f"\nConcepts per page:")212            for page in sorted(concepts_by_page.keys()):213                logger.info(f"  Page {page}: {list(concepts_by_page[page])}")214 215        logger.info(f"{'='*80}\n")216 217        return all_triples218 219    async def _extract_with_gemini(self, text: str, page_number: int) -> List[CanonicalTriple]:220        """221        Call Gemini API to extract technical concepts (nodes) from THIS PAGE222 223        Args:224            text: Text from single page225            page_number: Page number226 227        Returns:228            List of canonical triples229        """230        # Specialized technical concept extraction prompt231        prompt = f"""You are an expert in technical information extraction and knowledge graph construction.232Your task is to identify only the most meaningful *technical concepts* from the given text.233Concepts must represent scientific, mathematical, algorithmic, or methodological entities234that could exist as standalone nodes in a knowledge graph.235Ignore generic words, section titles, variable names, and everyday terms.236Focus on high-value, domain-specific terminology relevant to the text.237 238Extract all important technical concepts from the following text that would form the239nodes of a knowledge graph.240 241⚙️ Rules:242• Each concept should represent a self-contained technical idea, model, method, metric, loss, theorem, or process243• Keep only multi-word phrases when possible ("gradient descent", "convolutional neural network", "cross-entropy loss")244• Skip single, contextless nouns ("data", "model", "value", "equation", "result")245• Merge synonymous terms (e.g., "SGD", "stochastic gradient descent" → one entry)246• Do not include equations, numeric values, figure names, or symbols247• Do not repeat concepts248• Maintain consistent naming conventions (lowercase, hyphen-separated words)249• Extract MAXIMUM 4-5 concepts from this page (quality over quantity)250 251Return output strictly as JSON with "nodes" key:252{{253  "nodes": [254    "gradient descent",255    "neural network",256    "cross entropy loss"257  ]258}}259 260PAGE {page_number} TEXT:261{text}262 263CRITICAL: Return ONLY the JSON. If no technical concepts found, return {{"nodes": []}}"""264 265        logger.info(f"  🚀 Starting Gemini extraction for page {page_number}...")266        logger.info(f"  Text length: {len(text)} characters")267 268        try:269            # Call Gemini via litellm270            logger.info(f"  📡 Calling Gemini API for page {page_number}...")271 272            response = await asyncio.to_thread(273                self.litellm.completion,274                model=self.model_name,275                api_key=self.api_key,276                messages=[{277                    "role": "user",278                    "content": prompt279                }],280                temperature=0.0,  281                max_tokens=settings.llm_max_tokens,282                timeout=settings.llm_timeout283            )284 285            # Extract response text286            response_text = response.choices[0].message.content.strip()287            logger.info(f"  📥 Gemini response ({len(response_text)} chars):")288            logger.info(f"  {response_text[:500]}") 289 290            291            if "```json" in response_text:292                response_text = response_text.split("```json")[1].split("```")[0].strip()293            elif "```" in response_text:294                response_text = response_text.split("```")[1].split("```")[0].strip()295 296            data = json.loads(response_text)297 298            299            if isinstance(data, dict) and "nodes" in data:300                nodes = data["nodes"]301            elif isinstance(data, list):302                # Fallback: if Gemini returned a list directly303                nodes = data304            else:305                logger.warning(f"  ❌ Gemini returned unexpected format: {type(data)}")306                return []307 308            if not isinstance(nodes, list):309                logger.warning(f"  ❌ Nodes is not a list, got: {type(nodes)}")310                return []311 312            logger.info(f"  ✓ Gemini extracted {len(nodes)} nodes from page {page_number}")313            logger.info(f"  Raw nodes: {nodes}")314 315            # Validate and filter nodes316            valid_nodes = []317            rejected_nodes = []318 319            for node in nodes:320                if not isinstance(node, str):321                    logger.warning(f"  ⚠️ Skipping non-string node: {node}")322                    continue323 324                node = node.strip()325                if not node:326                    continue327 328                logger.info(f"  Validating node: '{node}'")329 330                # FILTER: Validate node is a technical concept331                if not self._is_technical_concept(node):332                    rejected_nodes.append(node)333                    logger.warning(f"  ✗ REJECTED node '{node}' - not technical enough")334                    continue335 336                logger.info(f"  ✅ ACCEPTED node: '{node}'")337                valid_nodes.append(node.lower())338 339            # Summary of rejections340            if rejected_nodes:341                logger.warning(f"  📊 Rejected {len(rejected_nodes)} nodes: {rejected_nodes}")342 343            if not valid_nodes:344                logger.warning(f"  ⚠️ ALL {len(nodes)} NODES REJECTED for page {page_number}")345                logger.warning(f"  No valid technical concepts found. Returning empty list.")346                return []347 348            349            selected_nodes = valid_nodes[:2]  #350            logger.info(f"  🎯 Selected {len(selected_nodes)} nodes (hard cap = 2): {selected_nodes}")351 352            353            page_triples = []354 355            if len(selected_nodes) == 1:356                # Only one node - create self-referencing relationship or skip357                logger.info(f"  ℹ️ Only 1 node on page {page_number}, cannot create relationships")358                359                return []360 361            elif len(selected_nodes) == 2:362                # Use LLM to determine actual relationship between nodes363                node1, node2 = selected_nodes[0], selected_nodes[1]364 365                # Extract relationship using LLM with page context366                logger.info(f"  🔍 Extracting relationship between: {node1} ↔ {node2}")367                relationship_triple = await self._extract_relationship_with_gemini(368                    text=text,369                    node1=node1,370                    node2=node2,371                    page_number=page_number372                )373 374                if relationship_triple:375                    page_triples.append(relationship_triple)376                    logger.info(f"  ✅ Created directed edge:")377                    logger.info(f"    → {relationship_triple.subject_label} --[{relationship_triple.relation.value}]--> {relationship_triple.object_label}")378                    logger.info(f"    Justification: {relationship_triple.justification}")379                else:380                    logger.warning(f"  ⚠️ Could not extract relationship for {node1} ↔ {node2}")381 382            logger.info(f"  ✅ Returning {len(page_triples)} triples for page {page_number}")383            return page_triples384 385        except json.JSONDecodeError as e:386            logger.error(f"  ❌ JSON PARSE ERROR for page {page_number}: {e}")387            logger.error(f"  Response was: {response_text[:500]}")388            return []389 390        except Exception as e:391            logger.error(f"  ❌ GEMINI API FAILED for page {page_number}: {e}")392            logger.error(f"  Exception type: {type(e).__name__}")393            logger.error(f"  Full trace:", exc_info=True)394            return []395 396    async def _extract_relationship_with_gemini(self, text: str, node1: str, node2: str, page_number: int) -> Optional[CanonicalTriple]:397        """398        Use Gemini to determine the actual relationship between two nodes based on page context399 400        Args:401            text: Full page text for context402            node1: First node/concept403            node2: Second node/concept404            page_number: Page number405 406        Returns:407            CanonicalTriple with proper relationship, or None if extraction fails408        """409        # List all available relation types for the LLM410        available_relations = [r.value for r in RelationType]411 412        prompt = f"""You are an expert at extracting knowledge graph relationships from technical text.413 414Given two concepts and the text they appear in, determine the most accurate relationship between them.415 416**Concepts:**417- Concept A: "{node1}"418- Concept B: "{node2}"419 420**Context (page {page_number}):**421{text[:3000]}422 423**Available Relationship Types:**424{', '.join(available_relations)}425 426**Instructions:**4271. Analyze how these two concepts relate in the given context4282. Choose the MOST SPECIFIC relationship type from the list above4293. Determine the direction: which concept is the subject and which is the object4304. Provide a brief justification from the text431 432**Output Format (JSON):**433{{434  "subject": "<node1 or node2>",435  "object": "<node1 or node2>",436  "relation": "<one of the available relationship types>",437  "confidence": <0.0-1.0>,438  "justification": "<brief explanation from text>"439}}440 441**Rules:**442- Use the exact concept names provided443- Choose only ONE relation type from the available list444- If no clear relationship exists, use "related_to"445- Direction matters: subject performs/has the relation to the object446"""447 448        try:449            # Call Gemini API450            response_text = await self.litellm.acompletion(451                model=self.model_name,452                messages=[453                    {"role": "system", "content": "You are an expert at knowledge graph relationship extraction. Always output valid JSON."},454                    {"role": "user", "content": prompt}455                ],456                api_key=self.api_key,457                temperature=0.1,  # Low temperature for consistent relationship extraction458                response_format={"type": "json_object"}459            )460 461            response_content = response_text.choices[0].message.content462            data = json.loads(response_content)463 464            # Validate response465            subject = data.get("subject", "").strip()466            obj = data.get("object", "").strip()467            relation_str = data.get("relation", "related_to").lower().strip().replace(" ", "_")468            confidence = float(data.get("confidence", 0.7))469            justification = data.get("justification", f"Relationship extracted from page {page_number}")470 471            # Map relation string to enum472            try:473                relation = RelationType(relation_str)474            except ValueError:475                logger.warning(f"  ⚠️ Invalid relation '{relation_str}', defaulting to RELATED_TO")476                relation = RelationType.RELATED_TO477 478            # Create triple479            triple = CanonicalTriple(480                subject_label=subject,481                object_label=obj,482                relation=relation,483                confidence=confidence,484                justification=justification,485                page_number=page_number486            )487 488            return triple489 490        except json.JSONDecodeError as e:491            logger.error(f"  ❌ JSON parse error in relationship extraction: {e}")492            return None493        except Exception as e:494            logger.error(f"  ❌ Relationship extraction failed: {e}")495            return None496 497    def _is_technical_concept(self, concept: str) -> bool:498        """499 500        Args:501            concept: Concept string to validate502 503        Returns:504            True if highly technical/specific, False otherwise505        """506        concept_lower = concept.lower().strip()507 508        # RULE 1: Reject if in stopwords509        if concept_lower in self.generic_stopwords:510            logger.debug(f"Rejected '{concept}' - in stopword list")511            return False512 513        # RULE 2: Reject if any word is a generic stopword (stricter)514        words = concept_lower.split()515        for word in words:516            if word in self.generic_stopwords:517                # Allow if it's part of a specific multi-word technical term518                # e.g., "convolutional neural network" has "network" but is specific519                if len(words) < 2:520                    logger.debug(f"Rejected '{concept}' - contains generic word '{word}'")521                    return False522 523        # RULE 3: Single-word concepts must have SOME specificity (RELAXED)524        if len(words) == 1:525            # Accept if ANY of these are true:526            # - Has uppercase (BERT, Adam, PyTorch)527            # - Has numbers (VGG16, GPT3)528            # - Has special chars (t-SNE, bi-LSTM)529            # - Longish word (8+ chars like "backpropagation")530            has_uppercase = any(c.isupper() for c in concept)531            has_numbers = any(c.isdigit() for c in concept)532            has_special = '-' in concept or '_' in concept533            is_longish = len(concept) >= 8  # RELAXED from 10534 535            if not (has_uppercase or has_numbers or has_special or is_longish):536                logger.debug(f"Rejected '{concept}' - single word not specific enough")537                return False538 539        # RULE 4: Multi-word phrases - very lenient540        if len(words) >= 2:541            # Just check that it's not ALL generic words542            # At least one word should be non-generic or have caps/numbers543            has_caps = any(c.isupper() for c in concept)544            has_numbers = any(c.isdigit() for c in concept)545            has_hyphen = '-' in concept546 547            # Count non-generic words548            non_generic_count = sum(1 for w in words if w not in self.generic_stopwords)549 550            # Accept if ANY of these:551            # - Has caps/numbers/hyphen552            # - At least one word is non-generic553            # - 3+ words (likely specific enough)554            if not (has_caps or has_numbers or has_hyphen or non_generic_count > 0 or len(words) >= 3):555                logger.debug(f"Rejected '{concept}' - multi-word phrase too generic")556                return False557 558        # RULE 5: Reject very short terms (1-2 chars) unless they're known acronyms (all caps)559        if len(concept) <= 2 and concept.upper() != concept:560            logger.debug(f"Rejected '{concept}' - too short")561            return False562 563        # RULE 6: Must contain at least one alphanumeric character564        if not any(c.isalnum() for c in concept):565            logger.debug(f"Rejected '{concept}' - no alphanumeric chars")566            return False567 568        # RULE 7: Reject if it's just a generic category with a modifier569        # e.g., "new algorithm", "proposed method", "our model"570        generic_patterns = [571            'new ', 'novel ', 'proposed ', 'our ', 'this ', 'that ',572            'these ', 'those ', 'such ', 'other ', 'another ',573            'existing ', 'current ', 'previous ', 'standard '574        ]575        for pattern in generic_patterns:576            if concept_lower.startswith(pattern):577                logger.debug(f"Rejected '{concept}' - generic pattern")578                return False579 580        # Passed all strict filters581        return True582 583    def _map_relation(self, relation_str: str) -> RelationType:584        """Map relation string to RelationType enum"""585        relation_lower = relation_str.lower().strip()586 587        # Direct mapping588        mapping = {589            "uses": RelationType.USES,590            "implements": RelationType.IMPLEMENTS,591            "is_a": RelationType.IS_A,592            "is a": RelationType.IS_A,593            "part_of": RelationType.PART_OF,594            "part of": RelationType.PART_OF,595            "requires": RelationType.REQUIRES,596            "produces": RelationType.PRODUCES,597            "enables": RelationType.ENABLES,598            "improves": RelationType.IMPROVES,599            "enhances": RelationType.ENHANCES,600            "contains": RelationType.CONTAINS,601            "depends_on": RelationType.DEPENDS_ON,602            "depends on": RelationType.DEPENDS_ON,603            "related_to": RelationType.RELATED_TO,604            "related to": RelationType.RELATED_TO,605        }606 607        if relation_lower in mapping:608            return mapping[relation_lower]609 610        # Fallback611        logger.debug(f"Unknown relation '{relation_str}', using 'related_to'")612        return RelationType.RELATED_TO613