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