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.
Files changed (96) hide show
  1. raglib/__init__.py +17 -0
  2. raglib/components/__init__.py +41 -0
  3. raglib/components/context_reducer.py +65 -0
  4. raglib/components/decision.py +63 -0
  5. raglib/components/deduplicator.py +58 -0
  6. raglib/components/evaluator.py +57 -0
  7. raglib/components/generator.py +91 -0
  8. raglib/components/hybrid_retriever.py +99 -0
  9. raglib/components/memory.py +58 -0
  10. raglib/components/multi_hop_retriever.py +40 -0
  11. raglib/components/multi_query_retriever.py +63 -0
  12. raglib/components/planner.py +44 -0
  13. raglib/components/query_expander.py +52 -0
  14. raglib/components/query_rewriter.py +38 -0
  15. raglib/components/refiner.py +37 -0
  16. raglib/components/reflection.py +48 -0
  17. raglib/components/reranker.py +54 -0
  18. raglib/components/retriever.py +342 -0
  19. raglib/components/router_retriever.py +70 -0
  20. raglib/components/web_retriever.py +36 -0
  21. raglib/config.py +39 -0
  22. raglib/constants.py +33 -0
  23. raglib/core/__init__.py +5 -0
  24. raglib/core/base.py +211 -0
  25. raglib/embedding/__init__.py +25 -0
  26. raglib/embedding/base_embedding.py +26 -0
  27. raglib/embedding/embedding_factory.py +52 -0
  28. raglib/embedding/google_embedding.py +53 -0
  29. raglib/embedding/huggingface_embedding.py +56 -0
  30. raglib/embedding/mock_embedding.py +51 -0
  31. raglib/embedding/ollama_embedding.py +59 -0
  32. raglib/embedding/openai_embedding.py +55 -0
  33. raglib/facade.py +459 -0
  34. raglib/llm/__init__.py +6 -0
  35. raglib/llm/base_client.py +21 -0
  36. raglib/llm/constants.py +32 -0
  37. raglib/llm/langchain_adapter.py +193 -0
  38. raglib/llm/mock_client.py +116 -0
  39. raglib/llm/provider_detector.py +162 -0
  40. raglib/loaders/__init__.py +26 -0
  41. raglib/loaders/chunk_splitter.py +129 -0
  42. raglib/loaders/document_loader.py +129 -0
  43. raglib/loaders/exceptions.py +11 -0
  44. raglib/loaders/pdf_loader.py +114 -0
  45. raglib/loaders/plain_text_loader.py +34 -0
  46. raglib/loaders/powerpoint_loader.py +49 -0
  47. raglib/loaders/pptx_loader.py +52 -0
  48. raglib/loaders/text_loader.py +39 -0
  49. raglib/loaders/web_loader.py +48 -0
  50. raglib/loaders/webpage_loader.py +84 -0
  51. raglib/loaders/word_loader.py +50 -0
  52. raglib/main.py +271 -0
  53. raglib/preprocessing/__init__.py +5 -0
  54. raglib/preprocessing/chunk_splitter.py +104 -0
  55. raglib/providers/__init__.py +31 -0
  56. raglib/providers/base_provider.py +73 -0
  57. raglib/providers/bing_provider.py +83 -0
  58. raglib/providers/brave_provider.py +91 -0
  59. raglib/providers/duckduckgo_provider.py +83 -0
  60. raglib/providers/exa_provider.py +88 -0
  61. raglib/providers/google_cse_provider.py +88 -0
  62. raglib/providers/searxng_provider.py +93 -0
  63. raglib/providers/serpapi_provider.py +84 -0
  64. raglib/providers/tavily_provider.py +93 -0
  65. raglib/rag_types/__init__.py +29 -0
  66. raglib/rag_types/advanced_rag.py +65 -0
  67. raglib/rag_types/agentic_rag.py +67 -0
  68. raglib/rag_types/corrective_rag.py +72 -0
  69. raglib/rag_types/hybrid_rag.py +54 -0
  70. raglib/rag_types/memory_rag.py +57 -0
  71. raglib/rag_types/multi_hop_rag.py +65 -0
  72. raglib/rag_types/multi_query_rag.py +63 -0
  73. raglib/rag_types/naive_rag.py +46 -0
  74. raglib/rag_types/routing_rag.py +47 -0
  75. raglib/rag_types/self_rag.py +85 -0
  76. raglib/rag_types/tool_augmented_rag.py +65 -0
  77. raglib/rag_types/web_rag.py +54 -0
  78. raglib/schemas.py +50 -0
  79. raglib/session/__init__.py +11 -0
  80. raglib/session/interactive.py +121 -0
  81. raglib/vectorstores/__init__.py +21 -0
  82. raglib/vectorstores/base_store.py +44 -0
  83. raglib/vectorstores/chroma_store.py +106 -0
  84. raglib/vectorstores/factory.py +130 -0
  85. raglib/vectorstores/in_memory_store.py +64 -0
  86. raglib/vision/__init__.py +23 -0
  87. raglib/vision/anthropic_vision.py +82 -0
  88. raglib/vision/base_vision.py +16 -0
  89. raglib/vision/google_vision.py +75 -0
  90. raglib/vision/mock_vision.py +19 -0
  91. raglib/vision/openai_vision.py +76 -0
  92. raglib/vision/vision_factory.py +43 -0
  93. raglib_py-0.1.3.dist-info/METADATA +161 -0
  94. raglib_py-0.1.3.dist-info/RECORD +96 -0
  95. raglib_py-0.1.3.dist-info/WHEEL +4 -0
  96. 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]