ragleap-rag 0.1.0__tar.gz
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.
- ragleap_rag-0.1.0/.gitignore +32 -0
- ragleap_rag-0.1.0/PKG-INFO +105 -0
- ragleap_rag-0.1.0/README.md +68 -0
- ragleap_rag-0.1.0/pyproject.toml +48 -0
- ragleap_rag-0.1.0/src/ragleap/__init__.py +188 -0
- ragleap_rag-0.1.0/src/ragleap/chunker.py +67 -0
- ragleap_rag-0.1.0/src/ragleap/embedding.py +60 -0
- ragleap_rag-0.1.0/src/ragleap/generation.py +276 -0
- ragleap_rag-0.1.0/src/ragleap/parsers.py +69 -0
- ragleap_rag-0.1.0/src/ragleap/retrieval.py +195 -0
- ragleap_rag-0.1.0/src/ragleap/schema.py +58 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
*.env.local
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
venv/
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# Node
|
|
13
|
+
node_modules/
|
|
14
|
+
|
|
15
|
+
# Docker
|
|
16
|
+
docker-compose.override.yml
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.vscode/
|
|
20
|
+
.idea/
|
|
21
|
+
|
|
22
|
+
# OS
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
|
|
26
|
+
# Logs
|
|
27
|
+
*.log
|
|
28
|
+
.env
|
|
29
|
+
|
|
30
|
+
# Package build artifacts
|
|
31
|
+
dist/
|
|
32
|
+
build/
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ragleap-rag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A fast, honest, self-hosted RAG engine — hybrid dense+sparse retrieval, streaming, provider fallback, and real token usage reporting. BYOK, no vendor lock-in.
|
|
5
|
+
Project-URL: Homepage, https://github.com/antonyrag/ragleap-core
|
|
6
|
+
Project-URL: Repository, https://github.com/antonyrag/ragleap-core
|
|
7
|
+
Project-URL: Documentation, https://docs.ragleap.com
|
|
8
|
+
Project-URL: Issues, https://github.com/antonyrag/ragleap-core/issues
|
|
9
|
+
Author-email: Antony <antony@ragleap.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: hybrid-search,llm,pgvector,rag,retrieval-augmented-generation,vector-search
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: psycopg2-binary>=2.9.0
|
|
23
|
+
Requires-Dist: pypdf>=4.0.0
|
|
24
|
+
Requires-Dist: python-docx>=1.1.0
|
|
25
|
+
Requires-Dist: requests>=2.31.0
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: anthropic>=0.34.0; extra == 'all'
|
|
28
|
+
Requires-Dist: google-genai>=0.3.0; extra == 'all'
|
|
29
|
+
Requires-Dist: openai>=1.40.0; extra == 'all'
|
|
30
|
+
Provides-Extra: anthropic
|
|
31
|
+
Requires-Dist: anthropic>=0.34.0; extra == 'anthropic'
|
|
32
|
+
Provides-Extra: gemini
|
|
33
|
+
Requires-Dist: google-genai>=0.3.0; extra == 'gemini'
|
|
34
|
+
Provides-Extra: openai
|
|
35
|
+
Requires-Dist: openai>=1.40.0; extra == 'openai'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# ragleap-rag
|
|
39
|
+
|
|
40
|
+
A fast, honest, self-hosted RAG engine. Hybrid dense+sparse retrieval,
|
|
41
|
+
streaming, provider fallback, and real token usage reporting — no
|
|
42
|
+
vendor lock-in, bring your own API keys.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install ragleap-rag[gemini]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from ragleap import RagLeap, ProviderConfig
|
|
50
|
+
|
|
51
|
+
rag = RagLeap(
|
|
52
|
+
database_url="postgresql://user:pass@localhost/mydb",
|
|
53
|
+
embedder_api_key="your-gemini-key",
|
|
54
|
+
primary=ProviderConfig(provider="gemini", api_key="your-gemini-key"),
|
|
55
|
+
)
|
|
56
|
+
rag.init_schema() # one-time, idempotent
|
|
57
|
+
|
|
58
|
+
with open("document.pdf", "rb") as f:
|
|
59
|
+
result = rag.ingest("document.pdf", f.read())
|
|
60
|
+
print(f"Ingested {result.chunks_stored} chunks")
|
|
61
|
+
|
|
62
|
+
answer = rag.ask("What does this document say?")
|
|
63
|
+
print(answer["answer"])
|
|
64
|
+
print("Sources:", answer["sources"])
|
|
65
|
+
print("Tokens used:", answer["usage"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Why ragleap-rag
|
|
69
|
+
|
|
70
|
+
- **Hybrid search by default** — combines pgvector dense similarity with
|
|
71
|
+
Postgres full-text sparse search via Reciprocal Rank Fusion, catching
|
|
72
|
+
both semantic matches and exact keyword/identifier matches.
|
|
73
|
+
- **Real provider fallback** — configure backup LLM providers; if your
|
|
74
|
+
primary fails (rate limit, outage, bad key), it retries automatically.
|
|
75
|
+
- **Streaming** — real per-provider streaming for Gemini, Anthropic, and
|
|
76
|
+
any OpenAI-compatible endpoint.
|
|
77
|
+
- **Real token usage** — every answer reports actual `prompt_tokens`,
|
|
78
|
+
`completion_tokens`, `total_tokens` from the provider's own response,
|
|
79
|
+
not an estimate.
|
|
80
|
+
- **Context budget trimming** — automatically drops lowest-ranked chunks
|
|
81
|
+
to stay within a character budget, so you're not paying for more
|
|
82
|
+
context than necessary.
|
|
83
|
+
- **BYOK, always** — no system-provided keys, ever. You own your data,
|
|
84
|
+
your database, and your API costs.
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
A PostgreSQL database with the [pgvector](https://github.com/pgvector/pgvector)
|
|
89
|
+
extension available (`CREATE EXTENSION vector` — `rag.init_schema()` handles
|
|
90
|
+
the rest). Embeddings currently use Google Gemini (`gemini-embedding-001`);
|
|
91
|
+
generation supports Gemini, Anthropic, and any OpenAI-compatible provider
|
|
92
|
+
(OpenAI, Groq, Mistral, Together, OpenRouter, Ollama, DeepSeek, xAI, Cohere,
|
|
93
|
+
Perplexity, or a custom endpoint).
|
|
94
|
+
|
|
95
|
+
## Status
|
|
96
|
+
|
|
97
|
+
This is a young, actively-developed extraction from
|
|
98
|
+
[ragleap-core](https://github.com/antonyrag/ragleap-core), the open-source
|
|
99
|
+
self-hosted RAG platform. Full documentation, more embedding providers, and
|
|
100
|
+
companion packages (`ragleap-graph` for knowledge-graph-boosted retrieval,
|
|
101
|
+
`ragleap-integrations` for CRM/database connectors) are in progress.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ragleap-rag
|
|
2
|
+
|
|
3
|
+
A fast, honest, self-hosted RAG engine. Hybrid dense+sparse retrieval,
|
|
4
|
+
streaming, provider fallback, and real token usage reporting — no
|
|
5
|
+
vendor lock-in, bring your own API keys.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ragleap-rag[gemini]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from ragleap import RagLeap, ProviderConfig
|
|
13
|
+
|
|
14
|
+
rag = RagLeap(
|
|
15
|
+
database_url="postgresql://user:pass@localhost/mydb",
|
|
16
|
+
embedder_api_key="your-gemini-key",
|
|
17
|
+
primary=ProviderConfig(provider="gemini", api_key="your-gemini-key"),
|
|
18
|
+
)
|
|
19
|
+
rag.init_schema() # one-time, idempotent
|
|
20
|
+
|
|
21
|
+
with open("document.pdf", "rb") as f:
|
|
22
|
+
result = rag.ingest("document.pdf", f.read())
|
|
23
|
+
print(f"Ingested {result.chunks_stored} chunks")
|
|
24
|
+
|
|
25
|
+
answer = rag.ask("What does this document say?")
|
|
26
|
+
print(answer["answer"])
|
|
27
|
+
print("Sources:", answer["sources"])
|
|
28
|
+
print("Tokens used:", answer["usage"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Why ragleap-rag
|
|
32
|
+
|
|
33
|
+
- **Hybrid search by default** — combines pgvector dense similarity with
|
|
34
|
+
Postgres full-text sparse search via Reciprocal Rank Fusion, catching
|
|
35
|
+
both semantic matches and exact keyword/identifier matches.
|
|
36
|
+
- **Real provider fallback** — configure backup LLM providers; if your
|
|
37
|
+
primary fails (rate limit, outage, bad key), it retries automatically.
|
|
38
|
+
- **Streaming** — real per-provider streaming for Gemini, Anthropic, and
|
|
39
|
+
any OpenAI-compatible endpoint.
|
|
40
|
+
- **Real token usage** — every answer reports actual `prompt_tokens`,
|
|
41
|
+
`completion_tokens`, `total_tokens` from the provider's own response,
|
|
42
|
+
not an estimate.
|
|
43
|
+
- **Context budget trimming** — automatically drops lowest-ranked chunks
|
|
44
|
+
to stay within a character budget, so you're not paying for more
|
|
45
|
+
context than necessary.
|
|
46
|
+
- **BYOK, always** — no system-provided keys, ever. You own your data,
|
|
47
|
+
your database, and your API costs.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
A PostgreSQL database with the [pgvector](https://github.com/pgvector/pgvector)
|
|
52
|
+
extension available (`CREATE EXTENSION vector` — `rag.init_schema()` handles
|
|
53
|
+
the rest). Embeddings currently use Google Gemini (`gemini-embedding-001`);
|
|
54
|
+
generation supports Gemini, Anthropic, and any OpenAI-compatible provider
|
|
55
|
+
(OpenAI, Groq, Mistral, Together, OpenRouter, Ollama, DeepSeek, xAI, Cohere,
|
|
56
|
+
Perplexity, or a custom endpoint).
|
|
57
|
+
|
|
58
|
+
## Status
|
|
59
|
+
|
|
60
|
+
This is a young, actively-developed extraction from
|
|
61
|
+
[ragleap-core](https://github.com/antonyrag/ragleap-core), the open-source
|
|
62
|
+
self-hosted RAG platform. Full documentation, more embedding providers, and
|
|
63
|
+
companion packages (`ragleap-graph` for knowledge-graph-boosted retrieval,
|
|
64
|
+
`ragleap-integrations` for CRM/database connectors) are in progress.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ragleap-rag"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A fast, honest, self-hosted RAG engine — hybrid dense+sparse retrieval, streaming, provider fallback, and real token usage reporting. BYOK, no vendor lock-in."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Antony", email = "antony@ragleap.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["rag", "retrieval-augmented-generation", "llm", "vector-search", "pgvector", "hybrid-search"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Topic :: Text Processing :: Linguistic",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
dependencies = [
|
|
29
|
+
"psycopg2-binary>=2.9.0",
|
|
30
|
+
"requests>=2.31.0",
|
|
31
|
+
"pypdf>=4.0.0",
|
|
32
|
+
"python-docx>=1.1.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
gemini = ["google-genai>=0.3.0"]
|
|
37
|
+
anthropic = ["anthropic>=0.34.0"]
|
|
38
|
+
openai = ["openai>=1.40.0"]
|
|
39
|
+
all = ["google-genai>=0.3.0", "anthropic>=0.34.0", "openai>=1.40.0"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/antonyrag/ragleap-core"
|
|
43
|
+
Repository = "https://github.com/antonyrag/ragleap-core"
|
|
44
|
+
Documentation = "https://docs.ragleap.com"
|
|
45
|
+
Issues = "https://github.com/antonyrag/ragleap-core/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/ragleap"]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ragleap-rag: a fast, honest, self-hosted RAG engine.
|
|
3
|
+
|
|
4
|
+
from ragleap import RagLeap, ProviderConfig
|
|
5
|
+
|
|
6
|
+
rag = RagLeap(
|
|
7
|
+
database_url="postgresql://user:pass@localhost/mydb",
|
|
8
|
+
embedder_api_key="...", # Gemini API key (embeddings)
|
|
9
|
+
primary=ProviderConfig(provider="gemini", api_key="..."),
|
|
10
|
+
)
|
|
11
|
+
rag.init_schema() # one-time, idempotent
|
|
12
|
+
|
|
13
|
+
result = rag.ingest("doc.pdf", raw_bytes)
|
|
14
|
+
print(result.document_id, result.chunks_stored)
|
|
15
|
+
|
|
16
|
+
answer = rag.ask("What does this document say about X?")
|
|
17
|
+
print(answer["answer"], answer["sources"], answer["usage"])
|
|
18
|
+
"""
|
|
19
|
+
import uuid
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Dict, Iterator, List, Optional
|
|
23
|
+
|
|
24
|
+
from ragleap.chunker import TextChunker
|
|
25
|
+
from ragleap.embedding import EmbeddingService
|
|
26
|
+
from ragleap.retrieval import VectorRetrievalService
|
|
27
|
+
from ragleap.generation import GenerationService, ProviderConfig
|
|
28
|
+
from ragleap.parsers import extract_text
|
|
29
|
+
from ragleap import schema as _schema
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
__all__ = ["RagLeap", "ProviderConfig", "IngestResult"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class IngestResult:
|
|
39
|
+
document_id: str
|
|
40
|
+
chunks_stored: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RagLeap:
|
|
44
|
+
"""
|
|
45
|
+
The main entry point for ragleap-rag. Wires together chunking,
|
|
46
|
+
embedding, hybrid retrieval, and generation (with fallback,
|
|
47
|
+
streaming, and token usage reporting) over a PostgreSQL + pgvector
|
|
48
|
+
database you control.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
database_url: str,
|
|
54
|
+
primary: ProviderConfig,
|
|
55
|
+
embedder_api_key: Optional[str] = None,
|
|
56
|
+
fallbacks: Optional[List[ProviderConfig]] = None,
|
|
57
|
+
embedding_dimensions: int = 3072,
|
|
58
|
+
default_temperature: float = 0.3,
|
|
59
|
+
default_max_tokens: int = 1024,
|
|
60
|
+
max_context_chars: int = 12000,
|
|
61
|
+
chunk_size: Optional[int] = None,
|
|
62
|
+
chunk_overlap: Optional[int] = None,
|
|
63
|
+
):
|
|
64
|
+
self.database_url = database_url
|
|
65
|
+
self.embedding_dimensions = embedding_dimensions
|
|
66
|
+
|
|
67
|
+
self._chunker = TextChunker(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
|
68
|
+
self._embedder = EmbeddingService(api_key=embedder_api_key, dimensions=embedding_dimensions)
|
|
69
|
+
self._retriever = VectorRetrievalService(database_url=database_url, embedding_dimensions=embedding_dimensions)
|
|
70
|
+
self._generator = GenerationService(
|
|
71
|
+
primary=primary,
|
|
72
|
+
fallbacks=fallbacks,
|
|
73
|
+
default_temperature=default_temperature,
|
|
74
|
+
default_max_tokens=default_max_tokens,
|
|
75
|
+
max_context_chars=max_context_chars,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def init_schema(self) -> None:
|
|
79
|
+
"""Create the required tables/indexes if they don't already exist. Idempotent."""
|
|
80
|
+
_schema.init_schema(self.database_url, dimensions=self.embedding_dimensions)
|
|
81
|
+
|
|
82
|
+
def _get_connection(self):
|
|
83
|
+
import psycopg2
|
|
84
|
+
return psycopg2.connect(self.database_url)
|
|
85
|
+
|
|
86
|
+
def ingest(self, filename: str, raw_bytes: bytes) -> IngestResult:
|
|
87
|
+
"""
|
|
88
|
+
Extract text (from .txt/.pdf/.docx bytes), chunk, embed, and
|
|
89
|
+
store it. Returns an IngestResult with the new document_id and
|
|
90
|
+
chunk count.
|
|
91
|
+
"""
|
|
92
|
+
text = extract_text(filename, raw_bytes)
|
|
93
|
+
return self.ingest_text(filename, text)
|
|
94
|
+
|
|
95
|
+
def ingest_text(self, filename: str, text: str) -> IngestResult:
|
|
96
|
+
"""Same as ingest(), but for text you've already extracted yourself."""
|
|
97
|
+
chunks = self._chunker.chunk_text(text)
|
|
98
|
+
if not chunks:
|
|
99
|
+
raise ValueError("No chunks produced from input text — is it empty?")
|
|
100
|
+
|
|
101
|
+
document_id = str(uuid.uuid4())
|
|
102
|
+
conn = self._get_connection()
|
|
103
|
+
try:
|
|
104
|
+
cur = conn.cursor()
|
|
105
|
+
cur.execute("INSERT INTO documents (id, filename) VALUES (%s, %s)", (document_id, filename))
|
|
106
|
+
|
|
107
|
+
stored = 0
|
|
108
|
+
for chunk in chunks:
|
|
109
|
+
embedding = self._embedder.embed_text(chunk["text"])
|
|
110
|
+
if embedding is None:
|
|
111
|
+
logger.warning(f"Skipping chunk {chunk['chunk_index']} — embedding failed")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
embedding_literal = "[" + ",".join(str(float(x)) for x in embedding) + "]"
|
|
115
|
+
cur.execute(
|
|
116
|
+
"""
|
|
117
|
+
INSERT INTO chunks (document_id, document_name, chunk_index, text, token_count, embedding)
|
|
118
|
+
VALUES (%s, %s, %s, %s, %s, %s::vector)
|
|
119
|
+
""",
|
|
120
|
+
(document_id, filename, chunk["chunk_index"], chunk["text"], chunk["token_count"], embedding_literal),
|
|
121
|
+
)
|
|
122
|
+
stored += 1
|
|
123
|
+
|
|
124
|
+
if stored == 0:
|
|
125
|
+
conn.rollback()
|
|
126
|
+
raise ValueError(f"All {len(chunks)} chunk(s) failed to embed — nothing was stored.")
|
|
127
|
+
|
|
128
|
+
conn.commit()
|
|
129
|
+
cur.close()
|
|
130
|
+
logger.info(f"Ingested '{filename}': {stored}/{len(chunks)} chunks stored")
|
|
131
|
+
return IngestResult(document_id=document_id, chunks_stored=stored)
|
|
132
|
+
except Exception:
|
|
133
|
+
conn.rollback()
|
|
134
|
+
raise
|
|
135
|
+
finally:
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
def ask(
|
|
139
|
+
self,
|
|
140
|
+
query: str,
|
|
141
|
+
top_k: int = 5,
|
|
142
|
+
temperature: Optional[float] = None,
|
|
143
|
+
system_prompt: Optional[str] = None,
|
|
144
|
+
max_tokens: Optional[int] = None,
|
|
145
|
+
hybrid: bool = True,
|
|
146
|
+
) -> Dict:
|
|
147
|
+
"""
|
|
148
|
+
Answer a question grounded in previously ingested documents.
|
|
149
|
+
Returns: {"answer": str, "sources": List[str], "provider_used": str,
|
|
150
|
+
"usage": dict|None, "chunks_sent": int}
|
|
151
|
+
"""
|
|
152
|
+
query_embedding = self._embedder.embed_text(query)
|
|
153
|
+
if query_embedding is None:
|
|
154
|
+
return {"answer": "Sorry, I couldn't process your question (embedding failed).",
|
|
155
|
+
"sources": [], "provider_used": None, "usage": None, "chunks_sent": 0}
|
|
156
|
+
|
|
157
|
+
if hybrid:
|
|
158
|
+
chunks = self._retriever.search_hybrid_chunks(query, query_embedding, top_k=top_k)
|
|
159
|
+
else:
|
|
160
|
+
chunks = self._retriever.search_similar_chunks(query_embedding, top_k=top_k)
|
|
161
|
+
|
|
162
|
+
return self._generator.generate_answer(
|
|
163
|
+
query, chunks, temperature=temperature, system_prompt=system_prompt, max_tokens=max_tokens
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def ask_stream(
|
|
167
|
+
self,
|
|
168
|
+
query: str,
|
|
169
|
+
top_k: int = 5,
|
|
170
|
+
temperature: Optional[float] = None,
|
|
171
|
+
system_prompt: Optional[str] = None,
|
|
172
|
+
max_tokens: Optional[int] = None,
|
|
173
|
+
hybrid: bool = True,
|
|
174
|
+
) -> Iterator[str]:
|
|
175
|
+
"""Same as ask(), but yields the answer incrementally as it's generated."""
|
|
176
|
+
query_embedding = self._embedder.embed_text(query)
|
|
177
|
+
if query_embedding is None:
|
|
178
|
+
yield "Sorry, I couldn't process your question (embedding failed)."
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if hybrid:
|
|
182
|
+
chunks = self._retriever.search_hybrid_chunks(query, query_embedding, top_k=top_k)
|
|
183
|
+
else:
|
|
184
|
+
chunks = self._retriever.search_similar_chunks(query_embedding, top_k=top_k)
|
|
185
|
+
|
|
186
|
+
yield from self._generator.generate_answer_stream(
|
|
187
|
+
query, chunks, temperature=temperature, system_prompt=system_prompt, max_tokens=max_tokens
|
|
188
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text Chunking for ragleap-rag.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
DEFAULT_CHUNK_SIZE = 512
|
|
10
|
+
DEFAULT_CHUNK_OVERLAP = 50
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TextChunker:
|
|
14
|
+
"""Splits documents into overlapping chunks for embedding and retrieval."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, chunk_size: int = None, chunk_overlap: int = None):
|
|
17
|
+
self.chunk_size = chunk_size or DEFAULT_CHUNK_SIZE
|
|
18
|
+
self.chunk_overlap = chunk_overlap or DEFAULT_CHUNK_OVERLAP
|
|
19
|
+
|
|
20
|
+
if self.chunk_overlap >= self.chunk_size:
|
|
21
|
+
raise ValueError("Chunk overlap must be less than chunk size")
|
|
22
|
+
|
|
23
|
+
logger.info(f"Initialized TextChunker: size={self.chunk_size}, overlap={self.chunk_overlap}")
|
|
24
|
+
|
|
25
|
+
def _tokenize(self, text: str) -> List[str]:
|
|
26
|
+
"""Simple whitespace tokenization."""
|
|
27
|
+
return text.split()
|
|
28
|
+
|
|
29
|
+
def chunk_text(self, text: str) -> List[dict]:
|
|
30
|
+
"""
|
|
31
|
+
Split text into overlapping chunks with metadata.
|
|
32
|
+
Returns a list of dicts with: text, chunk_index, token_count
|
|
33
|
+
"""
|
|
34
|
+
if not text or not text.strip():
|
|
35
|
+
logger.warning("Empty text provided for chunking")
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
tokens = self._tokenize(text)
|
|
39
|
+
if len(tokens) == 0:
|
|
40
|
+
logger.warning("No tokens extracted from text")
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
chunks = []
|
|
44
|
+
step = self.chunk_size - self.chunk_overlap
|
|
45
|
+
chunk_index = 0
|
|
46
|
+
|
|
47
|
+
for start in range(0, len(tokens), step):
|
|
48
|
+
end = min(start + self.chunk_size, len(tokens))
|
|
49
|
+
chunk_tokens = tokens[start:end]
|
|
50
|
+
chunk_text_content = " ".join(chunk_tokens)
|
|
51
|
+
|
|
52
|
+
chunks.append({
|
|
53
|
+
"text": chunk_text_content,
|
|
54
|
+
"chunk_index": chunk_index,
|
|
55
|
+
"token_count": len(chunk_tokens),
|
|
56
|
+
})
|
|
57
|
+
chunk_index += 1
|
|
58
|
+
|
|
59
|
+
if end == len(tokens):
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
logger.info(f"Chunked text into {len(chunks)} chunks")
|
|
63
|
+
return chunks
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_chunker(chunk_size: int = None, chunk_overlap: int = None) -> TextChunker:
|
|
67
|
+
return TextChunker(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Embedding for ragleap-rag.
|
|
3
|
+
Uses Google Gemini embeddings (gemini-embedding-001, 3072 dimensions).
|
|
4
|
+
Bring-your-own-key: pass api_key explicitly, or set GEMINI_API_KEY in
|
|
5
|
+
the environment as a convenience fallback (useful for scripts/notebooks,
|
|
6
|
+
but explicit is preferred for library usage inside a larger app).
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_MODEL = "models/gemini-embedding-001"
|
|
15
|
+
DEFAULT_DIMENSIONS = 3072
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmbeddingService:
|
|
19
|
+
"""Generates vector embeddings using Google Gemini."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, api_key: Optional[str] = None, model: str = None, dimensions: int = None):
|
|
22
|
+
self.api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
23
|
+
self.model = model or os.environ.get("GEMINI_EMBEDDING_MODEL", DEFAULT_MODEL)
|
|
24
|
+
self.dimensions = dimensions or int(os.environ.get("EMBEDDING_DIMENSIONS", str(DEFAULT_DIMENSIONS)))
|
|
25
|
+
|
|
26
|
+
if not self.api_key:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"No Gemini API key provided. Pass api_key= to EmbeddingService(), "
|
|
29
|
+
"or set GEMINI_API_KEY in your environment. Get a free key at "
|
|
30
|
+
"https://aistudio.google.com/apikey — there is no system-provided key."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def embed_text(self, text: str) -> Optional[List[float]]:
|
|
34
|
+
"""Generate an embedding vector for a single piece of text."""
|
|
35
|
+
if not text or not text.strip():
|
|
36
|
+
logger.warning("Empty text provided for embedding")
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import google.genai as genai
|
|
41
|
+
client = genai.Client(api_key=self.api_key)
|
|
42
|
+
response = client.models.embed_content(model=self.model, contents=text)
|
|
43
|
+
return response.embeddings[0].values
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(f"Embedding generation failed: {e}")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def embed_batch(self, texts: List[str]) -> List[Optional[List[float]]]:
|
|
49
|
+
"""Generate embeddings for multiple texts."""
|
|
50
|
+
if not texts:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import google.genai as genai
|
|
55
|
+
client = genai.Client(api_key=self.api_key)
|
|
56
|
+
response = client.models.embed_content(model=self.model, contents=texts)
|
|
57
|
+
return [e.values for e in response.embeddings]
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error(f"Batch embedding generation failed: {e}")
|
|
60
|
+
return [None] * len(texts)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Answer generation for ragleap-rag.
|
|
3
|
+
Bring-your-own-key: supports Gemini, Anthropic, and any OpenAI-compatible
|
|
4
|
+
provider. Configure explicitly via ProviderConfig, or let it fall back to
|
|
5
|
+
environment variables for convenience (useful for scripts/notebooks).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import List, Dict, Iterator, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
PROVIDER_BASE_URLS = {
|
|
15
|
+
"openai": "https://api.openai.com/v1",
|
|
16
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
17
|
+
"groq": "https://api.groq.com/openai/v1",
|
|
18
|
+
"together": "https://api.together.xyz/v1",
|
|
19
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
20
|
+
"ollama": "http://localhost:11434/v1",
|
|
21
|
+
"deepseek": "https://api.deepseek.com/v1",
|
|
22
|
+
"xai": "https://api.x.ai/v1",
|
|
23
|
+
"cohere": "https://api.cohere.ai/v1",
|
|
24
|
+
"perplexity": "https://api.perplexity.ai",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
DEFAULT_SYSTEM_PROMPT = """You are a helpful assistant that answers questions using ONLY the provided context.
|
|
28
|
+
If the answer is not in the context, say clearly that you don't have that information — do not make things up.
|
|
29
|
+
Always be concise and cite which document your answer came from when possible."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ProviderConfig:
|
|
34
|
+
"""Explicit provider configuration. Use this for library integration
|
|
35
|
+
rather than relying on environment variables."""
|
|
36
|
+
provider: str
|
|
37
|
+
api_key: Optional[str] = None
|
|
38
|
+
model: Optional[str] = None
|
|
39
|
+
base_url: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self):
|
|
42
|
+
self.provider = self.provider.lower()
|
|
43
|
+
|
|
44
|
+
# Convenience env-var fallback (e.g. for quick scripts) — explicit
|
|
45
|
+
# values passed to the constructor always take precedence.
|
|
46
|
+
if self.provider == "gemini":
|
|
47
|
+
self.api_key = self.api_key or os.environ.get("GEMINI_API_KEY")
|
|
48
|
+
self.model = self.model or os.environ.get("GEMINI_CHAT_MODEL", "gemini-2.5-flash")
|
|
49
|
+
elif self.provider == "anthropic":
|
|
50
|
+
self.api_key = self.api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
51
|
+
self.model = self.model or os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5")
|
|
52
|
+
elif self.provider in PROVIDER_BASE_URLS:
|
|
53
|
+
self.api_key = self.api_key or os.environ.get(f"{self.provider.upper()}_API_KEY")
|
|
54
|
+
self.model = self.model or os.environ.get(f"{self.provider.upper()}_MODEL")
|
|
55
|
+
self.base_url = self.base_url or PROVIDER_BASE_URLS[self.provider]
|
|
56
|
+
elif self.provider == "custom":
|
|
57
|
+
self.base_url = self.base_url or os.environ.get("CUSTOM_BASE_URL")
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Unknown provider '{self.provider}'. Supported: gemini, anthropic, custom, "
|
|
61
|
+
f"{', '.join(PROVIDER_BASE_URLS.keys())}."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not self.api_key and self.provider != "ollama":
|
|
65
|
+
raise ValueError(f"No API key for provider '{self.provider}'. Pass api_key= explicitly.")
|
|
66
|
+
if self.provider not in ("gemini", "anthropic") and not self.base_url:
|
|
67
|
+
raise ValueError(f"No base_url for provider '{self.provider}'. Pass base_url= explicitly.")
|
|
68
|
+
if self.provider not in ("gemini", "anthropic") and not self.model:
|
|
69
|
+
raise ValueError(f"No model for provider '{self.provider}'. Pass model= explicitly.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GenerationService:
|
|
73
|
+
"""
|
|
74
|
+
Generates a grounded answer using the configured provider, with an
|
|
75
|
+
optional fallback chain, streaming, and real token usage reporting.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
primary: ProviderConfig,
|
|
81
|
+
fallbacks: Optional[List[ProviderConfig]] = None,
|
|
82
|
+
default_temperature: float = 0.3,
|
|
83
|
+
default_max_tokens: int = 1024,
|
|
84
|
+
max_context_chars: int = 12000,
|
|
85
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
86
|
+
):
|
|
87
|
+
self.primary = primary
|
|
88
|
+
self.fallbacks = fallbacks or []
|
|
89
|
+
self.default_temperature = default_temperature
|
|
90
|
+
self.default_max_tokens = default_max_tokens
|
|
91
|
+
self.max_context_chars = max_context_chars
|
|
92
|
+
self.system_prompt = system_prompt
|
|
93
|
+
|
|
94
|
+
def _chain(self) -> List[ProviderConfig]:
|
|
95
|
+
return [self.primary] + [f for f in self.fallbacks if f.provider != self.primary.provider]
|
|
96
|
+
|
|
97
|
+
def _trim_chunks_to_budget(self, chunks: List[Dict]) -> List[Dict]:
|
|
98
|
+
if self.max_context_chars <= 0 or not chunks:
|
|
99
|
+
return chunks
|
|
100
|
+
kept, running_total = [], 0
|
|
101
|
+
for chunk in chunks:
|
|
102
|
+
chunk_len = len(chunk.get("text", ""))
|
|
103
|
+
if running_total + chunk_len > self.max_context_chars and kept:
|
|
104
|
+
break
|
|
105
|
+
kept.append(chunk)
|
|
106
|
+
running_total += chunk_len
|
|
107
|
+
if len(kept) < len(chunks):
|
|
108
|
+
logger.info(f"Trimmed context: {len(chunks)} -> {len(kept)} chunks ({running_total} chars)")
|
|
109
|
+
return kept
|
|
110
|
+
|
|
111
|
+
def _build_context(self, chunks: List[Dict]) -> str:
|
|
112
|
+
if not chunks:
|
|
113
|
+
return "No relevant context was found."
|
|
114
|
+
parts = []
|
|
115
|
+
for i, chunk in enumerate(chunks, start=1):
|
|
116
|
+
doc_name = chunk.get("document_name", "unknown document")
|
|
117
|
+
parts.append(f"[Source {i}: {doc_name}]\n{chunk.get('text', '')}")
|
|
118
|
+
return "\n\n".join(parts)
|
|
119
|
+
|
|
120
|
+
def _build_prompt(self, query: str, chunks: List[Dict], system_prompt: Optional[str]) -> str:
|
|
121
|
+
context = self._build_context(chunks)
|
|
122
|
+
instructions = system_prompt or self.system_prompt
|
|
123
|
+
return f"{instructions}\n\nContext:\n{context}\n\nQuestion: {query}\nAnswer:"
|
|
124
|
+
|
|
125
|
+
def generate_answer(
|
|
126
|
+
self,
|
|
127
|
+
query: str,
|
|
128
|
+
chunks: List[Dict],
|
|
129
|
+
temperature: Optional[float] = None,
|
|
130
|
+
system_prompt: Optional[str] = None,
|
|
131
|
+
max_tokens: Optional[int] = None,
|
|
132
|
+
) -> Dict:
|
|
133
|
+
"""
|
|
134
|
+
Returns: {"answer": str, "sources": List[str], "provider_used": str,
|
|
135
|
+
"usage": dict|None, "chunks_sent": int}
|
|
136
|
+
"""
|
|
137
|
+
temp = self.default_temperature if temperature is None else temperature
|
|
138
|
+
max_tok = self.default_max_tokens if max_tokens is None else max_tokens
|
|
139
|
+
|
|
140
|
+
trimmed = self._trim_chunks_to_budget(chunks)
|
|
141
|
+
sources = list({c.get("document_name", "unknown") for c in trimmed})
|
|
142
|
+
prompt = self._build_prompt(query, trimmed, system_prompt)
|
|
143
|
+
|
|
144
|
+
last_error = None
|
|
145
|
+
for i, config in enumerate(self._chain()):
|
|
146
|
+
try:
|
|
147
|
+
answer_text, usage = self._call_provider(config, prompt, temp, max_tok)
|
|
148
|
+
if i > 0:
|
|
149
|
+
logger.info(f"Answer generated via fallback provider '{config.provider}'")
|
|
150
|
+
return {
|
|
151
|
+
"answer": answer_text, "sources": sources,
|
|
152
|
+
"provider_used": config.provider, "usage": usage,
|
|
153
|
+
"chunks_sent": len(trimmed),
|
|
154
|
+
}
|
|
155
|
+
except Exception as e:
|
|
156
|
+
last_error = e
|
|
157
|
+
logger.warning(f"Provider '{config.provider}' failed: {e}")
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"answer": f"Sorry, all configured providers failed. Last error: {last_error}",
|
|
162
|
+
"sources": [], "provider_used": None, "usage": None, "chunks_sent": 0,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def generate_answer_stream(
|
|
166
|
+
self,
|
|
167
|
+
query: str,
|
|
168
|
+
chunks: List[Dict],
|
|
169
|
+
temperature: Optional[float] = None,
|
|
170
|
+
system_prompt: Optional[str] = None,
|
|
171
|
+
max_tokens: Optional[int] = None,
|
|
172
|
+
) -> Iterator[str]:
|
|
173
|
+
"""Streams the answer incrementally. Usage reporting isn't
|
|
174
|
+
available for streaming (each provider handles it differently)."""
|
|
175
|
+
temp = self.default_temperature if temperature is None else temperature
|
|
176
|
+
max_tok = self.default_max_tokens if max_tokens is None else max_tokens
|
|
177
|
+
|
|
178
|
+
trimmed = self._trim_chunks_to_budget(chunks)
|
|
179
|
+
prompt = self._build_prompt(query, trimmed, system_prompt)
|
|
180
|
+
|
|
181
|
+
last_error = None
|
|
182
|
+
for i, config in enumerate(self._chain()):
|
|
183
|
+
yielded = False
|
|
184
|
+
try:
|
|
185
|
+
for piece in self._stream_provider(config, prompt, temp, max_tok):
|
|
186
|
+
yielded = True
|
|
187
|
+
yield piece
|
|
188
|
+
return
|
|
189
|
+
except Exception as e:
|
|
190
|
+
last_error = e
|
|
191
|
+
logger.warning(f"Provider '{config.provider}' failed during streaming: {e}")
|
|
192
|
+
if yielded:
|
|
193
|
+
yield f"\n[Error: generation interrupted — {e}]"
|
|
194
|
+
return
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
yield f"Sorry, all configured providers failed. Last error: {last_error}"
|
|
198
|
+
|
|
199
|
+
def _call_provider(self, config: ProviderConfig, prompt: str, temperature: float, max_tokens: int) -> Tuple[str, Optional[Dict]]:
|
|
200
|
+
if config.provider == "gemini":
|
|
201
|
+
return self._call_gemini(prompt, temperature, max_tokens, config.api_key, config.model)
|
|
202
|
+
elif config.provider == "anthropic":
|
|
203
|
+
return self._call_anthropic(prompt, temperature, max_tokens, config.api_key, config.model)
|
|
204
|
+
else:
|
|
205
|
+
return self._call_openai_compatible(prompt, temperature, max_tokens, config.api_key, config.model, config.base_url)
|
|
206
|
+
|
|
207
|
+
def _stream_provider(self, config: ProviderConfig, prompt: str, temperature: float, max_tokens: int) -> Iterator[str]:
|
|
208
|
+
if config.provider == "gemini":
|
|
209
|
+
yield from self._stream_gemini(prompt, temperature, max_tokens, config.api_key, config.model)
|
|
210
|
+
elif config.provider == "anthropic":
|
|
211
|
+
yield from self._stream_anthropic(prompt, temperature, max_tokens, config.api_key, config.model)
|
|
212
|
+
else:
|
|
213
|
+
yield from self._stream_openai_compatible(prompt, temperature, max_tokens, config.api_key, config.model, config.base_url)
|
|
214
|
+
|
|
215
|
+
def _call_gemini(self, prompt, temperature, max_tokens, api_key, model) -> Tuple[str, Optional[Dict]]:
|
|
216
|
+
import google.genai as genai
|
|
217
|
+
from google.genai import types
|
|
218
|
+
client = genai.Client(api_key=api_key)
|
|
219
|
+
response = client.models.generate_content(
|
|
220
|
+
model=model, contents=prompt,
|
|
221
|
+
config=types.GenerateContentConfig(temperature=temperature, max_output_tokens=max_tokens),
|
|
222
|
+
)
|
|
223
|
+
text = response.text.strip() if response.text else "No answer generated."
|
|
224
|
+
usage = None
|
|
225
|
+
if getattr(response, "usage_metadata", None):
|
|
226
|
+
um = response.usage_metadata
|
|
227
|
+
usage = {"prompt_tokens": um.prompt_token_count, "completion_tokens": um.candidates_token_count, "total_tokens": um.total_token_count}
|
|
228
|
+
return text, usage
|
|
229
|
+
|
|
230
|
+
def _stream_gemini(self, prompt, temperature, max_tokens, api_key, model) -> Iterator[str]:
|
|
231
|
+
import google.genai as genai
|
|
232
|
+
from google.genai import types
|
|
233
|
+
client = genai.Client(api_key=api_key)
|
|
234
|
+
stream = client.models.generate_content_stream(
|
|
235
|
+
model=model, contents=prompt,
|
|
236
|
+
config=types.GenerateContentConfig(temperature=temperature, max_output_tokens=max_tokens),
|
|
237
|
+
)
|
|
238
|
+
for chunk in stream:
|
|
239
|
+
if chunk.text:
|
|
240
|
+
yield chunk.text
|
|
241
|
+
|
|
242
|
+
def _call_anthropic(self, prompt, temperature, max_tokens, api_key, model) -> Tuple[str, Optional[Dict]]:
|
|
243
|
+
import anthropic
|
|
244
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
245
|
+
response = client.messages.create(model=model, max_tokens=max_tokens, temperature=temperature, messages=[{"role": "user", "content": prompt}])
|
|
246
|
+
text = response.content[0].text.strip() if response.content else "No answer generated."
|
|
247
|
+
usage = None
|
|
248
|
+
if getattr(response, "usage", None):
|
|
249
|
+
usage = {"prompt_tokens": response.usage.input_tokens, "completion_tokens": response.usage.output_tokens, "total_tokens": response.usage.input_tokens + response.usage.output_tokens}
|
|
250
|
+
return text, usage
|
|
251
|
+
|
|
252
|
+
def _stream_anthropic(self, prompt, temperature, max_tokens, api_key, model) -> Iterator[str]:
|
|
253
|
+
import anthropic
|
|
254
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
255
|
+
with client.messages.stream(model=model, max_tokens=max_tokens, temperature=temperature, messages=[{"role": "user", "content": prompt}]) as stream:
|
|
256
|
+
for text in stream.text_stream:
|
|
257
|
+
yield text
|
|
258
|
+
|
|
259
|
+
def _call_openai_compatible(self, prompt, temperature, max_tokens, api_key, model, base_url) -> Tuple[str, Optional[Dict]]:
|
|
260
|
+
import openai
|
|
261
|
+
client = openai.OpenAI(api_key=api_key or "not-needed", base_url=base_url)
|
|
262
|
+
response = client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}], temperature=temperature, max_tokens=max_tokens)
|
|
263
|
+
text = response.choices[0].message.content.strip() if response.choices else "No answer generated."
|
|
264
|
+
usage = None
|
|
265
|
+
if getattr(response, "usage", None):
|
|
266
|
+
usage = {"prompt_tokens": response.usage.prompt_tokens, "completion_tokens": response.usage.completion_tokens, "total_tokens": response.usage.total_tokens}
|
|
267
|
+
return text, usage
|
|
268
|
+
|
|
269
|
+
def _stream_openai_compatible(self, prompt, temperature, max_tokens, api_key, model, base_url) -> Iterator[str]:
|
|
270
|
+
import openai
|
|
271
|
+
client = openai.OpenAI(api_key=api_key or "not-needed", base_url=base_url)
|
|
272
|
+
stream = client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}], temperature=temperature, max_tokens=max_tokens, stream=True)
|
|
273
|
+
for chunk in stream:
|
|
274
|
+
delta = chunk.choices[0].delta.content if chunk.choices else None
|
|
275
|
+
if delta:
|
|
276
|
+
yield delta
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Document text extraction for ragleap-rag.
|
|
3
|
+
Supports .txt, .pdf, .docx.
|
|
4
|
+
"""
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
SUPPORTED_EXTENSIONS = {".txt", ".pdf", ".docx"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_text(filename: str, raw_bytes: bytes) -> str:
|
|
14
|
+
"""Extract plain text from raw file bytes, dispatching on file extension."""
|
|
15
|
+
ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
|
|
16
|
+
ext = f".{ext}"
|
|
17
|
+
if ext == ".txt":
|
|
18
|
+
return _extract_txt(raw_bytes)
|
|
19
|
+
elif ext == ".pdf":
|
|
20
|
+
return _extract_pdf(raw_bytes)
|
|
21
|
+
elif ext == ".docx":
|
|
22
|
+
return _extract_docx(raw_bytes)
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError(f"Unsupported file type '{ext}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _extract_txt(raw_bytes: bytes) -> str:
|
|
28
|
+
try:
|
|
29
|
+
return raw_bytes.decode("utf-8")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
logger.warning("UTF-8 decode failed, retrying with latin-1")
|
|
32
|
+
return raw_bytes.decode("latin-1")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_pdf(raw_bytes: bytes) -> str:
|
|
36
|
+
try:
|
|
37
|
+
from pypdf import PdfReader
|
|
38
|
+
except ImportError as e:
|
|
39
|
+
raise ValueError("pypdf is required for PDF support — pip install ragleap-rag installs it by default") from e
|
|
40
|
+
|
|
41
|
+
reader = PdfReader(io.BytesIO(raw_bytes))
|
|
42
|
+
pages_text = []
|
|
43
|
+
for i, page in enumerate(reader.pages):
|
|
44
|
+
text = page.extract_text() or ""
|
|
45
|
+
if text.strip():
|
|
46
|
+
pages_text.append(text)
|
|
47
|
+
else:
|
|
48
|
+
logger.warning(f"No extractable text on PDF page {i + 1} (likely scanned/image-only)")
|
|
49
|
+
full_text = "\n\n".join(pages_text)
|
|
50
|
+
if not full_text.strip():
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"No text could be extracted from this PDF. It may be a scanned "
|
|
53
|
+
"image-only document, which requires OCR (not currently supported)."
|
|
54
|
+
)
|
|
55
|
+
return full_text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_docx(raw_bytes: bytes) -> str:
|
|
59
|
+
try:
|
|
60
|
+
import docx
|
|
61
|
+
except ImportError as e:
|
|
62
|
+
raise ValueError("python-docx is required for DOCX support — pip install ragleap-rag installs it by default") from e
|
|
63
|
+
|
|
64
|
+
document = docx.Document(io.BytesIO(raw_bytes))
|
|
65
|
+
paragraphs = [p.text for p in document.paragraphs if p.text.strip()]
|
|
66
|
+
full_text = "\n\n".join(paragraphs)
|
|
67
|
+
if not full_text.strip():
|
|
68
|
+
raise ValueError("No text could be extracted from this DOCX file — it may be empty.")
|
|
69
|
+
return full_text
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retrieval for ragleap-rag: dense (pgvector), sparse (Postgres full-text),
|
|
3
|
+
and hybrid (Reciprocal Rank Fusion) search.
|
|
4
|
+
|
|
5
|
+
Requires a PostgreSQL database with the pgvector extension and the
|
|
6
|
+
schema created by ragleap.schema.init_schema() (or your own compatible
|
|
7
|
+
schema — see that module for the exact DDL).
|
|
8
|
+
|
|
9
|
+
No knowledge-graph coupling here by design — ragleap-graph (a separate
|
|
10
|
+
package) extends retrieval with graph-boosted ranking on top of this.
|
|
11
|
+
"""
|
|
12
|
+
import logging
|
|
13
|
+
from typing import List, Dict, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
RRF_K = 60 # Reciprocal Rank Fusion constant — 60 is the standard default.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VectorRetrievalService:
|
|
21
|
+
"""
|
|
22
|
+
Performs dense, sparse, and hybrid search against a PostgreSQL
|
|
23
|
+
'chunks' table (see ragleap.schema for the required DDL).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, database_url: str, embedding_dimensions: int = 3072, min_similarity: float = 0.05):
|
|
27
|
+
self.database_url = database_url
|
|
28
|
+
self.embedding_dimensions = embedding_dimensions
|
|
29
|
+
self.min_similarity = min_similarity
|
|
30
|
+
|
|
31
|
+
def _get_connection(self):
|
|
32
|
+
import psycopg2
|
|
33
|
+
return psycopg2.connect(self.database_url)
|
|
34
|
+
|
|
35
|
+
def search_similar_chunks(
|
|
36
|
+
self,
|
|
37
|
+
query_embedding: List[float],
|
|
38
|
+
top_k: int = 5,
|
|
39
|
+
document_id: Optional[str] = None,
|
|
40
|
+
) -> List[Dict]:
|
|
41
|
+
"""Dense retrieval via pgvector cosine distance."""
|
|
42
|
+
if not query_embedding:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
if len(query_embedding) != self.embedding_dimensions:
|
|
46
|
+
logger.warning(
|
|
47
|
+
f"Query embedding dim={len(query_embedding)} != expected {self.embedding_dimensions}; skipping search"
|
|
48
|
+
)
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
literal = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
|
|
52
|
+
|
|
53
|
+
sql = """
|
|
54
|
+
SELECT
|
|
55
|
+
id, text, document_id, document_name, chunk_index,
|
|
56
|
+
1 - (embedding::halfvec(%s) <=> %s::halfvec(%s)) / 2 AS similarity_score
|
|
57
|
+
FROM chunks
|
|
58
|
+
"""
|
|
59
|
+
params = [self.embedding_dimensions, literal, self.embedding_dimensions]
|
|
60
|
+
|
|
61
|
+
if document_id:
|
|
62
|
+
sql += " WHERE document_id = %s"
|
|
63
|
+
params.append(document_id)
|
|
64
|
+
|
|
65
|
+
sql += " ORDER BY embedding::halfvec(%s) <=> %s::halfvec(%s) LIMIT %s"
|
|
66
|
+
params.extend([self.embedding_dimensions, literal, self.embedding_dimensions, top_k])
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
conn = self._get_connection()
|
|
70
|
+
cur = conn.cursor()
|
|
71
|
+
cur.execute(sql, params)
|
|
72
|
+
rows = cur.fetchall()
|
|
73
|
+
cur.close()
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
results = []
|
|
77
|
+
for row in rows:
|
|
78
|
+
chunk_id, text, doc_id, doc_name, chunk_index, similarity_score = row
|
|
79
|
+
if similarity_score < self.min_similarity:
|
|
80
|
+
continue
|
|
81
|
+
results.append({
|
|
82
|
+
"chunk_id": str(chunk_id),
|
|
83
|
+
"text": text,
|
|
84
|
+
"similarity_score": round(float(similarity_score), 4),
|
|
85
|
+
"document_id": str(doc_id),
|
|
86
|
+
"document_name": doc_name,
|
|
87
|
+
"chunk_index": chunk_index,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
logger.info(f"Vector search returned {len(results)} chunks (top_k={top_k})")
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"Vector search error: {e}", exc_info=True)
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
def search_sparse_chunks(
|
|
98
|
+
self,
|
|
99
|
+
query_text: str,
|
|
100
|
+
top_k: int = 5,
|
|
101
|
+
document_id: Optional[str] = None,
|
|
102
|
+
) -> List[Dict]:
|
|
103
|
+
"""Sparse (keyword/full-text) retrieval via Postgres text search."""
|
|
104
|
+
if not query_text or not query_text.strip():
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
sql = """
|
|
108
|
+
SELECT id, text, document_id, document_name, chunk_index,
|
|
109
|
+
ts_rank(text_search_vector, websearch_to_tsquery('english', %s)) AS rank_score
|
|
110
|
+
FROM chunks
|
|
111
|
+
WHERE text_search_vector @@ websearch_to_tsquery('english', %s)
|
|
112
|
+
"""
|
|
113
|
+
params = [query_text, query_text]
|
|
114
|
+
|
|
115
|
+
if document_id:
|
|
116
|
+
sql += " AND document_id = %s"
|
|
117
|
+
params.append(document_id)
|
|
118
|
+
|
|
119
|
+
sql += " ORDER BY rank_score DESC LIMIT %s"
|
|
120
|
+
params.append(top_k)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
conn = self._get_connection()
|
|
124
|
+
cur = conn.cursor()
|
|
125
|
+
cur.execute(sql, params)
|
|
126
|
+
rows = cur.fetchall()
|
|
127
|
+
cur.close()
|
|
128
|
+
conn.close()
|
|
129
|
+
|
|
130
|
+
results = []
|
|
131
|
+
for row in rows:
|
|
132
|
+
chunk_id, text, doc_id, doc_name, chunk_index, rank_score = row
|
|
133
|
+
results.append({
|
|
134
|
+
"chunk_id": str(chunk_id),
|
|
135
|
+
"text": text,
|
|
136
|
+
"similarity_score": round(float(rank_score), 4),
|
|
137
|
+
"document_id": str(doc_id),
|
|
138
|
+
"document_name": doc_name,
|
|
139
|
+
"chunk_index": chunk_index,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
logger.info(f"Sparse search returned {len(results)} chunks (top_k={top_k})")
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error(f"Sparse search error: {e}", exc_info=True)
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def search_hybrid_chunks(
|
|
150
|
+
self,
|
|
151
|
+
query_text: str,
|
|
152
|
+
query_embedding: List[float],
|
|
153
|
+
top_k: int = 5,
|
|
154
|
+
document_id: Optional[str] = None,
|
|
155
|
+
) -> List[Dict]:
|
|
156
|
+
"""
|
|
157
|
+
Combines dense + sparse via Reciprocal Rank Fusion. similarity_score
|
|
158
|
+
in the returned dicts is the RRF-fused score, meaningful only for
|
|
159
|
+
ranking within this result set (not a 0-1 cosine similarity).
|
|
160
|
+
"""
|
|
161
|
+
dense = self.search_similar_chunks(query_embedding, top_k=top_k * 3, document_id=document_id)
|
|
162
|
+
sparse = self.search_sparse_chunks(query_text, top_k=top_k * 3, document_id=document_id)
|
|
163
|
+
|
|
164
|
+
dense_ranks = {c["chunk_id"]: i for i, c in enumerate(dense)}
|
|
165
|
+
sparse_ranks = {c["chunk_id"]: i for i, c in enumerate(sparse)}
|
|
166
|
+
|
|
167
|
+
chunk_lookup: Dict[str, Dict] = {c["chunk_id"]: c for c in dense}
|
|
168
|
+
for c in sparse:
|
|
169
|
+
chunk_lookup.setdefault(c["chunk_id"], c)
|
|
170
|
+
|
|
171
|
+
all_ids = set(dense_ranks) | set(sparse_ranks)
|
|
172
|
+
if not all_ids:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
rrf_scores = {}
|
|
176
|
+
for cid in all_ids:
|
|
177
|
+
score = 0.0
|
|
178
|
+
if cid in dense_ranks:
|
|
179
|
+
score += 1.0 / (RRF_K + dense_ranks[cid] + 1)
|
|
180
|
+
if cid in sparse_ranks:
|
|
181
|
+
score += 1.0 / (RRF_K + sparse_ranks[cid] + 1)
|
|
182
|
+
rrf_scores[cid] = score
|
|
183
|
+
|
|
184
|
+
results = [dict(chunk_lookup[cid]) for cid in all_ids]
|
|
185
|
+
for c in results:
|
|
186
|
+
c["similarity_score"] = round(rrf_scores[c["chunk_id"]], 6)
|
|
187
|
+
c["retrieval_method"] = "hybrid_rrf"
|
|
188
|
+
|
|
189
|
+
results.sort(key=lambda c: c["similarity_score"], reverse=True)
|
|
190
|
+
|
|
191
|
+
logger.info(
|
|
192
|
+
f"Hybrid search: {len(dense)} dense + {len(sparse)} sparse -> "
|
|
193
|
+
f"{len(results)} fused, returning top {min(top_k, len(results))}"
|
|
194
|
+
)
|
|
195
|
+
return results[:top_k]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database schema management for ragleap-rag.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
SCHEMA_SQL_TEMPLATE = """
|
|
9
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
10
|
+
|
|
11
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
12
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
13
|
+
filename TEXT NOT NULL,
|
|
14
|
+
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
18
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
19
|
+
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
20
|
+
document_name TEXT NOT NULL,
|
|
21
|
+
chunk_index INTEGER NOT NULL,
|
|
22
|
+
text TEXT NOT NULL,
|
|
23
|
+
token_count INTEGER,
|
|
24
|
+
embedding vector({dimensions}),
|
|
25
|
+
text_search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', text)) STORED,
|
|
26
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS chunks_embedding_idx
|
|
30
|
+
ON chunks USING hnsw ((embedding::halfvec({dimensions})) halfvec_cosine_ops);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS chunks_text_search_idx
|
|
33
|
+
ON chunks USING GIN (text_search_vector);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_schema_sql(dimensions: int = 3072) -> str:
|
|
38
|
+
"""Return the DDL for the given embedding dimensionality."""
|
|
39
|
+
return SCHEMA_SQL_TEMPLATE.format(dimensions=dimensions)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def init_schema(database_url: str, dimensions: int = 3072) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Create the required tables/indexes in the given database if they
|
|
45
|
+
don't already exist. Safe to call repeatedly (idempotent —
|
|
46
|
+
everything uses IF NOT EXISTS).
|
|
47
|
+
"""
|
|
48
|
+
import psycopg2
|
|
49
|
+
|
|
50
|
+
conn = psycopg2.connect(database_url)
|
|
51
|
+
try:
|
|
52
|
+
cur = conn.cursor()
|
|
53
|
+
cur.execute(get_schema_sql(dimensions))
|
|
54
|
+
conn.commit()
|
|
55
|
+
cur.close()
|
|
56
|
+
logger.info(f"Schema initialized (embedding dimensions={dimensions})")
|
|
57
|
+
finally:
|
|
58
|
+
conn.close()
|