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 +175 -0
- ragbox/cli.py +106 -0
- ragbox/config/__init__.py +3 -0
- ragbox/config/defaults.py +28 -0
- ragbox/core/__init__.py +18 -0
- ragbox/core/agentic_orchestrator.py +178 -0
- ragbox/core/chunking_engine.py +169 -0
- ragbox/core/document_processor.py +206 -0
- ragbox/core/knowledge_graph.py +339 -0
- ragbox/core/reranker.py +73 -0
- ragbox/core/retrieval_fusion.py +92 -0
- ragbox/core/self_healing.py +334 -0
- ragbox/models/__init__.py +3 -0
- ragbox/models/chunks.py +35 -0
- ragbox/models/documents.py +51 -0
- ragbox/models/graph.py +41 -0
- ragbox/models/queries.py +39 -0
- ragbox/utils/__init__.py +1 -0
- ragbox/utils/cost_tracker.py +217 -0
- ragbox/utils/embeddings.py +180 -0
- ragbox/utils/llm_clients.py +271 -0
- ragbox/utils/vector_stores.py +273 -0
- ragbox_core-1.0.0.dist-info/METADATA +121 -0
- ragbox_core-1.0.0.dist-info/RECORD +27 -0
- ragbox_core-1.0.0.dist-info/WHEEL +4 -0
- ragbox_core-1.0.0.dist-info/entry_points.txt +3 -0
- ragbox_core-1.0.0.dist-info/licenses/LICENSE +21 -0
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,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
|
ragbox/core/__init__.py
ADDED
|
@@ -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
|