ragbox-core 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ragbox/__init__.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ RAGBox: Zero-Configuration Self-Building Agentic RAG System.
3
+
4
+ Provides the public RAGBox class for one-line integrations.
5
+ """
6
+ import asyncio
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ from loguru import logger
11
+
12
+ from ragbox.config.defaults import Settings
13
+ from ragbox.core.agentic_orchestrator import AgenticOrchestrator
14
+ from ragbox.core.chunking_engine import ChunkingEngine
15
+ from ragbox.core.document_processor import DocumentProcessorRouter
16
+ from ragbox.core.knowledge_graph import OptimizedKnowledgeGraph
17
+ from ragbox.core.retrieval_fusion import RetrievalFusionEngine
18
+ from ragbox.core.self_healing import SelfHealer
19
+ from ragbox.models.queries import Answer
20
+ from ragbox.utils.embeddings import EmbeddingAutoDetector
21
+ from ragbox.utils.llm_clients import LLMAutoDetector
22
+ from ragbox.utils.vector_stores import VectorStoreAutoDetector
23
+
24
+
25
+ class RAGBox:
26
+ """
27
+ RAG-in-a-Box: Zero-Configuration Self-Building Agentic RAG System.
28
+ """
29
+
30
+ def __init__(self, document_dir: str | Path, config: Optional[Settings] = None) -> None:
31
+ """
32
+ Initialize a complete RAG system pointing to a directory.
33
+
34
+ Args:
35
+ document_dir: Path to the directory containing documents.
36
+ config: Optional configuration overrides.
37
+ """
38
+ self.document_dir = Path(document_dir)
39
+ self.config = config or Settings()
40
+
41
+ # Initialize Core Layers Auto-magically
42
+ self.llm_client = LLMAutoDetector.detect(self.config)
43
+ self.embedding_provider = EmbeddingAutoDetector.detect(self.config)
44
+ self.vector_store = VectorStoreAutoDetector.detect(self.config, self.embedding_provider)
45
+
46
+ self.document_processor = DocumentProcessorRouter()
47
+ self.chunking_engine = ChunkingEngine(self.llm_client, self.embedding_provider)
48
+ self.knowledge_graph = OptimizedKnowledgeGraph(llm_client=self.llm_client)
49
+
50
+ self.retriever = RetrievalFusionEngine(
51
+ vector_store=self.vector_store,
52
+ knowledge_graph=self.knowledge_graph,
53
+ embedding_provider=self.embedding_provider,
54
+ llm_client=self.llm_client
55
+ )
56
+ self.orchestrator = AgenticOrchestrator(
57
+ retriever=self.retriever,
58
+ llm_client=self.llm_client,
59
+ document_processor=self.document_processor,
60
+ knowledge_graph=self.knowledge_graph
61
+ )
62
+ self.self_healer = SelfHealer(
63
+ document_dir=self.document_dir,
64
+ document_processor=self.document_processor,
65
+ chunking_engine=self.chunking_engine,
66
+ vector_store=self.vector_store,
67
+ knowledge_graph=self.knowledge_graph
68
+ )
69
+
70
+ # Start initial build and self-healing daemon
71
+ logger.info(f"Initializing RAGBox for {self.document_dir}")
72
+ self._ensure_built()
73
+
74
+ def _ensure_built(self) -> None:
75
+ """Synchronously trigger the build process to guarantee readiness."""
76
+ try:
77
+ loop = asyncio.get_running_loop()
78
+ except RuntimeError:
79
+ loop = asyncio.new_event_loop()
80
+ asyncio.set_event_loop(loop)
81
+
82
+ if loop.is_running():
83
+ asyncio.create_task(self.self_healer.initial_build())
84
+ else:
85
+ loop.run_until_complete(self.self_healer.initial_build())
86
+
87
+ # Start watchdog
88
+ self.self_healer.start_watchdog()
89
+
90
+ def query(self, question: str) -> str:
91
+ """
92
+ Query the RAGBox system synchronously.
93
+
94
+ Args:
95
+ question: The user's query.
96
+
97
+ Returns:
98
+ The direct answer string.
99
+ """
100
+ try:
101
+ loop = asyncio.get_running_loop()
102
+ if loop.is_running():
103
+ raise RuntimeError("Cannot call synchronous `query` inside an active event loop. Use `aquery`.")
104
+ except RuntimeError:
105
+ loop = asyncio.new_event_loop()
106
+ asyncio.set_event_loop(loop)
107
+
108
+ answer: Answer = loop.run_until_complete(self.orchestrator.execute(question))
109
+ return answer.content
110
+
111
+ async def aquery(self, question: str) -> str:
112
+ """
113
+ Async evaluation of a query.
114
+
115
+ Args:
116
+ question: The user's query.
117
+
118
+ Returns:
119
+ The direct answer string.
120
+ """
121
+ answer: Answer = await self.orchestrator.execute(question)
122
+ return answer.content
123
+
124
+ def estimate_cost(self, query: Optional[str] = None) -> str:
125
+ """
126
+ Estimate the cost of a query or indexing the corpus upfront.
127
+
128
+ Args:
129
+ query: The query to run. If None, estimates the cost of indexing the entire corpus.
130
+
131
+ Returns:
132
+ A string detailing the estimated cost.
133
+ """
134
+ from ragbox.utils.cost_tracker import CostEstimator
135
+
136
+ # We assume the default model for estimating cost
137
+ estimator = CostEstimator(self.llm_client.model_name if hasattr(self.llm_client, "model_name") else "gpt-4o")
138
+
139
+ if query is None:
140
+ # Estimate cost of indexing the corpus based on file size
141
+ total_bytes = 0
142
+ for ext in self.document_processor.processors.keys():
143
+ for p in self.document_dir.rglob(f"*{ext}"):
144
+ if p.is_file():
145
+ total_bytes += p.stat().st_size
146
+
147
+ # Wild guess: 1 byte ~ 0.25 tokens
148
+ estimated_tokens = int(total_bytes * 0.25)
149
+ # Embedding cost
150
+ embed_cost = (estimated_tokens / 1_000_000) * 0.13 # large embedding assumption
151
+ return f"Corpus Indexing Estimate: ~{estimated_tokens} tokens. Embedding Cost: ${embed_cost:.4f}"
152
+
153
+ else:
154
+ # Simulate retrieval and estimation for query
155
+ try:
156
+ loop = asyncio.get_running_loop()
157
+ if loop.is_running():
158
+ raise RuntimeError("Cannot call synchronous `estimate_cost` inside loop.")
159
+ except RuntimeError:
160
+ loop = asyncio.new_event_loop()
161
+ asyncio.set_event_loop(loop)
162
+
163
+ # Retrieve mock sources
164
+ sources = loop.run_until_complete(self.retriever.retrieve(query))
165
+ context_text = "\n".join([s.text for s in sources])
166
+
167
+ estimate = estimator.estimate_generation(
168
+ prompt=f"{context_text}\n{query}",
169
+ approx_output_tokens=500
170
+ )
171
+ return estimate.__str__()
172
+
173
+
174
+
175
+ __all__ = ["RAGBox"]
ragbox/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ import typer
2
+ import asyncio
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.markdown import Markdown
7
+
8
+ from ragbox import RAGBox
9
+ from ragbox.config.defaults import Settings
10
+
11
+ app = typer.Typer(
12
+ name="ragbox",
13
+ help="RAGBox: Zero-Configuration Self-Building Agentic RAG System",
14
+ add_completion=False,
15
+ )
16
+
17
+ console = Console()
18
+
19
+ @app.command()
20
+ def init(
21
+ document_dir: Path = typer.Argument(
22
+ ...,
23
+ exists=True,
24
+ file_okay=False,
25
+ dir_okay=True,
26
+ help="Directory containing local documents to index.",
27
+ ),
28
+ config_file: Path = typer.Option(
29
+ None,
30
+ "--config",
31
+ "-c",
32
+ help="Path to an optional JSON/YAML configuration file.",
33
+ )
34
+ ):
35
+ """
36
+ Initialize a RAGBox instance in the specified directory.
37
+
38
+ This command blocks until the initial index and knowledge graph are built.
39
+ Afterward, it starts the background daemon to watch for changes.
40
+ """
41
+ console.print(Panel(f"Initializing RAGBox at [cyan]{document_dir}[/cyan]", border_style="green"))
42
+
43
+ # In a real implementation this might parse config_file into Settings
44
+ settings = Settings()
45
+
46
+ try:
47
+ # Pre-flight cost estimation
48
+ rg_pre = RAGBox(document_dir, config=settings)
49
+ cost_estimate = rg_pre.estimate_cost(query=None)
50
+ console.print(Panel(f"[bold yellow]{cost_estimate}[/bold yellow]", title="Cost Estimate"))
51
+
52
+ # RAGBox __init__ calls _ensure_built which builds synchronously implicitly via asyncio
53
+ with console.status("[bold green]Building Vector Store and Knowledge Graph...", spinner="dots"):
54
+ rg = rg_pre # It's already built by the init above
55
+ console.print("[bold green]✓ Indexing complete.[/bold green] RAGBox is ready for queries.")
56
+ console.print("The self-healing daemon is now active in the background for this session.")
57
+
58
+ # Keep process alive to allow watchdog to run, or exit if init is purely
59
+ # meant to pre-compute. Because RAGBox design spins up daemon threads,
60
+ # we'll keep it alive here if we want continuous syncing.
61
+ console.print("[yellow]Press Ctrl+C to stop the daemon.[/yellow]")
62
+ try:
63
+ while True:
64
+ # Wait forever
65
+ asyncio.run(asyncio.sleep(3600))
66
+ except KeyboardInterrupt:
67
+ console.print("Daemon stopped.")
68
+
69
+ except Exception as e:
70
+ console.print(f"[bold red]Error initializing RAGBox: {e}[/bold red]")
71
+ raise typer.Exit(code=1)
72
+
73
+ @app.command()
74
+ def query(
75
+ question: str = typer.Argument(..., help="The question to ask the RAG system."),
76
+ document_dir: Path = typer.Option(
77
+ ".",
78
+ "--dir",
79
+ "-d",
80
+ help="Directory where RAGBox is initialized.",
81
+ exists=True,
82
+ dir_okay=True,
83
+ )
84
+ ):
85
+ """
86
+ Query an existing RAGBox instance.
87
+ """
88
+ console.print(f"[bold blue]Querying RAGBox in {document_dir}...[/bold blue]\n")
89
+ try:
90
+ rg = RAGBox(document_dir)
91
+
92
+ # Estimation
93
+ cost_estimate = rg.estimate_cost(query=question)
94
+ console.print(f"[yellow]Estimation:[/yellow] {cost_estimate}")
95
+
96
+ with console.status("[bold green]Agentic Orchestrator thinking...", spinner="dots"):
97
+ answer = rg.query(question)
98
+
99
+ console.print(Panel(Markdown(answer), title="Answer", border_style="blue"))
100
+
101
+ except Exception as e:
102
+ console.print(f"[bold red]Error querying RAGBox: {e}[/bold red]")
103
+ raise typer.Exit(code=1)
104
+
105
+ if __name__ == "__main__":
106
+ app()
@@ -0,0 +1,3 @@
1
+ """Config module initialization."""
2
+ from .defaults import Settings
3
+ __all__ = ["Settings"]
@@ -0,0 +1,28 @@
1
+ """
2
+ Zero-Configuration Settings via Pydantic using dotenv.
3
+ """
4
+ from typing import Optional
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+ from pydantic import Field
7
+
8
+ class Settings(BaseSettings):
9
+ """
10
+ RAGBox Configuration.
11
+ Prioritizes Env Vars, falls back to .env if present, otherwise defaults.
12
+ """
13
+ model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
14
+
15
+ # API Keys
16
+ openai_api_key: Optional[str] = Field(default=None, description="OpenAI API Key")
17
+ anthropic_api_key: Optional[str] = Field(default=None, description="Anthropic API Key")
18
+ groq_api_key: Optional[str] = Field(default=None, description="Groq API Key")
19
+ pinecone_api_key: Optional[str] = Field(default=None, description="Pinecone API Key")
20
+
21
+ # Local paths
22
+ local_model_path: str = Field(default="models/llama-3.1-8b-instruct.gguf", description="Path for local LLM")
23
+ chroma_db_dir: str = Field(default="./chroma_db", description="Path for local vector DB")
24
+
25
+ # Behaviors
26
+ log_level: str = "INFO"
27
+ max_agent_steps: int = 5
28
+ graph_community_level: int = 1
@@ -0,0 +1,18 @@
1
+ """
2
+ Core RAGBox mechanisms.
3
+ """
4
+ from .agentic_orchestrator import AgenticOrchestrator
5
+ from .chunking_engine import ChunkingEngine
6
+ from .document_processor import DocumentProcessorRouter
7
+ from .knowledge_graph import OptimizedKnowledgeGraph
8
+ from .retrieval_fusion import RetrievalFusionEngine
9
+ from .self_healing import SelfHealer
10
+
11
+ __all__ = [
12
+ "AgenticOrchestrator",
13
+ "ChunkingEngine",
14
+ "DocumentProcessorRouter",
15
+ "OptimizedKnowledgeGraph",
16
+ "RetrievalFusionEngine",
17
+ "SelfHealer",
18
+ ]
@@ -0,0 +1,178 @@
1
+ """
2
+ Layer 6: AGENTIC ORCHESTRATION
3
+ Query classification, dynamic routing, ReAct planning, and synthesis.
4
+ """
5
+ import time
6
+ from typing import Any, Optional
7
+ from loguru import logger
8
+
9
+ from ragbox.models.queries import Answer, RAGStrategy
10
+ from ragbox.core.retrieval_fusion import RetrievalFusionEngine
11
+ from ragbox.utils.llm_clients import LLMClient
12
+
13
+ class AgenticOrchestrator:
14
+ def __init__(self,
15
+ retriever: RetrievalFusionEngine,
16
+ llm_client: LLMClient,
17
+ document_processor: Any,
18
+ knowledge_graph: Any):
19
+ self.retriever = retriever
20
+ self.llm = llm_client
21
+ self.doc_processor = document_processor
22
+ self.kg = knowledge_graph
23
+
24
+ async def _classify_query(self, query: str) -> RAGStrategy:
25
+ """Classify query to route to correct strategy."""
26
+ schema = {
27
+ "strategy": "vector | agentic | graph | multi_query",
28
+ "reasoning": "string"
29
+ }
30
+ prompt = f"""
31
+ Classify this query into one of the following retrieval strategies based on intent:
32
+ - 'vector': General semantic search, asking "What is X?"
33
+ - 'graph': Relationships, connections ("How does X relate to Y?")
34
+ - 'multi_query': Broad comparisons, multi-step ("Compare A and B", "How do I deploy?")
35
+ - 'agentic': Complex reasoning requiring outside tool usage or sequence of thoughts.
36
+
37
+ Query: {query}
38
+ """
39
+
40
+ try:
41
+ res = await self.llm.agenerate_structured(prompt, schema)
42
+ strategy_str = res.get("strategy", "vector").lower()
43
+ if strategy_str == "graph":
44
+ return RAGStrategy.GRAPH
45
+ elif strategy_str == "agentic":
46
+ return RAGStrategy.AGENTIC
47
+ elif strategy_str == "multi_query":
48
+ return RAGStrategy.MULTI_QUERY
49
+ return RAGStrategy.VECTOR
50
+ except Exception as e:
51
+ logger.warning(f"Classification failed: {e}. Defaulting to VECTOR.")
52
+ return RAGStrategy.VECTOR
53
+
54
+ async def _expand_query(self, query: str) -> list[str]:
55
+ """Expand one query into multiple semantic variations."""
56
+ prompt = f"""
57
+ Generate 3 distinct semantic variations of this query to improve search recall.
58
+ Return ONLY the queries separated by newlines.
59
+ Query: {query}
60
+ """
61
+ try:
62
+ res = await self.llm.agenerate(prompt, system="You are an expert search query generator.")
63
+ queries = [q.strip("- \t1234567890.") for q in res.split('\n') if q.strip()]
64
+ queries = [q for q in queries if q]
65
+ if not queries:
66
+ return [query]
67
+ return queries[:3]
68
+ except Exception as e:
69
+ logger.warning(f"Multi-query expansion failed: {e}")
70
+ return [query]
71
+
72
+ async def execute(self, query_text: str, force_strategy: Optional[RAGStrategy] = None) -> Answer:
73
+ """Execute the end-to-end RAG pipeline."""
74
+ start_time = time.time()
75
+
76
+ strategy = force_strategy or await self._classify_query(query_text)
77
+ logger.info(f"Executing query '{query_text}' with strategy {strategy.name}")
78
+
79
+ if strategy == RAGStrategy.AGENTIC:
80
+ answer = await self._execute_agentic(query_text)
81
+ elif strategy == RAGStrategy.GRAPH:
82
+ answer = await self._execute_graph(query_text)
83
+ elif strategy == RAGStrategy.MULTI_QUERY:
84
+ answer = await self._execute_multi_query(query_text)
85
+ else:
86
+ answer = await self._execute_vector(query_text)
87
+
88
+ answer.execution_time_ms = (time.time() - start_time) * 1000
89
+ return answer
90
+
91
+ async def _execute_vector(self, query: str) -> Answer:
92
+ sources = await self.retriever.retrieve(query)
93
+ context = "\n\n---\n\n".join([s.text for s in sources])
94
+
95
+ prompt = f"Answer the query based ONLY on the following context.\n\nContext:\n{context}\n\nQuery: {query}"
96
+ response = await self.llm.agenerate(prompt, system="You are an expert Q&A engine. Be concise and accurate.")
97
+
98
+ return Answer(
99
+ query=query,
100
+ content=response,
101
+ sources=sources,
102
+ strategy_used=RAGStrategy.VECTOR
103
+ )
104
+
105
+ async def _execute_multi_query(self, query: str) -> Answer:
106
+ expanded_queries = await self._expand_query(query)
107
+ logger.info(f"Expanded initial query into: {expanded_queries}")
108
+
109
+ all_sources = []
110
+ for q in [query] + expanded_queries:
111
+ q_sources = await self.retriever.retrieve(q, top_k=3)
112
+ all_sources.extend(q_sources)
113
+
114
+ # Deduplicate sources by text chunk ID or content to avoid noise
115
+ seen = set()
116
+ unique_sources = []
117
+ for s in all_sources:
118
+ # simple dedup
119
+ if s.text not in seen:
120
+ seen.add(s.text)
121
+ unique_sources.append(s)
122
+
123
+ # Limit to top N unique (since reranker will do the heavy lifting later in the pipeline anyway)
124
+ unique_sources = unique_sources[:10]
125
+
126
+ context = "\n\n---\n\n".join([s.text for s in unique_sources])
127
+ prompt = f"Answer the query comprehensively based ONLY on the following context derived from multi-query expansion.\n\nContext:\n{context}\n\nQuery: {query}"
128
+ response = await self.llm.agenerate(prompt, system="You are an expert Q&A engine analyzing across multiple perspectives.")
129
+
130
+ return Answer(
131
+ query=query,
132
+ content=response,
133
+ sources=unique_sources,
134
+ strategy_used=RAGStrategy.MULTI_QUERY
135
+ )
136
+
137
+
138
+ async def _execute_graph(self, query: str) -> Answer:
139
+ sources = await self.retriever.retrieve(query)
140
+ context = "\n\n---\n\n".join([s.text for s in sources])
141
+
142
+ prompt = f"Answer this query using the graph summaries and retrieved text provided below:\n\nContext:\n{context}\n\nQuery: {query}"
143
+ response = await self.llm.agenerate(prompt, system="You are a graph-aware reasoning agent.")
144
+
145
+ return Answer(
146
+ query=query,
147
+ content=response,
148
+ sources=sources,
149
+ strategy_used=RAGStrategy.GRAPH
150
+ )
151
+
152
+ async def _execute_agentic(self, query: str) -> Answer:
153
+ history = []
154
+ max_steps = 3
155
+ sources = []
156
+
157
+ for step in range(max_steps):
158
+ prompt = f"Query: {query}\nHistory: {history}\n\nAction (SEARCH <term> or ANSWER <final answer>):"
159
+ action = await self.llm.agenerate(prompt, system="You are a ReAct agent. You can SEARCH or ANSWER.")
160
+
161
+ if "ANSWER" in action:
162
+ final_ans = action.split("ANSWER")[-1].strip(': \n')
163
+ return Answer(
164
+ query=query,
165
+ content=final_ans,
166
+ sources=sources,
167
+ strategy_used=RAGStrategy.AGENTIC
168
+ )
169
+ elif "SEARCH" in action:
170
+ search_term = action.split("SEARCH")[-1].strip(': \n')
171
+ step_sources = await self.retriever.retrieve(search_term, top_k=2)
172
+ sources.extend(step_sources)
173
+ context = "\n".join([s.text for s in step_sources])
174
+ history.append(f"Searched: {search_term}\nFound context length: {len(context)}")
175
+ else:
176
+ break
177
+
178
+ return await self._execute_vector(query)
@@ -0,0 +1,169 @@
1
+ """
2
+ Layer 3: SELF-OPTIMIZING CHUNKING
3
+ Configures and dispatches optimal chunking strategy.
4
+ """
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, Any
7
+ import hashlib
8
+ from loguru import logger
9
+ import asyncio
10
+
11
+ from ragbox.models.documents import Document
12
+ from ragbox.models.chunks import Chunk, TextChunk
13
+ from ragbox.utils.llm_clients import LLMClient
14
+ from ragbox.utils.embeddings import EmbeddingProvider
15
+
16
+ class ChunkingStrategy(ABC):
17
+ @abstractmethod
18
+ def chunk(self, document: Document) -> List[Chunk]:
19
+ """Synchronously chunk a document."""
20
+ pass
21
+
22
+
23
+ class FixedChunker(ChunkingStrategy):
24
+ """Fallback basic text chunker."""
25
+ def __init__(self, chunk_size: int = 1000, overlap: int = 200):
26
+ self.chunk_size = chunk_size
27
+ self.overlap = overlap
28
+
29
+ def chunk(self, document: Document) -> List[Chunk]:
30
+ chunks = []
31
+ text = document.content
32
+ if not text:
33
+ return chunks
34
+
35
+ start = 0
36
+ while start < len(text):
37
+ end = start + self.chunk_size
38
+ chunk_text = text[start:end]
39
+ chunk_id = hashlib.sha256(f"{document.id}_{start}".encode()).hexdigest()
40
+ chunks.append(TextChunk(
41
+ id=chunk_id,
42
+ document_id=document.id,
43
+ content=chunk_text,
44
+ metadata={"start_idx": start, "end_idx": end, "strategy": "fixed"}
45
+ ))
46
+ start += self.chunk_size - self.overlap
47
+ return chunks
48
+
49
+
50
+ class SentenceChunker(ChunkingStrategy):
51
+ """Chunker that attempts to split on sentences to preserve context."""
52
+ def __init__(self, max_sentences: int = 5):
53
+ self.max_sentences = max_sentences
54
+
55
+ def chunk(self, document: Document) -> List[Chunk]:
56
+ chunks = []
57
+ text = document.content
58
+ if not text:
59
+ return chunks
60
+
61
+ import re
62
+ # very naive sentence splitting
63
+ sentences = re.split(r'(?<=[.!?]) +', text)
64
+
65
+ current_chunk = []
66
+ for i, sentence in enumerate(sentences):
67
+ current_chunk.append(sentence)
68
+ if len(current_chunk) >= self.max_sentences or i == len(sentences) - 1:
69
+ chunk_text = " ".join(current_chunk)
70
+ chunk_id = hashlib.sha256(f"{document.id}_{i}".encode()).hexdigest()
71
+ chunks.append(TextChunk(
72
+ id=chunk_id,
73
+ document_id=document.id,
74
+ content=chunk_text,
75
+ metadata={"strategy": "sentence"}
76
+ ))
77
+ # Slight overlap: keep the last sentence for the next chunk
78
+ current_chunk = [current_chunk[-1]] if len(current_chunk) > 0 else []
79
+
80
+ return chunks
81
+
82
+
83
+ class SelfOptimizingChunker:
84
+ """Evaluates and selects best strategy."""
85
+ def __init__(self, llm_client: LLMClient):
86
+ self.llm = llm_client
87
+ self.strategies = {
88
+ "fixed_small": FixedChunker(chunk_size=500, overlap=100),
89
+ "fixed_large": FixedChunker(chunk_size=1500, overlap=300),
90
+ "sentence": SentenceChunker(max_sentences=8)
91
+ }
92
+
93
+ async def optimize(self, documents: List[Document]) -> ChunkingStrategy:
94
+ """Auto-evaluates which strategy is best by sampling."""
95
+ if not documents:
96
+ return self.strategies["fixed_large"]
97
+
98
+ # Sample document
99
+ sample_doc = documents[0]
100
+ # Only sample first 5000 chars to save tokens
101
+ sample_doc_truncated = Document(id="sample", path=sample_doc.path, content=sample_doc.content[:5000])
102
+
103
+ logger.debug(f"Auto-optimizing chunking strategy based on {sample_doc.path.name}...")
104
+
105
+ # Test strategies
106
+ evaluations = {}
107
+ for name, strategy in self.strategies.items():
108
+ chunks = strategy.chunk(sample_doc_truncated)
109
+ if not chunks:
110
+ continue
111
+
112
+ # Take the first ~3 chunks to show the LLM
113
+ sample_chunks_text = "\n---\n".join([c.content for c in chunks[:3]])
114
+
115
+ prompt = f"""
116
+ You are evaluating text chunking strategies for a RAG system.
117
+ Given this sample of document chunks extracted using strategy '{name}',
118
+ score it from 1 to 10 on how semantically coherent and self-contained the chunks are.
119
+ Return ONLY the integer score.
120
+
121
+ Chunks:
122
+ {sample_chunks_text}
123
+ """
124
+
125
+ try:
126
+ score_str = await self.llm.agenerate(prompt, system="You return only a single integer between 1 and 10.")
127
+ import re
128
+ numbers = re.findall(r'\d+', score_str)
129
+ score = int(numbers[0]) if numbers else 5
130
+ evaluations[name] = score
131
+ except Exception as e:
132
+ logger.warning(f"Chunk evaluation failed for {name}: {e}")
133
+ evaluations[name] = 5
134
+
135
+ if not evaluations:
136
+ return self.strategies["fixed_large"]
137
+
138
+ best_strategy_name = max(evaluations, key=evaluations.get)
139
+ logger.info(f"Auto-Optimized Chunking: Selected '{best_strategy_name}' (Scores: {evaluations})")
140
+ return self.strategies[best_strategy_name]
141
+
142
+
143
+ class ChunkingEngine:
144
+ """Entry point for Layer 3."""
145
+ def __init__(self, llm_client: LLMClient, embedding_provider: EmbeddingProvider):
146
+ self.llm = llm_client
147
+ self.embedding_provider = embedding_provider
148
+ self.optimizer = SelfOptimizingChunker(self.llm)
149
+
150
+ async def chunk(self, document: Document) -> List[Chunk]:
151
+ """Apply optimal chunking to document and embed chunks."""
152
+ # Use optimizer to pick the best strategy for this specific document
153
+ strategy = await self.optimizer.optimize([document])
154
+ chunks = await asyncio.to_thread(strategy.chunk, document)
155
+
156
+ if chunks:
157
+ try:
158
+ # Use Late Chunking / Contextual Retrieval
159
+ embeddings = await self.embedding_provider.embed_chunks_with_context(
160
+ document=document,
161
+ chunks=chunks,
162
+ llm_client=self.llm
163
+ )
164
+ for chunk, emb in zip(chunks, embeddings):
165
+ chunk.metadata["embedding"] = emb
166
+ except Exception as e:
167
+ logger.error(f"Failed to embed chunks for document {document.path}: {e}")
168
+
169
+ return chunks