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
@@ -0,0 +1,44 @@
1
+ """Query decomposition planner for multi-step retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from typing import List
8
+
9
+ from raglib.llm.base_client import BaseLLMClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Planner:
15
+ """Breaks complex user queries into simpler sub-queries."""
16
+
17
+ def __init__(self, llm_client: BaseLLMClient):
18
+ """Initialize planner with an LLM client."""
19
+
20
+ self.llm_client = llm_client
21
+
22
+ def plan(self, query: str) -> List[str]:
23
+ """Decompose a query into ordered sub-queries."""
24
+
25
+ prompt = (
26
+ "Decompose this user query into concise sub-queries for retrieval.\n"
27
+ f"Query: {query}\n"
28
+ "Return one sub-query per line."
29
+ )
30
+ raw = self.llm_client.complete(prompt=prompt)
31
+ lines = [line.strip() for line in raw.splitlines() if line.strip()]
32
+
33
+ sub_queries: List[str] = []
34
+ for line in lines:
35
+ cleaned = re.sub(r"^\d+[\).:-]\s*", "", line)
36
+ if cleaned:
37
+ sub_queries.append(cleaned)
38
+
39
+ if not sub_queries:
40
+ parts = [p.strip() for p in re.split(r"\band\b|\bthen\b|\b,\b", query) if p.strip()]
41
+ sub_queries = parts if parts else [query]
42
+
43
+ logger.info("Planner produced %d sub-queries", len(sub_queries))
44
+ return sub_queries
@@ -0,0 +1,52 @@
1
+ """Query expansion component for multi-query retrieval."""
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
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class QueryExpander:
14
+ """Generates semantic variants of a user query."""
15
+
16
+ def __init__(self, llm_client: Optional[BaseLLMClient] = None, max_variants: int = 4):
17
+ """Initialize expansion settings and optional LLM client."""
18
+
19
+ self.llm_client = llm_client
20
+ self.max_variants = max_variants
21
+
22
+ def expand(self, query: str) -> List[str]:
23
+ """Expand a query into several retrieval variants."""
24
+
25
+ if self.llm_client is None:
26
+ variants = [
27
+ query,
28
+ f"{query} best practices",
29
+ f"{query} implementation",
30
+ f"{query} examples",
31
+ ]
32
+ else:
33
+ prompt = (
34
+ "Expand the query into semantic variants for retrieval.\n"
35
+ f"Query: {query}\n"
36
+ f"Return up to {self.max_variants} variants, one per line."
37
+ )
38
+ raw = self.llm_client.complete(prompt=prompt)
39
+ variants = [line.strip(" -") for line in raw.splitlines() if line.strip()]
40
+ variants.insert(0, query)
41
+
42
+ deduped: List[str] = []
43
+ seen = set()
44
+ for variant in variants:
45
+ key = variant.lower().strip()
46
+ if key and key not in seen:
47
+ deduped.append(variant.strip())
48
+ seen.add(key)
49
+
50
+ selected = deduped[: self.max_variants]
51
+ logger.info("QueryExpander produced %d variants", len(selected))
52
+ return selected
@@ -0,0 +1,38 @@
1
+ """Pre-retrieval query rewriting component."""
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
+
13
+ class QueryRewriter:
14
+ """Rewrites incoming queries into retrieval-friendly forms."""
15
+
16
+ def __init__(self, llm_client: Optional[BaseLLMClient] = None):
17
+ """Initialize an optional LLM-backed query rewriter."""
18
+
19
+ self.llm_client = llm_client
20
+
21
+ def rewrite(self, query: str) -> str:
22
+ """Rewrite a query while preserving user intent."""
23
+
24
+ if self.llm_client is None:
25
+ rewritten = query.strip()
26
+ logger.debug("QueryRewriter fallback used original query")
27
+ return rewritten
28
+
29
+ prompt = (
30
+ "Rewrite the user query for search relevance while preserving intent.\n"
31
+ f"Query: {query}\n"
32
+ "Return one rewritten query."
33
+ )
34
+ rewritten = self.llm_client.complete(prompt=prompt).strip()
35
+ if rewritten.lower().startswith("refined query:"):
36
+ rewritten = rewritten.split(":", 1)[1].strip()
37
+ logger.info("QueryRewriter output: %s", rewritten)
38
+ return rewritten or query
@@ -0,0 +1,37 @@
1
+ """Query refinement component for corrective retrieval loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import List
7
+
8
+ from raglib.llm.base_client import BaseLLMClient
9
+ from raglib.schemas import Document
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Refiner:
15
+ """Refines user queries after poor retrieval quality."""
16
+
17
+ def __init__(self, llm_client: BaseLLMClient, max_retries: int = 2):
18
+ """Initialize the refiner with an LLM client and retry policy."""
19
+
20
+ self.llm_client = llm_client
21
+ self.max_retries = max_retries
22
+
23
+ def refine(self, query: str, failed_docs: List[Document]) -> str:
24
+ """Generate a refined query from failed retrieval context."""
25
+
26
+ snippets = "\n".join(doc.content[:180] for doc in failed_docs[:3])
27
+ prompt = (
28
+ "Rewrite the query to improve retrieval quality.\n"
29
+ f"Original query: {query}\n"
30
+ f"Low-quality snippets:\n{snippets}\n"
31
+ "Return only the refined query."
32
+ )
33
+ refined = self.llm_client.complete(prompt=prompt).strip()
34
+ if refined.lower().startswith("refined query:"):
35
+ refined = refined.split(":", 1)[1].strip()
36
+ logger.info("Refiner transformed query from '%s' to '%s'", query, refined)
37
+ return refined or query
@@ -0,0 +1,48 @@
1
+ """Reflection module for deciding next retrieval action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import List
7
+
8
+ from raglib.components.evaluator import Evaluator
9
+ from raglib.schemas import Document
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ ACTION_SUFFICIENT: str = "sufficient"
14
+ ACTION_RETRY: str = "retry"
15
+ ACTION_WEB_FALLBACK: str = "web_fallback"
16
+ ACTION_GIVE_UP: str = "give_up"
17
+
18
+
19
+ class ReflectionModule:
20
+ """Inspects retrieval quality and recommends next action."""
21
+
22
+ def __init__(self, evaluator: Evaluator):
23
+ """Initialize reflection with an evaluator component."""
24
+
25
+ self.evaluator = evaluator
26
+
27
+ def reflect(self, query: str, documents: List[Document]) -> str:
28
+ """Return the next action based on current retrieval quality."""
29
+
30
+ if not documents:
31
+ logger.info("ReflectionModule found no documents; suggesting web fallback")
32
+ return ACTION_WEB_FALLBACK
33
+
34
+ scores = [self.evaluator.score_document(query, doc) for doc in documents]
35
+ average = sum(scores) / len(scores)
36
+ best = max(scores)
37
+
38
+ if average >= self.evaluator.relevance_threshold + 0.15:
39
+ action = ACTION_SUFFICIENT
40
+ elif best >= self.evaluator.relevance_threshold:
41
+ action = ACTION_RETRY
42
+ elif len(documents) >= 3:
43
+ action = ACTION_WEB_FALLBACK
44
+ else:
45
+ action = ACTION_GIVE_UP
46
+
47
+ logger.info("ReflectionModule action=%s avg=%.3f best=%.3f", action, average, best)
48
+ return action
@@ -0,0 +1,54 @@
1
+ """Document reranking utilities for post-retrieval relevance sorting."""
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 Reranker:
16
+ """Reranks candidate documents using lightweight lexical scoring."""
17
+
18
+ def __init__(self, top_k: int = 3):
19
+ """Initialize reranker settings."""
20
+
21
+ self.top_k = top_k
22
+
23
+ def rerank(self, query: str, documents: List[Document]) -> List[Document]:
24
+ """Return top-k reranked documents for a query."""
25
+
26
+ if not documents:
27
+ return []
28
+
29
+ query_terms = self._tokenize(query)
30
+ rescored: List[Document] = []
31
+ for doc in documents:
32
+ score = self._score(query_terms, doc)
33
+ rescored.append(replace(doc, score=score))
34
+
35
+ rescored.sort(key=lambda doc: doc.score, reverse=True)
36
+ logger.info("Reranker reduced %d docs to top %d", len(documents), self.top_k)
37
+ return rescored[: self.top_k]
38
+
39
+ def _score(self, query_terms: Set[str], doc: Document) -> float:
40
+ """Score a document using overlap and length normalization."""
41
+
42
+ doc_terms = self._tokenize(doc.content)
43
+ if not doc_terms:
44
+ return 0.0
45
+
46
+ overlap = len(query_terms & doc_terms)
47
+ overlap_ratio = overlap / max(len(query_terms), 1)
48
+ length_norm = min(len(doc_terms), 300) / 300.0
49
+ return 0.75 * overlap_ratio + 0.25 * length_norm
50
+
51
+ def _tokenize(self, text: str) -> Set[str]:
52
+ """Tokenize text into a set of lowercase lexical units."""
53
+
54
+ return set(re.findall(r"[a-zA-Z0-9]+", text.lower()))
@@ -0,0 +1,342 @@
1
+ """In-memory vector retriever with optional FAISS acceleration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ import re
8
+ from collections import Counter
9
+ from dataclasses import replace
10
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
11
+
12
+ from raglib.embedding.base_embedding import BaseEmbedding
13
+ from raglib.schemas import Document
14
+ from raglib.vectorstores.base_store import BaseVectorStore, VectorRecord
15
+ from raglib.vectorstores.factory import VectorStoreFactory
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Retriever:
21
+ """Retrieves relevant documents from an in-memory corpus."""
22
+
23
+ def __init__(
24
+ self,
25
+ documents: List[Document],
26
+ embedding: Optional[BaseEmbedding] = None,
27
+ top_k: int = 5,
28
+ vector_db: Optional[Union[str, BaseVectorStore]] = None,
29
+ vector_db_kwargs: Optional[Dict[str, Any]] = None,
30
+ ):
31
+ """Initialize retriever state and optional FAISS backend."""
32
+
33
+ self.top_k = top_k
34
+ self._embedding = embedding
35
+ self._vector_store: Optional[BaseVectorStore] = None
36
+ self._documents: List[Document] = []
37
+ self._vector_document_lookup: Dict[str, Document] = {}
38
+ self._doc_tokens: List[List[str]] = []
39
+ self._doc_tfidf: List[Dict[str, float]] = []
40
+ self._document_frequency: Counter[str] = Counter()
41
+ self._faiss_index = None
42
+ self._faiss_vocabulary: List[str] = []
43
+ self._faiss_ready = False
44
+ self._faiss_import_error: Optional[Exception] = None
45
+
46
+ if self._embedding is not None:
47
+ logger.info("Retriever using embedding model=%s", type(self._embedding).__name__)
48
+ self._vector_store = VectorStoreFactory.build(
49
+ vector_db=vector_db,
50
+ vector_db_kwargs=vector_db_kwargs,
51
+ )
52
+ logger.info("Retriever using vector store=%s", type(self._vector_store).__name__)
53
+
54
+ self._detect_faiss()
55
+ self.add_documents(documents)
56
+
57
+ def add_documents(self, documents: List[Document]) -> None:
58
+ """Add documents to the corpus and refresh vector indices."""
59
+
60
+ if not documents:
61
+ return
62
+ self._documents.extend(documents)
63
+ logger.info("Retriever added %d documents (total=%d)", len(documents), len(self._documents))
64
+ self._reindex()
65
+
66
+ def retrieve(self, query: str) -> List[Document]:
67
+ """Retrieve top-k documents ranked by query similarity."""
68
+
69
+ logger.debug("Retriever received query=%s", query)
70
+ if not self._documents:
71
+ logger.warning("Retriever called with empty corpus")
72
+ return []
73
+
74
+ if not query or not query.strip():
75
+ logger.info("Retriever returning empty result for empty query")
76
+ return []
77
+
78
+ if not self._tokenize(query):
79
+ logger.info("Retriever returning empty result for query with no indexable tokens")
80
+ return []
81
+
82
+ if self._embedding is not None and self._vector_store is not None:
83
+ embedding_docs = self._retrieve_with_embedding(query)
84
+ if embedding_docs:
85
+ logger.info("Retriever returned %d documents via embedding search", len(embedding_docs))
86
+ return embedding_docs
87
+ logger.warning("Embedding retrieval produced no results; falling back to sparse search")
88
+
89
+ if self._faiss_ready:
90
+ faiss_docs = self._retrieve_with_faiss(query)
91
+ if faiss_docs:
92
+ logger.info("Retriever returned %d documents", len(faiss_docs))
93
+ return faiss_docs
94
+
95
+ docs = self._retrieve_with_sparse_cosine(query)
96
+ logger.info("Retriever returned %d documents", len(docs))
97
+ return docs
98
+
99
+ def _detect_faiss(self) -> None:
100
+ """Detect FAISS availability without requiring it at runtime."""
101
+
102
+ try:
103
+ import faiss # type: ignore # noqa: F401
104
+ import numpy # type: ignore # noqa: F401
105
+
106
+ logger.info("FAISS backend detected and will be used when possible")
107
+ except Exception as exc: # noqa: BLE001
108
+ self._faiss_import_error = exc
109
+ logger.info("FAISS backend unavailable; using pure Python retriever")
110
+
111
+ def _reindex(self) -> None:
112
+ """Recompute sparse vectors and optional FAISS index."""
113
+
114
+ self._doc_tokens = [self._tokenize(doc.content) for doc in self._documents]
115
+ self._document_frequency = Counter()
116
+
117
+ for tokens in self._doc_tokens:
118
+ for token in set(tokens):
119
+ self._document_frequency[token] += 1
120
+
121
+ self._doc_tfidf = []
122
+ for tokens in self._doc_tokens:
123
+ tf = Counter(tokens)
124
+ self._doc_tfidf.append(self._counter_to_tfidf(tf, len(tokens)))
125
+
126
+ if self._embedding is not None and self._vector_store is not None and self._documents:
127
+ try:
128
+ texts = [document.content for document in self._documents]
129
+ vectors = self._embedding.embed_documents(texts)
130
+ records: List[VectorRecord] = []
131
+ self._vector_document_lookup = {}
132
+
133
+ limit = min(len(self._documents), len(vectors))
134
+ for index in range(limit):
135
+ document = self._documents[index]
136
+ vector = vectors[index]
137
+ vector_id = self._build_vector_id(document=document, index=index)
138
+ records.append(
139
+ VectorRecord(
140
+ document_id=vector_id,
141
+ embedding=vector,
142
+ document_text=document.content,
143
+ metadata={
144
+ **document.metadata,
145
+ "source": document.source,
146
+ "doc_id": document.id,
147
+ },
148
+ )
149
+ )
150
+ self._vector_document_lookup[vector_id] = document
151
+
152
+ self._vector_store.clear()
153
+ self._vector_store.upsert(records)
154
+ logger.info(
155
+ "Retriever indexed %d embedding vectors in %s (dim=%d)",
156
+ len(records),
157
+ type(self._vector_store).__name__,
158
+ self._embedding.dimension,
159
+ )
160
+ except Exception as exc: # noqa: BLE001
161
+ logger.warning("Embedding indexing failed; using sparse fallback only: %s", exc)
162
+ self._vector_document_lookup = {}
163
+
164
+ self._build_faiss_index()
165
+
166
+ def _build_faiss_index(self) -> None:
167
+ """Build FAISS index if optional dependencies are available."""
168
+
169
+ if self._embedding is not None:
170
+ self._faiss_ready = False
171
+ self._faiss_index = None
172
+ return
173
+
174
+ try:
175
+ import faiss # type: ignore
176
+ import numpy as np # type: ignore
177
+ except Exception:
178
+ self._faiss_ready = False
179
+ self._faiss_index = None
180
+ return
181
+
182
+ if not self._documents:
183
+ self._faiss_ready = False
184
+ self._faiss_index = None
185
+ return
186
+
187
+ vocabulary = sorted(self._document_frequency.keys())
188
+ if not vocabulary:
189
+ self._faiss_ready = False
190
+ self._faiss_index = None
191
+ return
192
+
193
+ dim = len(vocabulary)
194
+ matrix = np.zeros((len(self._documents), dim), dtype="float32")
195
+ for row_index, doc_vector in enumerate(self._doc_tfidf):
196
+ for term_index, term in enumerate(vocabulary):
197
+ matrix[row_index, term_index] = float(doc_vector.get(term, 0.0))
198
+
199
+ faiss.normalize_L2(matrix)
200
+ index = faiss.IndexFlatIP(dim)
201
+ index.add(matrix)
202
+
203
+ self._faiss_index = index
204
+ self._faiss_vocabulary = vocabulary
205
+ self._faiss_ready = True
206
+ logger.debug("FAISS index built with %d docs and dim=%d", len(self._documents), dim)
207
+
208
+ def _retrieve_with_faiss(self, query: str) -> List[Document]:
209
+ """Retrieve documents using FAISS inner-product search."""
210
+
211
+ if not self._faiss_ready or self._faiss_index is None:
212
+ return []
213
+
214
+ try:
215
+ import faiss # type: ignore
216
+ import numpy as np # type: ignore
217
+ except Exception:
218
+ return []
219
+
220
+ query_tokens = self._tokenize(query)
221
+ tf = Counter(query_tokens)
222
+ query_vector = self._counter_to_tfidf(tf, len(query_tokens))
223
+ dense = np.zeros((1, len(self._faiss_vocabulary)), dtype="float32")
224
+ for term_index, term in enumerate(self._faiss_vocabulary):
225
+ dense[0, term_index] = float(query_vector.get(term, 0.0))
226
+
227
+ faiss.normalize_L2(dense)
228
+ distances, indices = self._faiss_index.search(dense, min(self.top_k, len(self._documents)))
229
+
230
+ ranked: List[Tuple[float, int]] = []
231
+ for rank_idx, doc_idx in enumerate(indices[0].tolist()):
232
+ if doc_idx < 0:
233
+ continue
234
+ ranked.append((float(distances[0][rank_idx]), int(doc_idx)))
235
+
236
+ return self._to_scored_documents(ranked)
237
+
238
+ def _retrieve_with_sparse_cosine(self, query: str) -> List[Document]:
239
+ """Retrieve documents using sparse cosine similarity."""
240
+
241
+ query_tokens = self._tokenize(query)
242
+ query_tf = Counter(query_tokens)
243
+ query_vector = self._counter_to_tfidf(query_tf, len(query_tokens))
244
+
245
+ scored: List[Tuple[float, int]] = []
246
+ for idx, doc_vector in enumerate(self._doc_tfidf):
247
+ score = self._cosine_similarity(query_vector, doc_vector)
248
+ scored.append((score, idx))
249
+
250
+ scored.sort(key=lambda item: item[0], reverse=True)
251
+ top = scored[: min(self.top_k, len(scored))]
252
+ return self._to_scored_documents(top)
253
+
254
+ def _retrieve_with_embedding(self, query: str) -> List[Document]:
255
+ """Retrieve documents using dense embedding cosine similarity."""
256
+
257
+ if self._embedding is None or self._vector_store is None:
258
+ return []
259
+ if not self._vector_document_lookup:
260
+ return []
261
+
262
+ try:
263
+ query_vector = self._embedding.embed_query(query)
264
+ except Exception as exc: # noqa: BLE001
265
+ logger.warning("Embedding query failed; using sparse fallback: %s", exc)
266
+ return []
267
+
268
+ hits = self._vector_store.query(
269
+ query_embedding=query_vector,
270
+ top_k=min(self.top_k, len(self._vector_document_lookup)),
271
+ )
272
+ documents: List[Document] = []
273
+ for hit in hits:
274
+ base_document = self._vector_document_lookup.get(hit.document_id)
275
+ if base_document is None:
276
+ continue
277
+ logger.debug("Embedding score doc_id=%s score=%.4f", base_document.id, hit.score)
278
+ documents.append(
279
+ replace(
280
+ base_document,
281
+ score=float(hit.score),
282
+ source=base_document.source or "vector",
283
+ metadata={
284
+ **base_document.metadata,
285
+ "retriever": "vector_store",
286
+ "vector_db": type(self._vector_store).__name__,
287
+ },
288
+ )
289
+ )
290
+ return documents
291
+
292
+ def _to_scored_documents(self, scored_indices: Sequence[Tuple[float, int]]) -> List[Document]:
293
+ """Convert score/index pairs into copied scored Documents."""
294
+
295
+ docs: List[Document] = []
296
+ for score, doc_idx in scored_indices:
297
+ base = self._documents[doc_idx]
298
+ docs.append(
299
+ replace(
300
+ base,
301
+ score=float(score),
302
+ source=base.source or "vector",
303
+ metadata={**base.metadata, "retriever": "in_memory"},
304
+ )
305
+ )
306
+ return docs
307
+
308
+ def _counter_to_tfidf(self, term_counter: Counter[str], token_count: int) -> Dict[str, float]:
309
+ """Convert term counts to tf-idf sparse weights."""
310
+
311
+ if token_count <= 0:
312
+ return {}
313
+ vector: Dict[str, float] = {}
314
+ doc_total = max(len(self._documents), 1)
315
+ for term, count in term_counter.items():
316
+ tf = count / token_count
317
+ idf = math.log((1 + doc_total) / (1 + self._document_frequency.get(term, 0))) + 1.0
318
+ vector[term] = tf * idf
319
+ return vector
320
+
321
+ def _cosine_similarity(self, a: Dict[str, float], b: Dict[str, float]) -> float:
322
+ """Compute cosine similarity for sparse vectors."""
323
+
324
+ if not a or not b:
325
+ return 0.0
326
+ shared_terms = set(a.keys()) & set(b.keys())
327
+ dot = sum(a[t] * b[t] for t in shared_terms)
328
+ norm_a = math.sqrt(sum(v * v for v in a.values()))
329
+ norm_b = math.sqrt(sum(v * v for v in b.values()))
330
+ if norm_a == 0.0 or norm_b == 0.0:
331
+ return 0.0
332
+ return dot / (norm_a * norm_b)
333
+
334
+ def _build_vector_id(self, document: Document, index: int) -> str:
335
+ """Build a unique vector record id for a document entry."""
336
+
337
+ return f"{document.id}__{index}"
338
+
339
+ def _tokenize(self, text: str) -> List[str]:
340
+ """Tokenize text into lowercase alphanumeric terms."""
341
+
342
+ return re.findall(r"[a-zA-Z0-9]+", text.lower())
@@ -0,0 +1,70 @@
1
+ """Query router that dispatches to vector, web, or hybrid retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import List, Optional
7
+
8
+ from raglib.components.hybrid_retriever import HybridRetriever
9
+ from raglib.components.retriever import Retriever
10
+ from raglib.components.web_retriever import WebRetriever
11
+ from raglib.llm.base_client import BaseLLMClient
12
+ from raglib.schemas import Document
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ROUTE_VECTOR: str = "vector"
17
+ ROUTE_WEB: str = "web"
18
+ ROUTE_HYBRID: str = "hybrid"
19
+
20
+
21
+ class RouterRetriever:
22
+ """Routes retrieval requests to the most suitable retriever."""
23
+
24
+ def __init__(
25
+ self,
26
+ vector_retriever: Retriever,
27
+ web_retriever: WebRetriever,
28
+ hybrid_retriever: Optional[HybridRetriever] = None,
29
+ llm_client: Optional[BaseLLMClient] = None,
30
+ ):
31
+ """Initialize routing dependencies and optional LLM policy."""
32
+
33
+ self.vector_retriever = vector_retriever
34
+ self.web_retriever = web_retriever
35
+ self.hybrid_retriever = hybrid_retriever
36
+ self.llm_client = llm_client
37
+
38
+ def route(self, query: str) -> str:
39
+ """Return the best retrieval route for the query."""
40
+
41
+ lower = query.lower()
42
+ if self.llm_client is not None:
43
+ prompt = (
44
+ "Choose one retrieval mode: vector, web, hybrid.\n"
45
+ f"Query: {query}"
46
+ )
47
+ candidate = self.llm_client.complete(prompt=prompt).strip().lower()
48
+ if candidate in {ROUTE_VECTOR, ROUTE_WEB, ROUTE_HYBRID}:
49
+ logger.info("RouterRetriever route selected by LLM: %s", candidate)
50
+ return candidate
51
+
52
+ if any(token in lower for token in ("today", "latest", "news", "current", "web")):
53
+ route = ROUTE_WEB
54
+ elif any(token in lower for token in ("compare", "trend", "benchmark", "vs")):
55
+ route = ROUTE_HYBRID
56
+ else:
57
+ route = ROUTE_VECTOR
58
+
59
+ logger.info("RouterRetriever route selected by heuristic: %s", route)
60
+ return route
61
+
62
+ def retrieve(self, query: str) -> List[Document]:
63
+ """Retrieve documents based on selected route."""
64
+
65
+ route = self.route(query)
66
+ if route == ROUTE_WEB:
67
+ return self.web_retriever.retrieve(query)
68
+ if route == ROUTE_HYBRID and self.hybrid_retriever is not None:
69
+ return self.hybrid_retriever.retrieve(query)
70
+ return self.vector_retriever.retrieve(query)