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
|
@@ -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)
|