raglib-py 0.1.3__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.
- raglib/__init__.py +17 -0
- raglib/components/__init__.py +41 -0
- raglib/components/context_reducer.py +65 -0
- raglib/components/decision.py +63 -0
- raglib/components/deduplicator.py +58 -0
- raglib/components/evaluator.py +57 -0
- raglib/components/generator.py +91 -0
- raglib/components/hybrid_retriever.py +99 -0
- raglib/components/memory.py +58 -0
- raglib/components/multi_hop_retriever.py +40 -0
- raglib/components/multi_query_retriever.py +63 -0
- raglib/components/planner.py +44 -0
- raglib/components/query_expander.py +52 -0
- raglib/components/query_rewriter.py +38 -0
- raglib/components/refiner.py +37 -0
- raglib/components/reflection.py +48 -0
- raglib/components/reranker.py +54 -0
- raglib/components/retriever.py +342 -0
- raglib/components/router_retriever.py +70 -0
- raglib/components/web_retriever.py +36 -0
- raglib/config.py +39 -0
- raglib/constants.py +33 -0
- raglib/core/__init__.py +5 -0
- raglib/core/base.py +211 -0
- raglib/embedding/__init__.py +25 -0
- raglib/embedding/base_embedding.py +26 -0
- raglib/embedding/embedding_factory.py +52 -0
- raglib/embedding/google_embedding.py +53 -0
- raglib/embedding/huggingface_embedding.py +56 -0
- raglib/embedding/mock_embedding.py +51 -0
- raglib/embedding/ollama_embedding.py +59 -0
- raglib/embedding/openai_embedding.py +55 -0
- raglib/facade.py +459 -0
- raglib/llm/__init__.py +6 -0
- raglib/llm/base_client.py +21 -0
- raglib/llm/constants.py +32 -0
- raglib/llm/langchain_adapter.py +193 -0
- raglib/llm/mock_client.py +116 -0
- raglib/llm/provider_detector.py +162 -0
- raglib/loaders/__init__.py +26 -0
- raglib/loaders/chunk_splitter.py +129 -0
- raglib/loaders/document_loader.py +129 -0
- raglib/loaders/exceptions.py +11 -0
- raglib/loaders/pdf_loader.py +114 -0
- raglib/loaders/plain_text_loader.py +34 -0
- raglib/loaders/powerpoint_loader.py +49 -0
- raglib/loaders/pptx_loader.py +52 -0
- raglib/loaders/text_loader.py +39 -0
- raglib/loaders/web_loader.py +48 -0
- raglib/loaders/webpage_loader.py +84 -0
- raglib/loaders/word_loader.py +50 -0
- raglib/main.py +271 -0
- raglib/preprocessing/__init__.py +5 -0
- raglib/preprocessing/chunk_splitter.py +104 -0
- raglib/providers/__init__.py +31 -0
- raglib/providers/base_provider.py +73 -0
- raglib/providers/bing_provider.py +83 -0
- raglib/providers/brave_provider.py +91 -0
- raglib/providers/duckduckgo_provider.py +83 -0
- raglib/providers/exa_provider.py +88 -0
- raglib/providers/google_cse_provider.py +88 -0
- raglib/providers/searxng_provider.py +93 -0
- raglib/providers/serpapi_provider.py +84 -0
- raglib/providers/tavily_provider.py +93 -0
- raglib/rag_types/__init__.py +29 -0
- raglib/rag_types/advanced_rag.py +65 -0
- raglib/rag_types/agentic_rag.py +67 -0
- raglib/rag_types/corrective_rag.py +72 -0
- raglib/rag_types/hybrid_rag.py +54 -0
- raglib/rag_types/memory_rag.py +57 -0
- raglib/rag_types/multi_hop_rag.py +65 -0
- raglib/rag_types/multi_query_rag.py +63 -0
- raglib/rag_types/naive_rag.py +46 -0
- raglib/rag_types/routing_rag.py +47 -0
- raglib/rag_types/self_rag.py +85 -0
- raglib/rag_types/tool_augmented_rag.py +65 -0
- raglib/rag_types/web_rag.py +54 -0
- raglib/schemas.py +50 -0
- raglib/session/__init__.py +11 -0
- raglib/session/interactive.py +121 -0
- raglib/vectorstores/__init__.py +21 -0
- raglib/vectorstores/base_store.py +44 -0
- raglib/vectorstores/chroma_store.py +106 -0
- raglib/vectorstores/factory.py +130 -0
- raglib/vectorstores/in_memory_store.py +64 -0
- raglib/vision/__init__.py +23 -0
- raglib/vision/anthropic_vision.py +82 -0
- raglib/vision/base_vision.py +16 -0
- raglib/vision/google_vision.py +75 -0
- raglib/vision/mock_vision.py +19 -0
- raglib/vision/openai_vision.py +76 -0
- raglib/vision/vision_factory.py +43 -0
- raglib_py-0.1.3.dist-info/METADATA +161 -0
- raglib_py-0.1.3.dist-info/RECORD +96 -0
- raglib_py-0.1.3.dist-info/WHEEL +4 -0
- raglib_py-0.1.3.dist-info/licenses/LICENSE +21 -0
raglib/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Public package exports for raglib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from raglib.facade import RAG
|
|
6
|
+
from raglib.llm.mock_client import MockLLMClient
|
|
7
|
+
from raglib.schemas import Document, GenerationResult, QueryResult
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RAG",
|
|
11
|
+
"Document",
|
|
12
|
+
"QueryResult",
|
|
13
|
+
"GenerationResult",
|
|
14
|
+
"MockLLMClient",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.3"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Component exports for raglib retrieval and generation building blocks."""
|
|
2
|
+
|
|
3
|
+
from raglib.components.context_reducer import ContextReducer
|
|
4
|
+
from raglib.components.decision import DecisionEngine
|
|
5
|
+
from raglib.components.deduplicator import Deduplicator
|
|
6
|
+
from raglib.components.evaluator import Evaluator
|
|
7
|
+
from raglib.components.generator import Generator
|
|
8
|
+
from raglib.components.hybrid_retriever import HybridRetriever
|
|
9
|
+
from raglib.components.memory import MemoryModule
|
|
10
|
+
from raglib.components.multi_hop_retriever import MultiHopRetriever
|
|
11
|
+
from raglib.components.multi_query_retriever import MultiQueryRetriever
|
|
12
|
+
from raglib.components.planner import Planner
|
|
13
|
+
from raglib.components.query_expander import QueryExpander
|
|
14
|
+
from raglib.components.query_rewriter import QueryRewriter
|
|
15
|
+
from raglib.components.refiner import Refiner
|
|
16
|
+
from raglib.components.reflection import ReflectionModule
|
|
17
|
+
from raglib.components.reranker import Reranker
|
|
18
|
+
from raglib.components.retriever import Retriever
|
|
19
|
+
from raglib.components.router_retriever import RouterRetriever
|
|
20
|
+
from raglib.components.web_retriever import WebRetriever
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Retriever",
|
|
24
|
+
"WebRetriever",
|
|
25
|
+
"HybridRetriever",
|
|
26
|
+
"MultiQueryRetriever",
|
|
27
|
+
"RouterRetriever",
|
|
28
|
+
"MultiHopRetriever",
|
|
29
|
+
"Reranker",
|
|
30
|
+
"Evaluator",
|
|
31
|
+
"Refiner",
|
|
32
|
+
"QueryRewriter",
|
|
33
|
+
"QueryExpander",
|
|
34
|
+
"Deduplicator",
|
|
35
|
+
"ContextReducer",
|
|
36
|
+
"MemoryModule",
|
|
37
|
+
"Planner",
|
|
38
|
+
"ReflectionModule",
|
|
39
|
+
"DecisionEngine",
|
|
40
|
+
"Generator",
|
|
41
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Context reduction utilities for prompt token budgeting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from raglib.schemas import Document
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContextReducer:
|
|
15
|
+
"""Trims retrieved context to fit a target token budget."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, max_context_tokens: int = 3000):
|
|
18
|
+
"""Initialize context token budget."""
|
|
19
|
+
|
|
20
|
+
self.max_context_tokens = max_context_tokens
|
|
21
|
+
|
|
22
|
+
def reduce(self, documents: List[Document], max_tokens: Optional[int] = None) -> List[Document]:
|
|
23
|
+
"""Reduce documents until the token budget is satisfied."""
|
|
24
|
+
|
|
25
|
+
budget = max_tokens if max_tokens is not None else self.max_context_tokens
|
|
26
|
+
reduced: List[Document] = []
|
|
27
|
+
used_tokens = 0
|
|
28
|
+
|
|
29
|
+
for doc in documents:
|
|
30
|
+
doc_tokens = self._estimate_tokens(doc.content)
|
|
31
|
+
if used_tokens + doc_tokens <= budget:
|
|
32
|
+
reduced.append(doc)
|
|
33
|
+
used_tokens += doc_tokens
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
remaining = budget - used_tokens
|
|
37
|
+
if remaining <= 0:
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
trimmed_content = self._truncate_to_tokens(doc.content, remaining)
|
|
41
|
+
if trimmed_content.strip():
|
|
42
|
+
reduced.append(
|
|
43
|
+
replace(
|
|
44
|
+
doc,
|
|
45
|
+
content=trimmed_content,
|
|
46
|
+
metadata={**doc.metadata, "trimmed": True},
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
used_tokens = budget
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
logger.info("ContextReducer used %d/%d tokens", used_tokens, budget)
|
|
53
|
+
return reduced
|
|
54
|
+
|
|
55
|
+
def _estimate_tokens(self, text: str) -> int:
|
|
56
|
+
"""Estimate token count from whitespace-separated words."""
|
|
57
|
+
|
|
58
|
+
return max(1, int(len(text.split()) * 1.2))
|
|
59
|
+
|
|
60
|
+
def _truncate_to_tokens(self, text: str, max_tokens: int) -> str:
|
|
61
|
+
"""Truncate text to an approximate token count."""
|
|
62
|
+
|
|
63
|
+
words = text.split()
|
|
64
|
+
max_words = max(1, int(max_tokens / 1.2))
|
|
65
|
+
return " ".join(words[:max_words])
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Decision engine for retrieval gating and mode selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from raglib.llm.base_client import BaseLLMClient
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
RETRIEVAL_VECTOR: str = "vector"
|
|
13
|
+
RETRIEVAL_WEB: str = "web"
|
|
14
|
+
RETRIEVAL_HYBRID: str = "hybrid"
|
|
15
|
+
RETRIEVAL_NONE: str = "none"
|
|
16
|
+
ALLOWED_RETRIEVAL_TYPES = {
|
|
17
|
+
RETRIEVAL_VECTOR,
|
|
18
|
+
RETRIEVAL_WEB,
|
|
19
|
+
RETRIEVAL_HYBRID,
|
|
20
|
+
RETRIEVAL_NONE,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DecisionEngine:
|
|
25
|
+
"""Decides whether retrieval is needed and what retrieval mode to use."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, llm_client: Optional[BaseLLMClient] = None):
|
|
28
|
+
"""Initialize decision policy with an optional LLM client."""
|
|
29
|
+
|
|
30
|
+
self.llm_client = llm_client
|
|
31
|
+
|
|
32
|
+
def should_retrieve(self, query: str) -> bool:
|
|
33
|
+
"""Return whether retrieval should run for this query."""
|
|
34
|
+
|
|
35
|
+
route = self.retrieval_type(query)
|
|
36
|
+
decision = route != RETRIEVAL_NONE
|
|
37
|
+
logger.info("DecisionEngine should_retrieve=%s route=%s", decision, route)
|
|
38
|
+
return decision
|
|
39
|
+
|
|
40
|
+
def retrieval_type(self, query: str) -> str:
|
|
41
|
+
"""Return one of: vector, web, hybrid, none."""
|
|
42
|
+
|
|
43
|
+
if self.llm_client is not None:
|
|
44
|
+
prompt = (
|
|
45
|
+
"Decide retrieval_type for this query. Allowed: vector, web, hybrid, none.\n"
|
|
46
|
+
f"Query: {query}\n"
|
|
47
|
+
"Return one token only."
|
|
48
|
+
)
|
|
49
|
+
response = self.llm_client.complete(prompt=prompt).strip().lower()
|
|
50
|
+
if response in ALLOWED_RETRIEVAL_TYPES:
|
|
51
|
+
logger.info("DecisionEngine selected via LLM: %s", response)
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
lower = query.lower().strip()
|
|
55
|
+
if not lower:
|
|
56
|
+
return RETRIEVAL_NONE
|
|
57
|
+
if any(token in lower for token in ("hello", "thanks", "hi", "good morning")):
|
|
58
|
+
return RETRIEVAL_NONE
|
|
59
|
+
if any(token in lower for token in ("latest", "today", "news", "current", "web")):
|
|
60
|
+
return RETRIEVAL_WEB
|
|
61
|
+
if any(token in lower for token in ("compare", "versus", "vs", "trend")):
|
|
62
|
+
return RETRIEVAL_HYBRID
|
|
63
|
+
return RETRIEVAL_VECTOR
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Near-duplicate removal for retrieved document sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import List, Set
|
|
8
|
+
|
|
9
|
+
from raglib.schemas import Document
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Deduplicator:
|
|
15
|
+
"""Removes semantically similar duplicate document chunks."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, similarity_threshold: float = 0.85):
|
|
18
|
+
"""Initialize deduplication threshold."""
|
|
19
|
+
|
|
20
|
+
self.similarity_threshold = similarity_threshold
|
|
21
|
+
|
|
22
|
+
def deduplicate(self, documents: List[Document]) -> List[Document]:
|
|
23
|
+
"""Return documents after near-duplicate filtering."""
|
|
24
|
+
|
|
25
|
+
unique_docs: List[Document] = []
|
|
26
|
+
signatures: List[Set[str]] = []
|
|
27
|
+
|
|
28
|
+
for doc in documents:
|
|
29
|
+
signature = self._signature(doc.content)
|
|
30
|
+
if not signatures:
|
|
31
|
+
unique_docs.append(doc)
|
|
32
|
+
signatures.append(signature)
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
is_duplicate = any(
|
|
36
|
+
self._jaccard(signature, existing) >= self.similarity_threshold
|
|
37
|
+
for existing in signatures
|
|
38
|
+
)
|
|
39
|
+
if not is_duplicate:
|
|
40
|
+
unique_docs.append(doc)
|
|
41
|
+
signatures.append(signature)
|
|
42
|
+
|
|
43
|
+
logger.info("Deduplicator kept %d/%d documents", len(unique_docs), len(documents))
|
|
44
|
+
return unique_docs
|
|
45
|
+
|
|
46
|
+
def _signature(self, text: str) -> Set[str]:
|
|
47
|
+
"""Build a token-set signature for similarity checks."""
|
|
48
|
+
|
|
49
|
+
return set(re.findall(r"[a-zA-Z0-9]+", text.lower()))
|
|
50
|
+
|
|
51
|
+
def _jaccard(self, a: Set[str], b: Set[str]) -> float:
|
|
52
|
+
"""Compute Jaccard similarity between token signatures."""
|
|
53
|
+
|
|
54
|
+
if not a and not b:
|
|
55
|
+
return 1.0
|
|
56
|
+
if not a or not b:
|
|
57
|
+
return 0.0
|
|
58
|
+
return len(a & b) / len(a | b)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Retrieval quality evaluator used for corrective feedback loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import List, Set
|
|
9
|
+
|
|
10
|
+
from raglib.schemas import Document
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Evaluator:
|
|
16
|
+
"""Scores relevance and filters low-quality retrieval results."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, relevance_threshold: float = 0.3):
|
|
19
|
+
"""Initialize evaluator with a minimum relevance threshold."""
|
|
20
|
+
|
|
21
|
+
self.relevance_threshold = relevance_threshold
|
|
22
|
+
|
|
23
|
+
def evaluate(self, query: str, documents: List[Document]) -> List[Document]:
|
|
24
|
+
"""Filter documents by relevance score threshold."""
|
|
25
|
+
|
|
26
|
+
passed: List[Document] = []
|
|
27
|
+
for doc in documents:
|
|
28
|
+
score = self.score_document(query, doc)
|
|
29
|
+
if score >= self.relevance_threshold:
|
|
30
|
+
passed.append(replace(doc, score=score))
|
|
31
|
+
|
|
32
|
+
logger.info(
|
|
33
|
+
"Evaluator kept %d/%d documents with threshold %.2f",
|
|
34
|
+
len(passed),
|
|
35
|
+
len(documents),
|
|
36
|
+
self.relevance_threshold,
|
|
37
|
+
)
|
|
38
|
+
return passed
|
|
39
|
+
|
|
40
|
+
def score_document(self, query: str, doc: Document) -> float:
|
|
41
|
+
"""Compute a relevance score for a single document."""
|
|
42
|
+
|
|
43
|
+
query_terms = self._tokenize(query)
|
|
44
|
+
doc_terms = self._tokenize(doc.content)
|
|
45
|
+
if not query_terms or not doc_terms:
|
|
46
|
+
return 0.0
|
|
47
|
+
|
|
48
|
+
overlap = len(query_terms & doc_terms) / len(query_terms)
|
|
49
|
+
density = len(query_terms & doc_terms) / max(len(doc_terms), 1)
|
|
50
|
+
phrase_bonus = 0.2 if query.lower() in doc.content.lower() else 0.0
|
|
51
|
+
score = min(1.0, overlap * 0.7 + density * 0.3 + phrase_bonus)
|
|
52
|
+
return score
|
|
53
|
+
|
|
54
|
+
def _tokenize(self, text: str) -> Set[str]:
|
|
55
|
+
"""Tokenize text into a normalized set of terms."""
|
|
56
|
+
|
|
57
|
+
return set(re.findall(r"[a-zA-Z0-9]+", text.lower()))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Answer generation component with prompt template support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from raglib.llm.base_client import BaseLLMClient
|
|
9
|
+
from raglib.schemas import Document, GenerationResult
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_PROMPT_TEMPLATE: str = (
|
|
14
|
+
"You are a precise assistant. Use only the provided context when possible.\n"
|
|
15
|
+
"Query: {query}\n"
|
|
16
|
+
"Memory:\n{memory_context}\n"
|
|
17
|
+
"Context:\n{context}\n"
|
|
18
|
+
"Answer:"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Generator:
|
|
23
|
+
"""Builds prompts and generates final answers from retrieved context."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
llm_client: BaseLLMClient,
|
|
28
|
+
prompt_template: Optional[str] = None,
|
|
29
|
+
max_context_tokens: int = 3000,
|
|
30
|
+
):
|
|
31
|
+
"""Initialize generator with LLM client and prompt settings."""
|
|
32
|
+
|
|
33
|
+
self.llm_client = llm_client
|
|
34
|
+
self.prompt_template = prompt_template or DEFAULT_PROMPT_TEMPLATE
|
|
35
|
+
self.max_context_tokens = max_context_tokens
|
|
36
|
+
|
|
37
|
+
def generate(
|
|
38
|
+
self,
|
|
39
|
+
query: str,
|
|
40
|
+
documents: List[Document],
|
|
41
|
+
memory_context: str = "",
|
|
42
|
+
reasoning_trace: List[str] = [],
|
|
43
|
+
) -> GenerationResult:
|
|
44
|
+
"""Generate an answer from query, documents, and optional memory context."""
|
|
45
|
+
|
|
46
|
+
trace = list(reasoning_trace)
|
|
47
|
+
context_docs = self._truncate_documents(documents)
|
|
48
|
+
context_block = self._build_context(context_docs)
|
|
49
|
+
|
|
50
|
+
prompt = self.prompt_template.format(
|
|
51
|
+
query=query,
|
|
52
|
+
memory_context=memory_context,
|
|
53
|
+
context=context_block,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
context_tokens = sum(self._estimate_tokens(doc.content) for doc in context_docs)
|
|
57
|
+
logger.info(
|
|
58
|
+
"Generator sending prompt with %d context docs and %d context tokens",
|
|
59
|
+
len(context_docs),
|
|
60
|
+
context_tokens,
|
|
61
|
+
)
|
|
62
|
+
answer = self.llm_client.complete(prompt=prompt, system="Answer with grounded reasoning.")
|
|
63
|
+
trace.append(f"generated_with_docs={len(context_docs)}")
|
|
64
|
+
|
|
65
|
+
return GenerationResult(answer=answer, sources=context_docs, reasoning_trace=trace)
|
|
66
|
+
|
|
67
|
+
def _truncate_documents(self, documents: List[Document]) -> List[Document]:
|
|
68
|
+
"""Trim document set to fit max context token budget."""
|
|
69
|
+
|
|
70
|
+
selected: List[Document] = []
|
|
71
|
+
used = 0
|
|
72
|
+
for doc in documents:
|
|
73
|
+
tokens = self._estimate_tokens(doc.content)
|
|
74
|
+
if used + tokens > self.max_context_tokens:
|
|
75
|
+
break
|
|
76
|
+
selected.append(doc)
|
|
77
|
+
used += tokens
|
|
78
|
+
return selected
|
|
79
|
+
|
|
80
|
+
def _build_context(self, documents: List[Document]) -> str:
|
|
81
|
+
"""Build a source-tagged context block from documents."""
|
|
82
|
+
|
|
83
|
+
lines: List[str] = []
|
|
84
|
+
for idx, doc in enumerate(documents, start=1):
|
|
85
|
+
lines.append(f"[{idx}] ({doc.source}) {doc.content}")
|
|
86
|
+
return "\n\n".join(lines)
|
|
87
|
+
|
|
88
|
+
def _estimate_tokens(self, text: str) -> int:
|
|
89
|
+
"""Estimate token usage from text length."""
|
|
90
|
+
|
|
91
|
+
return max(1, len(text) // 4)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Hybrid retrieval that blends vector and web search signals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
|
|
9
|
+
from raglib.components.retriever import Retriever
|
|
10
|
+
from raglib.components.web_retriever import WebRetriever
|
|
11
|
+
from raglib.schemas import Document
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HybridRetriever:
|
|
17
|
+
"""Combines vector and web retrieval with weighted re-scoring."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
vector_retriever: Retriever,
|
|
22
|
+
web_retriever: WebRetriever,
|
|
23
|
+
vector_weight: float = 0.6,
|
|
24
|
+
web_weight: float = 0.4,
|
|
25
|
+
):
|
|
26
|
+
"""Initialize weighted retriever dependencies."""
|
|
27
|
+
|
|
28
|
+
self.vector_retriever = vector_retriever
|
|
29
|
+
self.web_retriever = web_retriever
|
|
30
|
+
total = max(vector_weight + web_weight, 1e-9)
|
|
31
|
+
self.vector_weight = vector_weight / total
|
|
32
|
+
self.web_weight = web_weight / total
|
|
33
|
+
|
|
34
|
+
def retrieve(self, query: str) -> List[Document]:
|
|
35
|
+
"""Retrieve and merge vector/web results with weighted scores."""
|
|
36
|
+
|
|
37
|
+
vector_docs = self.vector_retriever.retrieve(query) if self.vector_weight > 0 else []
|
|
38
|
+
web_docs = self.web_retriever.retrieve(query) if self.web_weight > 0 else []
|
|
39
|
+
|
|
40
|
+
normalized_vector = self._normalize_scores(vector_docs)
|
|
41
|
+
normalized_web = self._normalize_scores(web_docs)
|
|
42
|
+
|
|
43
|
+
merged: Dict[str, Document] = {}
|
|
44
|
+
|
|
45
|
+
for doc in normalized_vector:
|
|
46
|
+
key = self._doc_key(doc)
|
|
47
|
+
weighted = doc.score * self.vector_weight
|
|
48
|
+
merged[key] = replace(
|
|
49
|
+
doc,
|
|
50
|
+
score=weighted,
|
|
51
|
+
source=doc.source or "vector",
|
|
52
|
+
metadata={**doc.metadata, "hybrid_components": ["vector"]},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for doc in normalized_web:
|
|
56
|
+
key = self._doc_key(doc)
|
|
57
|
+
weighted = doc.score * self.web_weight
|
|
58
|
+
if key in merged:
|
|
59
|
+
base = merged[key]
|
|
60
|
+
components = list(base.metadata.get("hybrid_components", []))
|
|
61
|
+
components.append("web")
|
|
62
|
+
merged[key] = replace(
|
|
63
|
+
base,
|
|
64
|
+
score=base.score + weighted,
|
|
65
|
+
source="hybrid",
|
|
66
|
+
metadata={**base.metadata, "hybrid_components": components},
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
merged[key] = replace(
|
|
70
|
+
doc,
|
|
71
|
+
score=weighted,
|
|
72
|
+
source=doc.source or "web",
|
|
73
|
+
metadata={**doc.metadata, "hybrid_components": ["web"]},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
ranked = sorted(merged.values(), key=lambda d: d.score, reverse=True)
|
|
77
|
+
top_k = max(self.vector_retriever.top_k, self.web_retriever.top_k)
|
|
78
|
+
logger.info("HybridRetriever merged %d documents", len(ranked))
|
|
79
|
+
return ranked[:top_k]
|
|
80
|
+
|
|
81
|
+
def _normalize_scores(self, documents: List[Document]) -> List[Document]:
|
|
82
|
+
"""Normalize scores into [0, 1] for stable weighting."""
|
|
83
|
+
|
|
84
|
+
if not documents:
|
|
85
|
+
return []
|
|
86
|
+
scores = [doc.score for doc in documents]
|
|
87
|
+
max_score = max(scores)
|
|
88
|
+
min_score = min(scores)
|
|
89
|
+
span = max(max_score - min_score, 1e-9)
|
|
90
|
+
normalized: List[Document] = []
|
|
91
|
+
for doc in documents:
|
|
92
|
+
scaled = (doc.score - min_score) / span if max_score != min_score else 1.0
|
|
93
|
+
normalized.append(replace(doc, score=scaled))
|
|
94
|
+
return normalized
|
|
95
|
+
|
|
96
|
+
def _doc_key(self, doc: Document) -> str:
|
|
97
|
+
"""Build a stable merge key for duplicate detection."""
|
|
98
|
+
|
|
99
|
+
return doc.id or f"doc-{abs(hash(doc.content.lower().strip()))}"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Conversation memory store for RAG turn history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Deque, List
|
|
9
|
+
|
|
10
|
+
from raglib.schemas import Document
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class MemoryTurn:
|
|
17
|
+
"""Represents one conversational turn in memory."""
|
|
18
|
+
|
|
19
|
+
query: str
|
|
20
|
+
answer: str
|
|
21
|
+
documents: List[Document]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MemoryModule:
|
|
25
|
+
"""Stores and exposes bounded conversational memory context."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, max_turns: int = 10):
|
|
28
|
+
"""Initialize bounded memory storage."""
|
|
29
|
+
|
|
30
|
+
self.max_turns = max_turns
|
|
31
|
+
self._turns: Deque[MemoryTurn] = deque(maxlen=max_turns)
|
|
32
|
+
|
|
33
|
+
def add(self, query: str, answer: str, documents: List[Document]) -> None:
|
|
34
|
+
"""Add a turn to memory."""
|
|
35
|
+
|
|
36
|
+
self._turns.append(MemoryTurn(query=query, answer=answer, documents=documents))
|
|
37
|
+
logger.info("MemoryModule stored turn count=%d", len(self._turns))
|
|
38
|
+
|
|
39
|
+
def get_context(self) -> str:
|
|
40
|
+
"""Return serialized memory context for prompt injection."""
|
|
41
|
+
|
|
42
|
+
if not self._turns:
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
lines: List[str] = []
|
|
46
|
+
for idx, turn in enumerate(self._turns, start=1):
|
|
47
|
+
lines.append(f"Turn {idx} Query: {turn.query}")
|
|
48
|
+
lines.append(f"Turn {idx} Answer: {turn.answer}")
|
|
49
|
+
if turn.documents:
|
|
50
|
+
sources = ", ".join(doc.id for doc in turn.documents[:3])
|
|
51
|
+
lines.append(f"Turn {idx} Sources: {sources}")
|
|
52
|
+
return "\n".join(lines)
|
|
53
|
+
|
|
54
|
+
def clear(self) -> None:
|
|
55
|
+
"""Clear all memory turns."""
|
|
56
|
+
|
|
57
|
+
self._turns.clear()
|
|
58
|
+
logger.info("MemoryModule cleared")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Iterative multi-hop retrieval over planned sub-queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
from raglib.components.planner import Planner
|
|
9
|
+
from raglib.components.retriever import Retriever
|
|
10
|
+
from raglib.schemas import Document, QueryResult
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MultiHopRetriever:
|
|
16
|
+
"""Performs staged retrieval across multiple reasoning hops."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, retriever: Retriever, planner: Planner, max_hops: int = 3):
|
|
19
|
+
"""Initialize multi-hop retrieval dependencies."""
|
|
20
|
+
|
|
21
|
+
self.retriever = retriever
|
|
22
|
+
self.planner = planner
|
|
23
|
+
self.max_hops = max_hops
|
|
24
|
+
|
|
25
|
+
def retrieve(self, query: str) -> QueryResult:
|
|
26
|
+
"""Retrieve documents iteratively over planned hops."""
|
|
27
|
+
|
|
28
|
+
sub_queries = self.planner.plan(query)[: self.max_hops]
|
|
29
|
+
logger.info("MultiHopRetriever planned %d hops", len(sub_queries))
|
|
30
|
+
|
|
31
|
+
merged: Dict[str, Document] = {}
|
|
32
|
+
for hop_index, sub_query in enumerate(sub_queries, start=1):
|
|
33
|
+
logger.info("MultiHopRetriever hop=%d query=%s", hop_index, sub_query)
|
|
34
|
+
for doc in self.retriever.retrieve(sub_query):
|
|
35
|
+
key = doc.id or f"doc-{abs(hash(doc.content.lower().strip()))}"
|
|
36
|
+
if key not in merged or doc.score > merged[key].score:
|
|
37
|
+
merged[key] = doc
|
|
38
|
+
|
|
39
|
+
ranked = sorted(merged.values(), key=lambda doc: doc.score, reverse=True)
|
|
40
|
+
return QueryResult(query=query, documents=ranked, hop_count=len(sub_queries))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Multi-query retrieval via semantic expansion and result merging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from raglib.components.deduplicator import Deduplicator
|
|
10
|
+
from raglib.components.query_expander import QueryExpander
|
|
11
|
+
from raglib.components.retriever import Retriever
|
|
12
|
+
from raglib.schemas import Document
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MultiQueryRetriever:
|
|
18
|
+
"""Expands a query into variants and merges retrieval results."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
retriever: Retriever,
|
|
23
|
+
query_expander: QueryExpander,
|
|
24
|
+
deduplicator: Optional[Deduplicator] = None,
|
|
25
|
+
top_k: int = 5,
|
|
26
|
+
):
|
|
27
|
+
"""Initialize variant retrieval dependencies."""
|
|
28
|
+
|
|
29
|
+
self.retriever = retriever
|
|
30
|
+
self.query_expander = query_expander
|
|
31
|
+
self.deduplicator = deduplicator
|
|
32
|
+
self.top_k = top_k
|
|
33
|
+
|
|
34
|
+
def retrieve(self, query: str) -> List[Document]:
|
|
35
|
+
"""Retrieve documents across query variants and merge scores."""
|
|
36
|
+
|
|
37
|
+
variants = self.query_expander.expand(query)
|
|
38
|
+
logger.info("MultiQueryRetriever generated %d variants", len(variants))
|
|
39
|
+
|
|
40
|
+
merged: Dict[str, Document] = {}
|
|
41
|
+
counts: Dict[str, int] = {}
|
|
42
|
+
|
|
43
|
+
for variant in variants:
|
|
44
|
+
docs = self.retriever.retrieve(variant)
|
|
45
|
+
for doc in docs:
|
|
46
|
+
key = doc.id or f"doc-{abs(hash(doc.content.lower().strip()))}"
|
|
47
|
+
if key in merged:
|
|
48
|
+
previous = merged[key]
|
|
49
|
+
merged[key] = replace(previous, score=max(previous.score, doc.score))
|
|
50
|
+
counts[key] += 1
|
|
51
|
+
else:
|
|
52
|
+
merged[key] = doc
|
|
53
|
+
counts[key] = 1
|
|
54
|
+
|
|
55
|
+
blended = [
|
|
56
|
+
replace(doc, score=doc.score * (1.0 + 0.1 * (counts[key] - 1)))
|
|
57
|
+
for key, doc in merged.items()
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
ranked = sorted(blended, key=lambda d: d.score, reverse=True)
|
|
61
|
+
if self.deduplicator:
|
|
62
|
+
ranked = self.deduplicator.deduplicate(ranked)
|
|
63
|
+
return ranked[: self.top_k]
|