From 7c39ad8972a2cf55dec64d35c137e9c9dedd3c10 Mon Sep 17 00:00:00 2001 From: Hardik Anand Date: Sat, 12 Sep 2026 23:00:51 +0530 Subject: [PATCH 01/27] feat: add async RAG pipeline --- pyproject.toml | 1 + ragframework/pipeline/async_rag.py | 58 +++++++++++++++++++++++++++ tests/test_pipeline/test_async_rag.py | 58 +++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 ragframework/pipeline/async_rag.py create mode 100644 tests/test_pipeline/test_async_rag.py diff --git a/pyproject.toml b/pyproject.toml index 5bb249a..40fb229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ all = [ dev = [ "pytest>=8.0", "pytest-cov>=5.0", + "pytest-asyncio>=0.23", "black>=24.0", "isort>=5.13", "ruff>=0.4", diff --git a/ragframework/pipeline/async_rag.py b/ragframework/pipeline/async_rag.py new file mode 100644 index 0000000..ad57a06 --- /dev/null +++ b/ragframework/pipeline/async_rag.py @@ -0,0 +1,58 @@ +"""Async wrapper for the RAG pipeline.""" + +from __future__ import annotations + +import asyncio + +from ragframework.base import ( + DocumentLoader, + Embedder, + Generator, + RAGResponse, + Retriever, + TextChunker, +) +from ragframework.config import RAGConfig +from ragframework.pipeline.rag import RAGPipeline + + +class AsyncRAGPipeline: + """Async interface for RAGPipeline using asyncio.to_thread().""" + + def __init__( + self, + loader: DocumentLoader, + chunker: TextChunker, + embedder: Embedder, + retriever: Retriever, + generator: Generator, + config: RAGConfig | None = None, + ) -> None: + self._pipeline = RAGPipeline( + loader=loader, + chunker=chunker, + embedder=embedder, + retriever=retriever, + generator=generator, + config=config, + ) + + @classmethod + def from_pipeline(cls, pipeline: RAGPipeline) -> AsyncRAGPipeline: + """Create an async wrapper from an existing RAGPipeline.""" + instance = cls.__new__(cls) + instance._pipeline = pipeline + return instance + + @property + def config(self) -> RAGConfig: + """Return the configuration of the wrapped RAG pipeline.""" + return self._pipeline.config + + async def async_ingest(self, source: str) -> int: + """Asynchronously ingest a document.""" + return await asyncio.to_thread(self._pipeline.ingest, source) + + async def async_query(self, query: str) -> RAGResponse: + """Asynchronously query the RAG pipeline.""" + return await asyncio.to_thread(self._pipeline.query, query) \ No newline at end of file diff --git a/tests/test_pipeline/test_async_rag.py b/tests/test_pipeline/test_async_rag.py new file mode 100644 index 0000000..ea1cff0 --- /dev/null +++ b/tests/test_pipeline/test_async_rag.py @@ -0,0 +1,58 @@ + +"""Tests for AsyncRAGPipeline.""" + +import pytest + +from ragframework.config import RAGConfig +from ragframework.document.chunkers import FixedSizeChunker +from ragframework.document.loaders import TextFileLoader +from ragframework.embeddings.random_embedder import RandomEmbedder +from ragframework.generator.echo_generator import EchoGenerator +from ragframework.pipeline.async_rag import AsyncRAGPipeline +from ragframework.pipeline.rag import RAGPipeline +from ragframework.retriever.in_memory import InMemoryRetriever + + +@pytest.fixture() +def pipeline(tmp_text_file): + return RAGPipeline( + loader=TextFileLoader(), + chunker=FixedSizeChunker(chunk_size=50, chunk_overlap=10), + embedder=RandomEmbedder(dim=16, seed=0), + retriever=InMemoryRetriever(), + generator=EchoGenerator(), + config=RAGConfig(top_k=2), + ) + + +@pytest.mark.asyncio +async def test_async_ingest(pipeline, tmp_text_file): + async_pipeline = AsyncRAGPipeline.from_pipeline(pipeline) + + count = await async_pipeline.async_ingest(tmp_text_file) + + assert count > 0 + + +@pytest.mark.asyncio +async def test_async_query(pipeline, tmp_text_file): + async_pipeline = AsyncRAGPipeline.from_pipeline(pipeline) + + await async_pipeline.async_ingest(tmp_text_file) + response = await async_pipeline.async_query("What is this about?") + + assert response.answer + assert isinstance(response.source_chunks, list) + + +def test_config_is_forwarded(pipeline): + async_pipeline = AsyncRAGPipeline.from_pipeline(pipeline) + + assert async_pipeline.config.top_k == 2 + + +def test_from_pipeline_wraps_existing_pipeline(pipeline): + async_pipeline = AsyncRAGPipeline.from_pipeline(pipeline) + + assert async_pipeline._pipeline is pipeline + From d58724baaf7515f990db8e3e170199db2c91c816 Mon Sep 17 00:00:00 2001 From: Hardik Anand Date: Sat, 12 Sep 2026 23:14:24 +0530 Subject: [PATCH 02/27] docs: update changelog for async RAG pipeline --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a77a636..a4ae75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `HuggingFaceEmbedder` for local embeddings with Sentence Transformers (closes #7) - RecursiveChunker for semantics-preserving text splitting (closes #3) - PDFLoader with support for per-page and whole-file modes (closes #1) +- `AsyncRAGPipeline` for asynchronous RAG ingestion and querying using `asyncio.to_thread()` (closes #9) ## [0.1.0] - 2026-03-24 ### Added @@ -32,4 +33,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 10 curated Good First Issues for new contributors [Unreleased]: https://github.com/adaumsilva/RAG-framework/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/adaumsilva/RAG-framework/releases/tag/v0.1.0 +[0.1.0]: https://github.com/adaumsilva/RAG-framework/releases/tag/v0.1.0 \ No newline at end of file From 896e0ad68023de45a6431d44f6215510a9747497 Mon Sep 17 00:00:00 2001 From: Hardik Anand Date: Sat, 12 Sep 2026 23:15:43 +0530 Subject: [PATCH 03/27] docs: add async RAG example --- examples/async_rag.py | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 examples/async_rag.py diff --git a/examples/async_rag.py b/examples/async_rag.py new file mode 100644 index 0000000..7b43a09 --- /dev/null +++ b/examples/async_rag.py @@ -0,0 +1,81 @@ + + +from __future__ import annotations + +import asyncio +import sys +import tempfile +from pathlib import Path + +# Allow running from the repo root without installing the package +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from ragframework.config import RAGConfig +from ragframework.document.chunkers import FixedSizeChunker +from ragframework.document.loaders import TextFileLoader +from ragframework.embeddings.random_embedder import RandomEmbedder +from ragframework.generator.echo_generator import EchoGenerator +from ragframework.pipeline.async_rag import AsyncRAGPipeline +from ragframework.retriever.in_memory import InMemoryRetriever + +SAMPLE_TEXT = """\ +Retrieval-Augmented Generation (RAG) combines information retrieval with +text generation. A typical RAG pipeline loads documents, splits them into +chunks, creates embeddings, retrieves relevant chunks, and generates an +answer from the retrieved context. + +AsyncRAGPipeline provides an asynchronous interface for the same pipeline, +making it suitable for async applications such as FastAPI and Starlette. +""" + + +async def main() -> None: + # ------------------------------------------------------------------ # + # 1. Write a sample document to a temporary file + # ------------------------------------------------------------------ # + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False, encoding="utf-8" + ) as f: + f.write(SAMPLE_TEXT) + tmp_path = f.name + + print(f"Sample document written to: {tmp_path}\n") + + # ------------------------------------------------------------------ # + # 2. Build the async pipeline + # ------------------------------------------------------------------ # + async_pipeline = AsyncRAGPipeline( + loader=TextFileLoader(), + chunker=FixedSizeChunker(chunk_size=200, chunk_overlap=40), + embedder=RandomEmbedder(dim=64, seed=42), + retriever=InMemoryRetriever(), + generator=EchoGenerator(), + config=RAGConfig(top_k=3), + ) + + # ------------------------------------------------------------------ # + # 3. Asynchronously ingest the document + # ------------------------------------------------------------------ # + n_chunks = await async_pipeline.async_ingest(tmp_path) + print(f"Ingested {n_chunks} chunks.\n") + + # ------------------------------------------------------------------ # + # 4. Asynchronously query the pipeline + # ------------------------------------------------------------------ # + query = "What are the stages of a RAG pipeline?" + print(f"Query: {query!r}\n") + + response = await async_pipeline.async_query(query) + + print("Answer (retrieved context):") + print("-" * 60) + print(response.answer) + print("-" * 60) + print(f"\nSource chunks used: {len(response.source_chunks)}") + + # Clean up + Path(tmp_path).unlink(missing_ok=True) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 487d093d89759a775e91253295fed3a1e78d7039 Mon Sep 17 00:00:00 2001 From: balajis-09 <78738719+balajisadhasivam@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:18:37 +0530 Subject: [PATCH 04/27] feat: add AnthropicGenerator for grounded RAG answers --- CHANGELOG.md | 1 + examples/README.md | 9 + examples/anthropic_generator.py | 83 +++++++++ ragframework/generator/__init__.py | 3 +- ragframework/generator/anthropic.py | 116 ++++++++++++ tests/test_generator/test_anthropic.py | 243 +++++++++++++++++++++++++ 6 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 examples/anthropic_generator.py create mode 100644 ragframework/generator/anthropic.py create mode 100644 tests/test_generator/test_anthropic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a29827f..26550e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `AnthropicGenerator` for grounded answers via the Anthropic Messages API (closes #20) - `FAISSRetriever` for approximate cosine-similarity search with an HNSW index (closes #4) - `HuggingFaceEmbedder` for local embeddings with Sentence Transformers (closes #7) - RecursiveChunker for semantics-preserving text splitting (closes #3) diff --git a/examples/README.md b/examples/README.md index cff22bc..9605516 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,7 @@ matches = retriever.retrieve([0.9, 0.1], top_k=1) |------|-------------| | [basic_rag.py](basic_rag.py) | End-to-end pipeline using only built-in components (no API key needed) | | [basic_rag.ipynb](basic_rag.ipynb) | Interactive Jupyter notebook covering the complete RAG pipeline | +| [anthropic_generator.py](anthropic_generator.py) | End-to-end pipeline using `AnthropicGenerator` (requires `ANTHROPIC_API_KEY`) | ## Running the examples @@ -33,6 +34,14 @@ pip install -e ".[dev]" python examples/basic_rag.py ``` +For the Anthropic example: + +```bash +pip install -e ".[dev,anthropic]" +export ANTHROPIC_API_KEY=your-key-here +python examples/anthropic_generator.py +``` + ### Jupyter notebook Install Jupyter dependencies: diff --git a/examples/anthropic_generator.py b/examples/anthropic_generator.py new file mode 100644 index 0000000..bc0732d --- /dev/null +++ b/examples/anthropic_generator.py @@ -0,0 +1,83 @@ +"""Anthropic-backed RAG pipeline example. + +Demonstrates a full ingest -> query cycle using AnthropicGenerator for +answer synthesis. Requires the optional Anthropic dependency and an API key. + +Run: + pip install "ragframework[anthropic]" + set ANTHROPIC_API_KEY=your-key-here # Windows + export ANTHROPIC_API_KEY=your-key-here # macOS/Linux + python examples/anthropic_generator.py +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +# Allow running from the repo root without installing the package +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from ragframework.config import RAGConfig +from ragframework.document.chunkers import FixedSizeChunker +from ragframework.document.loaders import TextFileLoader +from ragframework.embeddings.random_embedder import RandomEmbedder +from ragframework.generator.anthropic import AnthropicGenerator +from ragframework.pipeline.rag import RAGPipeline +from ragframework.retriever.in_memory import InMemoryRetriever + +SAMPLE_TEXT = """\ +Retrieval-Augmented Generation (RAG) combines document retrieval with language +model generation. A typical pipeline loads documents, splits them into chunks, +embeds those chunks, retrieves the most relevant pieces for a query, and then +asks a generator to produce a grounded answer from the retrieved context. +""" + + +def main() -> None: + if not os.environ.get("ANTHROPIC_API_KEY", "").strip(): + print("ANTHROPIC_API_KEY is not set.") + print("Install the optional dependency with:") + print(' pip install "ragframework[anthropic]"') + print("Then set your API key, for example:") + print(" export ANTHROPIC_API_KEY=your-key-here") + raise SystemExit(0) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False, encoding="utf-8" + ) as handle: + handle.write(SAMPLE_TEXT) + tmp_path = handle.name + + print(f"Sample document written to: {tmp_path}\n") + + pipeline = RAGPipeline( + loader=TextFileLoader(), + chunker=FixedSizeChunker(chunk_size=200, chunk_overlap=40), + embedder=RandomEmbedder(dim=64, seed=42), + retriever=InMemoryRetriever(), + generator=AnthropicGenerator(model="claude-sonnet-5"), + config=RAGConfig(top_k=3), + ) + + n_chunks = pipeline.ingest(tmp_path) + print(f"Ingested {n_chunks} chunks.\n") + + query = "What does a RAG pipeline do?" + print(f"Query: {query!r}\n") + + response = pipeline.query(query) + + print("Answer:") + print("-" * 60) + print(response.answer) + print("-" * 60) + print(f"\nSource chunks used: {len(response.source_chunks)}") + + Path(tmp_path).unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/ragframework/generator/__init__.py b/ragframework/generator/__init__.py index d0642c4..64922ac 100644 --- a/ragframework/generator/__init__.py +++ b/ragframework/generator/__init__.py @@ -1,5 +1,6 @@ """Generator implementations.""" +from ragframework.generator.anthropic import AnthropicGenerator from ragframework.generator.echo_generator import EchoGenerator -__all__ = ["EchoGenerator"] +__all__ = ["AnthropicGenerator", "EchoGenerator"] diff --git a/ragframework/generator/anthropic.py b/ragframework/generator/anthropic.py new file mode 100644 index 0000000..16f183d --- /dev/null +++ b/ragframework/generator/anthropic.py @@ -0,0 +1,116 @@ +"""Anthropic Claude generator for grounded RAG answers.""" + +from __future__ import annotations + +import os +from typing import Any + +from ragframework.base import Chunk, Generator +from ragframework.exceptions import GeneratorError + +_INSTALL_HINT = ( + "Anthropic support requires the 'anthropic' package. " + "Install it with: pip install ragframework[anthropic]" +) + +_DEFAULT_SYSTEM_PROMPT = ( + "Answer the question using only the provided context. " + "If the context is insufficient, say so." +) + + +class AnthropicGenerator(Generator): + """Generate answers using the Anthropic Messages API. + + Args: + model: Anthropic model identifier (for example ``"claude-sonnet-5"``). + api_key: API key. Falls back to ``ANTHROPIC_API_KEY`` when omitted. + system_prompt: System instruction passed to the Messages API. + max_tokens: Maximum tokens to generate. + + Raises: + ImportError: If the ``anthropic`` optional dependency is not installed. + GeneratorError: If the API key is missing or generation fails. + """ + + def __init__( + self, + model: str, + api_key: str | None = None, + system_prompt: str = _DEFAULT_SYSTEM_PROMPT, + max_tokens: int = 1024, + ) -> None: + try: + from anthropic import Anthropic + except ImportError as exc: + raise ImportError(_INSTALL_HINT) from exc + + resolved_key = (api_key or os.environ.get("ANTHROPIC_API_KEY") or "").strip() + if not resolved_key: + raise GeneratorError( + "No API key provided. Pass `api_key` or set the " + "`ANTHROPIC_API_KEY` environment variable." + ) + + self.model = model + self.system_prompt = system_prompt + self.max_tokens = max_tokens + try: + self._client: Any = Anthropic(api_key=resolved_key) + except Exception as exc: + raise GeneratorError("Could not create Anthropic client.") from exc + + def generate(self, query: str, context: list[Chunk]) -> str: + """Generate an answer for *query* using retrieved *context* chunks.""" + user_message = self._build_user_message(query, context) + try: + response = self._client.messages.create( + model=self.model, + max_tokens=self.max_tokens, + system=self.system_prompt, + messages=[{"role": "user", "content": user_message}], + ) + except Exception as exc: + raise GeneratorError("Anthropic API call failed.") from exc + + try: + return self._extract_text(response) + except GeneratorError: + raise + except Exception as exc: + raise GeneratorError("Could not read Anthropic response.") from exc + + @staticmethod + def _build_user_message(query: str, context: list[Chunk]) -> str: + if context: + numbered = "\n\n".join( + f"[{index + 1}] {chunk.content}" for index, chunk in enumerate(context) + ) + return ( + "Use the following context to answer the question.\n\n" + f"Context:\n{numbered}\n\n" + f"Question: {query}" + ) + return ( + "Use the following context to answer the question.\n\n" + "Context:\n\n" + f"Question: {query}" + ) + + @staticmethod + def _extract_text(response: Any) -> str: + content = getattr(response, "content", None) + if not content: + raise GeneratorError("Anthropic response contained no content blocks.") + + text_parts: list[str] = [] + for block in content: + if getattr(block, "type", None) == "text": + text = getattr(block, "text", None) + if isinstance(text, str) and text: + text_parts.append(text) + + if not text_parts: + raise GeneratorError("Anthropic response contained no text blocks.") + + return "".join(text_parts) diff --git a/tests/test_generator/test_anthropic.py b/tests/test_generator/test_anthropic.py new file mode 100644 index 0000000..a5f3e3c --- /dev/null +++ b/tests/test_generator/test_anthropic.py @@ -0,0 +1,243 @@ +"""Tests for the Anthropic Messages API generator.""" + +from __future__ import annotations + +import builtins +import sys +import types + +import pytest + +from ragframework.base import Chunk, Generator +from ragframework.exceptions import GeneratorError +from ragframework.generator.anthropic import AnthropicGenerator + + +class FakeTextBlock: + def __init__(self, text: str) -> None: + self.type = "text" + self.text = text + + +class FakeNonTextBlock: + def __init__(self) -> None: + self.type = "tool_use" + + +class FakeMessageResponse: + def __init__(self, content: list[object]) -> None: + self.content = content + + +class FakeMessages: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + self.create_side_effect: Exception | None = None + self.response_content: list[object] = [FakeTextBlock("Generated answer.")] + + def create(self, **kwargs: object) -> FakeMessageResponse: + self.calls.append(kwargs) + if self.create_side_effect is not None: + raise self.create_side_effect + return FakeMessageResponse(self.response_content) + + +class FakeAnthropicClient: + def __init__(self, api_key: str | None = None) -> None: + self.api_key = api_key + self.messages = FakeMessages() + + +@pytest.fixture +def fake_anthropic(monkeypatch: pytest.MonkeyPatch) -> list[FakeAnthropicClient]: + module = types.ModuleType("anthropic") + created_clients: list[FakeAnthropicClient] = [] + + def factory(*, api_key: str | None = None) -> FakeAnthropicClient: + client = FakeAnthropicClient(api_key=api_key) + created_clients.append(client) + return client + + module.Anthropic = factory + monkeypatch.setitem(sys.modules, "anthropic", module) + return created_clients + + +def _make_generator( + fake_anthropic: list[FakeAnthropicClient], + *, + model: str = "claude-sonnet-5", + api_key: str = "sk-test", + **kwargs: object, +) -> tuple[AnthropicGenerator, FakeAnthropicClient]: + generator = AnthropicGenerator(model=model, api_key=api_key, **kwargs) + return generator, fake_anthropic[-1] + + +def test_subclasses_generator() -> None: + assert issubclass(AnthropicGenerator, Generator) + + +def test_successful_generation(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + client.messages.response_content = [FakeTextBlock("Grounded answer.")] + + answer = generator.generate("What is RAG?", [Chunk(id="c1", content="RAG combines retrieval and generation.")]) + + assert answer == "Grounded answer." + assert len(client.messages.calls) == 1 + + +def test_api_call_arguments(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator( + fake_anthropic, + model="claude-test-model", + system_prompt="Stay grounded.", + max_tokens=256, + ) + + generator.generate("Explain embeddings.", [Chunk(id="c1", content="Vectors represent text.")]) + + call = client.messages.calls[0] + assert call["model"] == "claude-test-model" + assert call["max_tokens"] == 256 + assert call["system"] == "Stay grounded." + messages = call["messages"] + assert isinstance(messages, list) + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert isinstance(messages[0]["content"], str) + + +def test_context_formatting(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + context = [ + Chunk(id="alpha-id", content="Alpha", metadata={"source": "a.txt"}, embedding=[1.0]), + Chunk(id="beta-id", content="Beta", metadata={"source": "b.txt"}, embedding=[2.0]), + ] + + generator.generate("What happened?", context) + + user_message = client.messages.calls[0]["messages"][0]["content"] + assert "[1] Alpha" in user_message + assert "[2] Beta" in user_message + assert user_message.index("[1] Alpha") < user_message.index("[2] Beta") + assert "Question: What happened?" in user_message + assert "alpha-id" not in user_message + assert "a.txt" not in user_message + + +def test_empty_context_still_calls_api(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + + generator.generate("q", []) + + assert len(client.messages.calls) == 1 + user_message = client.messages.calls[0]["messages"][0]["content"] + assert "Question: q" in user_message + assert "[1]" not in user_message + + +def test_explicit_api_key(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator = AnthropicGenerator(model="claude-sonnet-5", api_key="sk-test") + + assert fake_anthropic[-1].api_key == "sk-test" + assert generator.model == "claude-sonnet-5" + + +def test_environment_api_key_fallback( + fake_anthropic: list[FakeAnthropicClient], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "env-key") + + AnthropicGenerator(model="claude-sonnet-5") + + assert fake_anthropic[-1].api_key == "env-key" + + +def test_missing_api_key(fake_anthropic: list[FakeAnthropicClient], monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + with pytest.raises(GeneratorError, match="No API key provided"): + AnthropicGenerator(model="claude-sonnet-5") + + assert fake_anthropic == [] + + +def test_missing_dependency_has_helpful_message(monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "anthropic": + raise ImportError("No module named 'anthropic'") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.delitem(sys.modules, "anthropic", raising=False) + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ImportError, match=r"ragframework\[anthropic\]"): + AnthropicGenerator(model="claude-sonnet-5", api_key="sk-test") + + +def test_client_construction_failure(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("anthropic") + + def failing_factory(*, api_key: str | None = None) -> None: + raise RuntimeError("client init failed") + + module.Anthropic = failing_factory + monkeypatch.setitem(sys.modules, "anthropic", module) + + with pytest.raises(GeneratorError, match="Could not create Anthropic client") as exc_info: + AnthropicGenerator(model="claude-sonnet-5", api_key="sk-test") + + assert isinstance(exc_info.value.__cause__, RuntimeError) + + +def test_api_failure(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + client.messages.create_side_effect = RuntimeError("API offline") + + with pytest.raises(GeneratorError, match="Anthropic API call failed") as exc_info: + generator.generate("q", [Chunk(id="c1", content="context")]) + + assert isinstance(exc_info.value.__cause__, RuntimeError) + + +def test_multiple_text_blocks(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + client.messages.response_content = [FakeTextBlock("Part one. "), FakeTextBlock("Part two.")] + + answer = generator.generate("q", [Chunk(id="c1", content="context")]) + + assert answer == "Part one. Part two." + + +def test_empty_response_raises_generator_error(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + client.messages.response_content = [] + + with pytest.raises(GeneratorError, match="no content blocks"): + generator.generate("q", [Chunk(id="c1", content="context")]) + + +def test_non_text_response_raises_generator_error(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator, client = _make_generator(fake_anthropic) + client.messages.response_content = [FakeNonTextBlock()] + + with pytest.raises(GeneratorError, match="no text blocks"): + generator.generate("q", [Chunk(id="c1", content="context")]) + + +def test_constructor_configuration(fake_anthropic: list[FakeAnthropicClient]) -> None: + generator = AnthropicGenerator( + model="custom-model", + api_key="sk-test", + system_prompt="Custom prompt.", + max_tokens=512, + ) + + assert generator.model == "custom-model" + assert generator.system_prompt == "Custom prompt." + assert generator.max_tokens == 512 From 6fdf3ae1f494058416248f4bdb63ceb205f66cff Mon Sep 17 00:00:00 2001 From: berkay-byte Date: Sat, 19 Sep 2026 00:29:09 +0300 Subject: [PATCH 05/27] chore: add pre-commit hooks and Dependabot Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 22 ++++++++++++++++++++ .pre-commit-config.yaml | 28 ++++++++++++++++++++++++++ CONTRIBUTING.md | 6 ++++++ examples/basic_rag.py | 4 +--- pyproject.toml | 1 + ragframework/document/loaders.py | 9 +++++---- ragframework/generator/anthropic.py | 9 ++------- ragframework/retriever/chroma.py | 14 ++++--------- ragframework/retriever/faiss.py | 3 +-- tests/conftest.py | 5 +---- tests/test_document/test_loaders.py | 2 +- tests/test_generator/test_anthropic.py | 12 ++++++++--- tests/test_retriever/test_chroma.py | 22 ++++++++++++-------- tests/test_retriever/test_in_memory.py | 1 - 14 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3081454 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + pip-minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - "*" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..79694af --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + exclude: \.ipynb$ + - id: trailing-whitespace + - id: check-yaml + - id: check-toml + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.10 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: mypy + name: mypy + entry: python -m mypy + language: system + types: [python] + pass_filenames: false + args: [ragframework/] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5976ef1..6dbb2b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,12 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e ".[dev]" ``` +Install the Git hooks once per clone: + +```bash +pre-commit install +``` + To install optional extras (e.g. while adding a PDF loader): ```bash diff --git a/examples/basic_rag.py b/examples/basic_rag.py index 4520f05..82bcdf5 100644 --- a/examples/basic_rag.py +++ b/examples/basic_rag.py @@ -50,9 +50,7 @@ def main() -> None: # ------------------------------------------------------------------ # # 1. Write a sample document to a temporary file # ------------------------------------------------------------------ # - with tempfile.NamedTemporaryFile( - mode="w", suffix=".txt", delete=False, encoding="utf-8" - ) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f: f.write(SAMPLE_TEXT) tmp_path = f.name diff --git a/pyproject.toml b/pyproject.toml index 5bb249a..1d44a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "isort>=5.13", "ruff>=0.4", "mypy>=1.10", + "pre-commit>=3.7", ] [project.urls] diff --git a/ragframework/document/loaders.py b/ragframework/document/loaders.py index f2fe7c8..9f3ee55 100644 --- a/ragframework/document/loaders.py +++ b/ragframework/document/loaders.py @@ -93,7 +93,10 @@ def load(self, source: str) -> list[Document]: try: from pypdf import PdfReader except ImportError as exc: - raise ImportError("PDF support requires 'ragframework[pdf]'. " "Install it with: pip install ragframework[pdf]") from exc + raise ImportError( + "PDF support requires 'ragframework[pdf]'. " + "Install it with: pip install ragframework[pdf]" + ) from exc # Open the PDF try: @@ -106,9 +109,7 @@ def load(self, source: str) -> list[Document]: if not self.split_pages: # One single Document for the whole PDF try: - full_text = "\n\n".join( - page.extract_text() or "" for page in reader.pages - ) + full_text = "\n\n".join(page.extract_text() or "" for page in reader.pages) except Exception as exc: raise LoaderError(f"Failed to extract text from {source}: {exc}") from exc diff --git a/ragframework/generator/anthropic.py b/ragframework/generator/anthropic.py index 16f183d..d4684bb 100644 --- a/ragframework/generator/anthropic.py +++ b/ragframework/generator/anthropic.py @@ -14,8 +14,7 @@ ) _DEFAULT_SYSTEM_PROMPT = ( - "Answer the question using only the provided context. " - "If the context is insufficient, say so." + "Answer the question using only the provided context. If the context is insufficient, say so." ) @@ -91,11 +90,7 @@ def _build_user_message(query: str, context: list[Chunk]) -> str: f"Context:\n{numbered}\n\n" f"Question: {query}" ) - return ( - "Use the following context to answer the question.\n\n" - "Context:\n\n" - f"Question: {query}" - ) + return f"Use the following context to answer the question.\n\nContext:\n\nQuestion: {query}" @staticmethod def _extract_text(response: Any) -> str: diff --git a/ragframework/retriever/chroma.py b/ragframework/retriever/chroma.py index 0504bd5..0323b4d 100644 --- a/ragframework/retriever/chroma.py +++ b/ragframework/retriever/chroma.py @@ -48,9 +48,7 @@ def __init__( name=collection_name, ) except Exception as exc: - raise RetrieverError( - f"Could not initialize ChromaDB: {exc}" - ) from exc + raise RetrieverError(f"Could not initialize ChromaDB: {exc}") from exc def add(self, chunks: list[Chunk]) -> None: """Add embedded chunks to the ChromaDB collection.""" @@ -76,9 +74,7 @@ def add(self, chunks: list[Chunk]) -> None: metadatas=[self._metadata(chunk) for chunk in chunks], ) except Exception as exc: - raise RetrieverError( - f"Failed to add chunks to ChromaDB: {exc}" - ) from exc + raise RetrieverError(f"Failed to add chunks to ChromaDB: {exc}") from exc def retrieve( self, @@ -98,9 +94,7 @@ def retrieve( include=["documents", "metadatas"], ) except Exception as exc: - raise RetrieverError( - f"Failed to query ChromaDB: {exc}" - ) from exc + raise RetrieverError(f"Failed to query ChromaDB: {exc}") from exc ids_result = result.get("ids") or [] documents_result = result.get("documents") or [] @@ -134,7 +128,7 @@ def _metadata(cls, chunk: Chunk) -> dict[str, str]: metadata: dict[str, Any] = {} for key, value in chunk.metadata.items(): - if isinstance(value, (str, int, float, bool)): + if isinstance(value, str | int | float | bool): metadata[key] = value else: metadata[key] = str(value) diff --git a/ragframework/retriever/faiss.py b/ragframework/retriever/faiss.py index 25d6a2e..184419f 100644 --- a/ragframework/retriever/faiss.py +++ b/ragframework/retriever/faiss.py @@ -17,8 +17,7 @@ from ragframework.exceptions import RetrieverError _INSTALL_HINT = ( - "FAISS support requires 'ragframework[faiss]'. " - "Install it with: pip install ragframework[faiss]" + "FAISS support requires 'ragframework[faiss]'. Install it with: pip install ragframework[faiss]" ) diff --git a/tests/conftest.py b/tests/conftest.py index 470205c..c0ccab0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,10 +7,7 @@ from ragframework.base import Chunk, Document from ragframework.config import RAGConfig from ragframework.document.chunkers import FixedSizeChunker -from ragframework.document.loaders import TextFileLoader from ragframework.embeddings.random_embedder import RandomEmbedder -from ragframework.generator.echo_generator import EchoGenerator -from ragframework.retriever.in_memory import InMemoryRetriever @pytest.fixture() @@ -33,7 +30,7 @@ def embedded_chunks(sample_chunks: list[Chunk]) -> list[Chunk]: embedder = RandomEmbedder(dim=16, seed=42) texts = [c.content for c in sample_chunks] embeddings = embedder.embed(texts) - for chunk, emb in zip(sample_chunks, embeddings): + for chunk, emb in zip(sample_chunks, embeddings, strict=True): chunk.embedding = emb return sample_chunks diff --git a/tests/test_document/test_loaders.py b/tests/test_document/test_loaders.py index 90620c9..a30aebb 100644 --- a/tests/test_document/test_loaders.py +++ b/tests/test_document/test_loaders.py @@ -6,7 +6,7 @@ import pytest -from ragframework.document.loaders import MarkdownLoader, TextFileLoader, PDFLoader +from ragframework.document.loaders import MarkdownLoader, PDFLoader, TextFileLoader from ragframework.exceptions import LoaderError diff --git a/tests/test_generator/test_anthropic.py b/tests/test_generator/test_anthropic.py index a5f3e3c..00c25ec 100644 --- a/tests/test_generator/test_anthropic.py +++ b/tests/test_generator/test_anthropic.py @@ -82,7 +82,9 @@ def test_successful_generation(fake_anthropic: list[FakeAnthropicClient]) -> Non generator, client = _make_generator(fake_anthropic) client.messages.response_content = [FakeTextBlock("Grounded answer.")] - answer = generator.generate("What is RAG?", [Chunk(id="c1", content="RAG combines retrieval and generation.")]) + answer = generator.generate( + "What is RAG?", [Chunk(id="c1", content="RAG combines retrieval and generation.")] + ) assert answer == "Grounded answer." assert len(client.messages.calls) == 1 @@ -156,7 +158,9 @@ def test_environment_api_key_fallback( assert fake_anthropic[-1].api_key == "env-key" -def test_missing_api_key(fake_anthropic: list[FakeAnthropicClient], monkeypatch: pytest.MonkeyPatch) -> None: +def test_missing_api_key( + fake_anthropic: list[FakeAnthropicClient], monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) with pytest.raises(GeneratorError, match="No API key provided"): @@ -222,7 +226,9 @@ def test_empty_response_raises_generator_error(fake_anthropic: list[FakeAnthropi generator.generate("q", [Chunk(id="c1", content="context")]) -def test_non_text_response_raises_generator_error(fake_anthropic: list[FakeAnthropicClient]) -> None: +def test_non_text_response_raises_generator_error( + fake_anthropic: list[FakeAnthropicClient], +) -> None: generator, client = _make_generator(fake_anthropic) client.messages.response_content = [FakeNonTextBlock()] diff --git a/tests/test_retriever/test_chroma.py b/tests/test_retriever/test_chroma.py index 22497c8..1b00dd2 100644 --- a/tests/test_retriever/test_chroma.py +++ b/tests/test_retriever/test_chroma.py @@ -142,15 +142,21 @@ def test_non_positive_top_k_returns_empty(): ] ) - assert retriever.retrieve( - [1.0, 0.0], - top_k=0, - ) == [] + assert ( + retriever.retrieve( + [1.0, 0.0], + top_k=0, + ) + == [] + ) - assert retriever.retrieve( - [1.0, 0.0], - top_k=-1, - ) == [] + assert ( + retriever.retrieve( + [1.0, 0.0], + top_k=-1, + ) + == [] + ) def test_metadata_is_preserved(): diff --git a/tests/test_retriever/test_in_memory.py b/tests/test_retriever/test_in_memory.py index b0aaea7..bc0a626 100644 --- a/tests/test_retriever/test_in_memory.py +++ b/tests/test_retriever/test_in_memory.py @@ -36,7 +36,6 @@ def test_chunk_without_embedding_raises(self): r.add([bad_chunk]) def test_retrieve_returns_most_similar_first(self): - dim = 4 r = InMemoryRetriever() # chunk_a is identical to the query — highest similarity chunk_a = make_chunk("a", [1.0, 0.0, 0.0, 0.0]) From b89fcd0441084d2a65cd45199d0aade3750a4036 Mon Sep 17 00:00:00 2001 From: Hardik Anand Date: Sat, 19 Sep 2026 11:54:05 +0530 Subject: [PATCH 06/27] fix: address async pipeline review feedback --- CHANGELOG.md | 2 +- examples/async_rag.py | 2 +- ragframework/pipeline/__init__.py | 3 ++- ragframework/pipeline/async_rag.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ae75c..aff3696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,4 +33,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 10 curated Good First Issues for new contributors [Unreleased]: https://github.com/adaumsilva/RAG-framework/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/adaumsilva/RAG-framework/releases/tag/v0.1.0 \ No newline at end of file +[0.1.0]: https://github.com/adaumsilva/RAG-framework/releases/tag/v0.1.0 diff --git a/examples/async_rag.py b/examples/async_rag.py index 7b43a09..43536ae 100644 --- a/examples/async_rag.py +++ b/examples/async_rag.py @@ -78,4 +78,4 @@ async def main() -> None: if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/ragframework/pipeline/__init__.py b/ragframework/pipeline/__init__.py index d287a24..b2dbd76 100644 --- a/ragframework/pipeline/__init__.py +++ b/ragframework/pipeline/__init__.py @@ -1,5 +1,6 @@ """RAG pipeline orchestration.""" +from ragframework.pipeline.async_rag import AsyncRAGPipeline from ragframework.pipeline.rag import RAGPipeline -__all__ = ["RAGPipeline"] +__all__ = ["AsyncRAGPipeline", "RAGPipeline"] \ No newline at end of file diff --git a/ragframework/pipeline/async_rag.py b/ragframework/pipeline/async_rag.py index ad57a06..03fda16 100644 --- a/ragframework/pipeline/async_rag.py +++ b/ragframework/pipeline/async_rag.py @@ -55,4 +55,4 @@ async def async_ingest(self, source: str) -> int: async def async_query(self, query: str) -> RAGResponse: """Asynchronously query the RAG pipeline.""" - return await asyncio.to_thread(self._pipeline.query, query) \ No newline at end of file + return await asyncio.to_thread(self._pipeline.query, query) From 53abfd232b2eefb1cfd3c9ec4758ddfe9027a839 Mon Sep 17 00:00:00 2001 From: Adam Silva Date: Sat, 19 Sep 2026 10:23:44 -0400 Subject: [PATCH 07/27] Fix newline at end of file in __init__.py style: add missing newline to pipeline exports --- ragframework/pipeline/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ragframework/pipeline/__init__.py b/ragframework/pipeline/__init__.py index b2dbd76..da919db 100644 --- a/ragframework/pipeline/__init__.py +++ b/ragframework/pipeline/__init__.py @@ -3,4 +3,4 @@ from ragframework.pipeline.async_rag import AsyncRAGPipeline from ragframework.pipeline.rag import RAGPipeline -__all__ = ["AsyncRAGPipeline", "RAGPipeline"] \ No newline at end of file +__all__ = ["AsyncRAGPipeline", "RAGPipeline"] From c5047abe7b479563c8cb0e6547a6824c2e67aa52 Mon Sep 17 00:00:00 2001 From: Adam Silva Date: Sat, 19 Sep 2026 11:37:17 -0400 Subject: [PATCH 08/27] Add issue backlog 011-039 from project audit; retitle 008 to match #8 --- .github/issues/008-llm-generator.md | 2 +- .../011-inmemory-retriever-top-k-zero.md | 40 ++++++++++++++++ .../issues/012-loader-unicode-decode-error.md | 39 +++++++++++++++ .../013-fixed-size-chunker-validation.md | 42 ++++++++++++++++ .../014-inmemory-retriever-dimension-check.md | 43 +++++++++++++++++ .../015-inmemory-retriever-incremental-add.md | 37 ++++++++++++++ .../issues/016-idempotent-retriever-add.md | 42 ++++++++++++++++ .github/issues/017-ragconfig-not-applied.md | 48 +++++++++++++++++++ .../018-getting-started-markdown-broken.md | 39 +++++++++++++++ .github/issues/019-stale-roadmap-and-docs.md | 32 +++++++++++++ .../issues/020-sentence-chunker-max-chars.md | 26 ++++++++++ .github/issues/021-retrieval-scores.md | 43 +++++++++++++++++ .github/issues/022-directory-loader.md | 46 ++++++++++++++++++ .github/issues/023-html-loader.md | 36 ++++++++++++++ .github/issues/024-csv-jsonl-loader.md | 40 ++++++++++++++++ .github/issues/025-bm25-hybrid-retriever.md | 42 ++++++++++++++++ .github/issues/026-reranker-stage.md | 44 +++++++++++++++++ .github/issues/027-retriever-persistence.md | 41 ++++++++++++++++ .github/issues/028-metadata-filtering.md | 44 +++++++++++++++++ .github/issues/029-logging.md | 38 +++++++++++++++ .github/issues/030-batch-embedding.md | 34 +++++++++++++ .github/issues/031-pipeline-ergonomics.md | 29 +++++++++++ .github/issues/032-token-chunker.md | 38 +++++++++++++++ .github/issues/033-cli.md | 42 ++++++++++++++++ .github/issues/034-retriever-abc-len-clear.md | 25 ++++++++++ .github/issues/035-py-typed.md | 28 +++++++++++ .../issues/036-gitattributes-line-endings.md | 37 ++++++++++++++ .github/issues/037-ci-hardening.md | 39 +++++++++++++++ .github/issues/038-pre-commit-dependabot.md | 32 +++++++++++++ .github/issues/039-version-and-release.md | 32 +++++++++++++ 30 files changed, 1099 insertions(+), 1 deletion(-) create mode 100644 .github/issues/011-inmemory-retriever-top-k-zero.md create mode 100644 .github/issues/012-loader-unicode-decode-error.md create mode 100644 .github/issues/013-fixed-size-chunker-validation.md create mode 100644 .github/issues/014-inmemory-retriever-dimension-check.md create mode 100644 .github/issues/015-inmemory-retriever-incremental-add.md create mode 100644 .github/issues/016-idempotent-retriever-add.md create mode 100644 .github/issues/017-ragconfig-not-applied.md create mode 100644 .github/issues/018-getting-started-markdown-broken.md create mode 100644 .github/issues/019-stale-roadmap-and-docs.md create mode 100644 .github/issues/020-sentence-chunker-max-chars.md create mode 100644 .github/issues/021-retrieval-scores.md create mode 100644 .github/issues/022-directory-loader.md create mode 100644 .github/issues/023-html-loader.md create mode 100644 .github/issues/024-csv-jsonl-loader.md create mode 100644 .github/issues/025-bm25-hybrid-retriever.md create mode 100644 .github/issues/026-reranker-stage.md create mode 100644 .github/issues/027-retriever-persistence.md create mode 100644 .github/issues/028-metadata-filtering.md create mode 100644 .github/issues/029-logging.md create mode 100644 .github/issues/030-batch-embedding.md create mode 100644 .github/issues/031-pipeline-ergonomics.md create mode 100644 .github/issues/032-token-chunker.md create mode 100644 .github/issues/033-cli.md create mode 100644 .github/issues/034-retriever-abc-len-clear.md create mode 100644 .github/issues/035-py-typed.md create mode 100644 .github/issues/036-gitattributes-line-endings.md create mode 100644 .github/issues/037-ci-hardening.md create mode 100644 .github/issues/038-pre-commit-dependabot.md create mode 100644 .github/issues/039-version-and-release.md diff --git a/.github/issues/008-llm-generator.md b/.github/issues/008-llm-generator.md index f5cd15b..81bf936 100644 --- a/.github/issues/008-llm-generator.md +++ b/.github/issues/008-llm-generator.md @@ -1,5 +1,5 @@ --- -title: "OpenAI / Anthropic LLM Generator" +title: "OpenAI LLM Generator" labels: ["good first issue", "enhancement"] --- diff --git a/.github/issues/011-inmemory-retriever-top-k-zero.md b/.github/issues/011-inmemory-retriever-top-k-zero.md new file mode 100644 index 0000000..e1e6bf6 --- /dev/null +++ b/.github/issues/011-inmemory-retriever-top-k-zero.md @@ -0,0 +1,40 @@ +--- +title: "InMemoryRetriever returns every chunk when top_k <= 0" +labels: ["bug", "good first issue"] +--- + +## Description + +`InMemoryRetriever.retrieve()` mishandles non-positive `top_k`. With 5 indexed chunks: + +```python +r.retrieve(q, top_k=0) # returns 5 chunks +r.retrieve(q, top_k=-1) # returns 4 chunks +``` + +Cause: `k = min(top_k, len(self._chunks))` gives `k = 0`, and `np.argpartition(scores, -0)[-0:]` is `[0:]` — the whole array. `FAISSRetriever` and `ChromaRetriever` both return `[]` for `top_k <= 0`, so the three built-in retrievers disagree. + +## Motivation + +Retrievers must behave identically for the same inputs so users can swap them freely. Returning the entire corpus for `top_k=0` can also silently blow up a prompt sent to an LLM generator. + +## Acceptance criteria + +- [ ] `InMemoryRetriever.retrieve()` returns `[]` when `top_k <= 0` +- [ ] Non-integer / bool `top_k` raises `RetrieverError` (mirror `FAISSRetriever.retrieve`) +- [ ] Regression tests in `tests/test_retriever/test_in_memory.py` for `top_k` in `(0, -1, True)` +- [ ] `Retriever.retrieve` docstring in `ragframework/base.py` states the contract: non-positive `top_k` → empty list +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` + +## Files to touch + +- `ragframework/retriever/in_memory.py` — `retrieve()` (lines 38–48) +- `ragframework/base.py` — `Retriever.retrieve` docstring +- `tests/test_retriever/test_in_memory.py` + +## Resources + +- `FAISSRetriever.retrieve` in `ragframework/retriever/faiss.py` for the reference behaviour +- `test_non_positive_top_k_returns_empty` in `tests/test_retriever/test_chroma.py` + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/012-loader-unicode-decode-error.md b/.github/issues/012-loader-unicode-decode-error.md new file mode 100644 index 0000000..51e5be2 --- /dev/null +++ b/.github/issues/012-loader-unicode-decode-error.md @@ -0,0 +1,39 @@ +--- +title: "TextFileLoader / MarkdownLoader leak UnicodeDecodeError instead of LoaderError" +labels: ["bug", "good first issue"] +--- + +## Description + +Both file loaders only catch `OSError` around `path.read_text(...)`. A file that is not valid in the configured encoding raises a raw `UnicodeDecodeError` (a `ValueError`), and an unknown encoding name raises `LookupError`: + +```python +TextFileLoader().load("latin1.txt") # UnicodeDecodeError +TextFileLoader(encoding="nope").load("a.txt") # LookupError +``` + +The documented contract (`ragframework/exceptions.py`, `CONTRIBUTING.md`) is that loaders raise `LoaderError`. + +## Motivation + +Callers that catch `LoaderError` (or `RAGFrameworkError`) miss these failures. The pipeline still wraps them in `PipelineError`, but direct loader users do not get the promised exception type. + +## Acceptance criteria + +- [ ] `UnicodeDecodeError` and `LookupError` are converted to `LoaderError` with the file path and encoding in the message +- [ ] The duplicated exists / is_file / read_text block in `TextFileLoader` and `MarkdownLoader` is extracted into a private helper (e.g. `_read_text_file(path, encoding)`) so the fix lives in one place +- [ ] Optional: `errors: str = "strict"` constructor argument passed through to `read_text` so users can opt into `"replace"` +- [ ] Tests for both failure modes in `tests/test_document/test_loaders.py` +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` + +## Files to touch + +- `ragframework/document/loaders.py` — `TextFileLoader.load`, `MarkdownLoader.load` +- `tests/test_document/test_loaders.py` + +## Resources + +- [`Path.read_text`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.read_text) +- [Error handlers](https://docs.python.org/3/library/codecs.html#error-handlers) + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/013-fixed-size-chunker-validation.md b/.github/issues/013-fixed-size-chunker-validation.md new file mode 100644 index 0000000..08e4901 --- /dev/null +++ b/.github/issues/013-fixed-size-chunker-validation.md @@ -0,0 +1,42 @@ +--- +title: "FixedSizeChunker accepts chunk_size <= 0 and negative chunk_overlap" +labels: ["bug", "good first issue"] +--- + +## Description + +`FixedSizeChunker.__init__` only checks `chunk_overlap >= chunk_size`. Invalid values slip through and produce garbage: + +```python +FixedSizeChunker(chunk_size=0, chunk_overlap=-1).chunk(Document("d", "hello")) +# -> 5 empty chunks + +FixedSizeChunker(chunk_size=10, chunk_overlap=-5).chunk(Document("d", "0123456789ABCDEFGHIJKLMNOPQRS")) +# -> ['0123456789', 'FGHIJKLMNO'] characters A–E and P–S silently dropped +``` + +`RecursiveChunker` and `RAGConfig` already validate all three conditions; `FixedSizeChunker` and `SentenceChunker` do not. + +## Motivation + +Negative overlap silently loses text from the index — the worst kind of bug for a retrieval system, because nothing fails and recall just degrades. + +## Acceptance criteria + +- [ ] `FixedSizeChunker` raises `ValueError` for `chunk_size <= 0` or `chunk_overlap < 0` (same messages as `RecursiveChunker`) +- [ ] `SentenceChunker` raises `ValueError` for `max_sentences <= 0` or `overlap_sentences < 0` +- [ ] `RecursiveChunker.from_config(config)` added, matching the classmethod `FixedSizeChunker` already has +- [ ] Parametrised tests in `tests/test_document/test_chunkers.py` covering each invalid value +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` + +## Files to touch + +- `ragframework/document/chunkers.py` — `FixedSizeChunker.__init__` (line 21), `SentenceChunker.__init__` (line 65) +- `tests/test_document/test_chunkers.py` + +## Resources + +- `RecursiveChunker.__init__` (lines 116–121) for the validation to copy +- `RAGConfig.__post_init__` in `ragframework/config.py` + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/014-inmemory-retriever-dimension-check.md b/.github/issues/014-inmemory-retriever-dimension-check.md new file mode 100644 index 0000000..1c963fa --- /dev/null +++ b/.github/issues/014-inmemory-retriever-dimension-check.md @@ -0,0 +1,43 @@ +--- +title: "InMemoryRetriever raises raw numpy ValueError on embedding dimension mismatch" +labels: ["bug", "good first issue"] +--- + +## Description + +`InMemoryRetriever` performs no validation of vector shape or content: + +```python +r.add([Chunk(id="a", content="a", embedding=[1.0, 0.0, 0.0])]) +r.add([Chunk(id="b", content="b", embedding=[1.0, 0.0])]) # ValueError from np.array (ragged) +r.retrieve([1.0, 0.0]) # ValueError from matrix @ q +``` + +`FAISSRetriever` already has `_validated_vector()` that checks 1-D, non-empty, finite, non-zero, and enforces a consistent dimension across `add()` and `retrieve()`, raising `RetrieverError` with a clear message. `InMemoryRetriever` should behave the same way. + +## Motivation + +Dimension mismatches are the most common mistake when swapping embedders (e.g. 384-dim MiniLM vs 1536-dim OpenAI). Users deserve `RetrieverError: Query embedding has dimension 384; expected 1536.` rather than a numpy shape error. + +## Acceptance criteria + +- [ ] Move `FAISSRetriever._validated_vector` into a shared helper, e.g. `ragframework/utils/vectors.py::validate_vector(values, label) -> np.ndarray` +- [ ] `InMemoryRetriever.add()` validates every chunk vector before mutating state and tracks `self._dimension` +- [ ] `InMemoryRetriever.retrieve()` validates the query vector and its dimension, raising `RetrieverError` +- [ ] `FAISSRetriever` uses the shared helper (behaviour unchanged, its tests still pass) +- [ ] Tests in `tests/test_retriever/test_in_memory.py` for: wrong dimension on add, wrong dimension on query, NaN, zero vector, empty vector +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` + +## Files to touch + +- `ragframework/utils/vectors.py` — new +- `ragframework/utils/__init__.py` — export +- `ragframework/retriever/in_memory.py` +- `ragframework/retriever/faiss.py` — replace private static method with the helper +- `tests/test_retriever/test_in_memory.py` + +## Resources + +- `FAISSRetriever._validated_vector` (`ragframework/retriever/faiss.py` lines 131–144) + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/015-inmemory-retriever-incremental-add.md b/.github/issues/015-inmemory-retriever-incremental-add.md new file mode 100644 index 0000000..6989206 --- /dev/null +++ b/.github/issues/015-inmemory-retriever-incremental-add.md @@ -0,0 +1,37 @@ +--- +title: "InMemoryRetriever.add() re-normalises the entire matrix on every call (O(N²))" +labels: ["bug", "good first issue"] +--- + +## Description + +Every call to `InMemoryRetriever.add()` rebuilds `self._matrix` from **all** stored chunks: + +```python +self._chunks.extend(chunks) +vectors = np.array([c.embedding for c in self._chunks], dtype=np.float32) # all N, every time +``` + +Ingesting N documents one at a time is therefore O(N²). Measured locally: 2 000 single-chunk `add()` calls with 64-dim vectors take ~2 s; 20 000 would take minutes. + +## Motivation + +`RAGPipeline.ingest()` is called once per source, so per-file ingestion of a folder hits exactly this path. Incremental ingestion should be linear. + +## Acceptance criteria + +- [ ] `add()` normalises only the **new** vectors and appends them (`np.vstack` onto the existing matrix, or keep a list of row blocks and concatenate lazily on first `retrieve()` after a change) +- [ ] Retrieval results are identical before/after the change (existing tests pass) +- [ ] A test that adds chunks in several batches and asserts `len(r)` and the top result match a single-batch add +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` + +## Files to touch + +- `ragframework/retriever/in_memory.py` — `add()` (lines 25–36) +- `tests/test_retriever/test_in_memory.py` + +## Resources + +- [`numpy.vstack`](https://numpy.org/doc/stable/reference/generated/numpy.vstack.html) + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/016-idempotent-retriever-add.md b/.github/issues/016-idempotent-retriever-add.md new file mode 100644 index 0000000..5f841c8 --- /dev/null +++ b/.github/issues/016-idempotent-retriever-add.md @@ -0,0 +1,42 @@ +--- +title: "Re-ingesting the same source duplicates chunks in InMemoryRetriever and FAISSRetriever" +labels: ["bug", "help wanted"] +--- + +## Description + +`RAGPipeline.ingest(path)` called twice on the same file doubles the index: + +```python +pipe.ingest("doc.txt") # 12 chunks +pipe.ingest("doc.txt") # 12 chunks +len(pipe.retriever) # 24 — same 12 chunk ids stored twice +``` + +`ChromaRetriever.add()` uses `upsert`, so it is idempotent on chunk id. `InMemoryRetriever` and `FAISSRetriever` blindly append. The `Retriever.add` docstring in `base.py` does not say which behaviour is correct. + +## Motivation + +Re-running an ingestion script (the normal workflow while iterating) silently pollutes results: the same chunk appears multiple times in `top_k`, crowding out genuinely different context. Chunk ids are already deterministic (`:`, where `doc_id` is a hash of the source path), so idempotency is achievable. + +## Acceptance criteria + +- [ ] `Retriever.add` docstring in `ragframework/base.py` defines the contract: adding a chunk whose `id` already exists **replaces** it (upsert semantics) +- [ ] `InMemoryRetriever.add()` replaces existing rows by id (maintain an `id -> row index` dict) +- [ ] `FAISSRetriever.add()` handles duplicates. `IndexHNSWFlat` does not support removal, so pick and document one approach: (a) keep an `id -> label` map and tombstone superseded labels (filtered out in `retrieve`, over-fetching to compensate), or (b) wrap the index in `faiss.IndexIDMap2` if it supports the chosen index type. Explain the trade-off in the docstring. +- [ ] Tests for all three retrievers: add the same chunk twice → `len()` unchanged, retrieval returns it once, and the **new** content/embedding wins +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` + +## Files to touch + +- `ragframework/base.py` — `Retriever.add` docstring +- `ragframework/retriever/in_memory.py` +- `ragframework/retriever/faiss.py` +- `tests/test_retriever/test_in_memory.py`, `tests/test_retriever/test_faiss.py`, `tests/test_retriever/test_chroma.py` + +## Resources + +- [FAISS: removing vectors](https://github.com/facebookresearch/faiss/wiki/Special-operations-on-indexes#removing-elements-from-an-index) +- `ChromaRetriever.add` for the upsert reference behaviour + +**Estimated effort:** Medium (half day) diff --git a/.github/issues/017-ragconfig-not-applied.md b/.github/issues/017-ragconfig-not-applied.md new file mode 100644 index 0000000..9544340 --- /dev/null +++ b/.github/issues/017-ragconfig-not-applied.md @@ -0,0 +1,48 @@ +--- +title: "RAGConfig.chunk_size / chunk_overlap / embedding_dim are silently ignored by RAGPipeline" +labels: ["bug", "help wanted"] +--- + +## Description + +`RAGPipeline` reads exactly one field from its config — `top_k`. The other three are dead: + +```python +pipe = RAGPipeline( + chunker=FixedSizeChunker(), # chunk_size=512 + embedder=RandomEmbedder(dim=16), + config=RAGConfig(chunk_size=20, chunk_overlap=5, embedding_dim=8), + ... +) +pipe.chunker.chunk_size # 512 — config ignored, no warning +pipe.embedder.dim # 16 — embedding_dim=8 never checked +``` + +The `RAGConfig` docstring says `embedding_dim` "must match the Embedder in use", but nothing verifies it. Only `FixedSizeChunker.from_config()` consumes the chunk fields, and it must be called by hand. + +## Motivation + +Users reading the README's `config=RAGConfig(top_k=5)` example reasonably assume the other fields work the same way. A config object that looks authoritative but isn't is a trap. + +## Proposed resolution + +1. **Enforce `embedding_dim`.** In `ingest()` and `query()`, after the first `embed()` call, compare `len(vector)` to `config.embedding_dim` and raise `PipelineError("Embedder produced 16-dim vectors but RAGConfig.embedding_dim is 8")`. Change the default to `embedding_dim: int | None = None` so the check is opt-in and existing users are not broken. +2. **Make the chunk fields usable.** Add `RAGPipeline.from_config(config, *, loader, embedder, retriever, generator, chunker_cls=FixedSizeChunker)` classmethod that builds the chunker via `chunker_cls.from_config(config)` (depends on `RecursiveChunker.from_config` from #25). +3. **Document** in the `RAGConfig` docstring which fields the pipeline enforces vs. which are consumed only by `from_config` helpers. + +## Acceptance criteria + +- [ ] `embedding_dim` mismatch raises `PipelineError` with both numbers in the message; `None` disables the check +- [ ] `RAGPipeline.from_config(...)` exists and is covered by a test +- [ ] `RAGConfig` docstring updated; README Quick Start mentions `from_config` +- [ ] Existing tests pass unchanged (default `RAGConfig()` must still work with `RandomEmbedder(dim=16)` in `tests/`) — hence the `None` default +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Fixed` / `### Changed` + +## Files to touch + +- `ragframework/config.py` +- `ragframework/pipeline/rag.py` +- `tests/test_pipeline/test_rag.py`, `tests/test_config.py` +- `README.md` + +**Estimated effort:** Medium (half day) diff --git a/.github/issues/018-getting-started-markdown-broken.md b/.github/issues/018-getting-started-markdown-broken.md new file mode 100644 index 0000000..a3c036e --- /dev/null +++ b/.github/issues/018-getting-started-markdown-broken.md @@ -0,0 +1,39 @@ +--- +title: "docs/getting-started.md renders as plain text (escaped headings, HTML entities, 322 blank lines)" +labels: ["bug", "documentation", "good first issue"] +--- + +## Description + +`docs/getting-started.md` appears to have been exported from a rich-text editor. On GitHub it renders as one unformatted blob: + +- Every heading is escaped: `\# Getting Started`, `\## Installation`, `\### Requirements` (29 occurrences) +- Indentation inside code blocks is written as ` ` HTML entities +- Underscores are escaped: `chunk\_size`, `top\_k` +- 322 of the 480 lines are blank (every paragraph is separated by 3 empty lines) +- File uses CRLF line endings (see the `.gitattributes` issue) + +## Motivation + +This is the first document a new contributor or user opens after the README. Right now it is unreadable, which undermines the whole onboarding story. + +## Acceptance criteria + +- [ ] All `\#` headings converted to real Markdown headings with a sensible hierarchy (`#` → `##` → `###`) +- [ ] ` ` and `\_` escapes removed; code samples are inside fenced ` ```python ` blocks and actually run against the current API +- [ ] Blank-line bloat collapsed to standard single blank lines between paragraphs +- [ ] Content checked against the current codebase: mentions `PDFLoader`, `RecursiveChunker`, `HuggingFaceEmbedder`, `FAISSRetriever`, `ChromaRetriever` where relevant +- [ ] File saved with LF line endings +- [ ] Linked from `README.md` (it currently is not) and from `CONTRIBUTING.md` + +## Files to touch + +- `docs/getting-started.md` +- `README.md` — add a link under Quick Start +- `CONTRIBUTING.md` — add a link + +## Resources + +- `README.md` Quick Start and `examples/basic_rag.py` as the source of truth for working code + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/019-stale-roadmap-and-docs.md b/.github/issues/019-stale-roadmap-and-docs.md new file mode 100644 index 0000000..ec29324 --- /dev/null +++ b/.github/issues/019-stale-roadmap-and-docs.md @@ -0,0 +1,32 @@ +--- +title: "README roadmap, GOOD_FIRST_ISSUES.md and CHANGELOG are out of date" +labels: ["documentation", "good first issue"] +--- + +## Description + +Several docs still describe the v0.1.0 state: + +- **`README.md` Roadmap table** marks PDF loader, HuggingFace embedder, FAISS, ChromaDB, recursive chunker and Jupyter notebook as `Open` — all six are merged (issues #1, #3, #4, #5, #7, #10 closed) +- **`.github/GOOD_FIRST_ISSUES.md`** says issues "will be opened" and lists the same six finished items as available work; it also duplicates the content of `.github/issues/*.md`, so it drifts every time an issue file changes +- **`CHANGELOG.md`** `[Unreleased]` section: missing blank line before `## [0.1.0]`, and two entries lack backticks (`RecursiveChunker`, `PDFLoader`) unlike the others +- **`examples/README.md`** says OpenAI integrations "are not included" — still true, but it should point at the open issues (#6, #8, #20) instead of the stale good-first-issues file +- Ten source docstrings reference `.github/GOOD_FIRST_ISSUES.md` (e.g. `InMemoryRetriever`, `EchoGenerator`, `RandomEmbedder`, `loaders.py`, `chunkers.py`) — they should link to the GitHub issues page instead so they don't rot + +## Acceptance criteria + +- [ ] Roadmap table reflects reality (Done / Open / In progress) and links each row to its issue number +- [ ] `GOOD_FIRST_ISSUES.md` becomes a short pointer: how to find issues (`label:"good first issue"` link), how to claim one, link to `CONTRIBUTING.md`. Detailed specs live only in `.github/issues/*.md` + GitHub +- [ ] `CHANGELOG.md` formatting fixed +- [ ] Docstring references updated to `https://github.com/adaumsilva/RAG-framework/issues` +- [ ] `examples/README.md` links to the open integration issues + +## Files to touch + +- `README.md` +- `.github/GOOD_FIRST_ISSUES.md` +- `CHANGELOG.md` +- `examples/README.md` +- `ragframework/document/loaders.py`, `ragframework/document/chunkers.py`, `ragframework/embeddings/random_embedder.py`, `ragframework/retriever/in_memory.py`, `ragframework/generator/echo_generator.py` + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/020-sentence-chunker-max-chars.md b/.github/issues/020-sentence-chunker-max-chars.md new file mode 100644 index 0000000..35879bd --- /dev/null +++ b/.github/issues/020-sentence-chunker-max-chars.md @@ -0,0 +1,26 @@ +--- +title: "SentenceChunker: enforce a maximum character length per chunk" +labels: ["enhancement", "good first issue"] +--- + +## Description + +`SentenceChunker` groups N sentences per chunk with no upper bound on characters. A document with three 5 000-character "sentences" yields a single 15 005-character chunk, which will exceed most embedding models' context windows and get truncated silently by the provider. + +## Motivation + +Chunk size limits are the whole point of chunking. Every other chunker in the package is size-bounded; this one should be too. + +## Acceptance criteria + +- [ ] New constructor argument `max_chars: int | None = None`. When set, a window that would exceed `max_chars` is closed early (fewer than `max_sentences`), and a single sentence longer than `max_chars` is hard-split at `max_chars` (reuse the fixed-window logic from `FixedSizeChunker`, or delegate to `RecursiveChunker` with `separators=[" ", ""]`) +- [ ] `max_chars <= 0` raises `ValueError` +- [ ] Tests: window closes early; oversized single sentence is split; `None` preserves current behaviour +- [ ] Docstring updated; `CHANGELOG.md` updated under `[Unreleased]` → `### Added` + +## Files to touch + +- `ragframework/document/chunkers.py` — `SentenceChunker` +- `tests/test_document/test_chunkers.py` + +**Estimated effort:** Small (< 2 hours) diff --git a/.github/issues/021-retrieval-scores.md b/.github/issues/021-retrieval-scores.md new file mode 100644 index 0000000..ff30d36 --- /dev/null +++ b/.github/issues/021-retrieval-scores.md @@ -0,0 +1,43 @@ +--- +title: "Expose similarity scores from retrievers and in RAGResponse" +labels: ["enhancement", "help wanted"] +--- + +## Description + +All three retrievers compute a similarity score and throw it away: + +- `InMemoryRetriever.retrieve` — `scores = self._matrix @ q`, only used for ordering +- `FAISSRetriever.retrieve` — `_scores, labels = self._index.search(...)` +- `ChromaRetriever.retrieve` — `include=["documents", "metadatas"]` (omits `"distances"`) + +Users cannot filter by a relevance threshold, log retrieval quality, or show confidence in a UI. + +## Proposed design + +- Add `score: float | None = None` to the `Chunk` dataclass (last field, default `None`, so existing code is unaffected) +- Each retriever sets `score` on the **returned** chunks. Returned chunks should be shallow copies (`dataclasses.replace(chunk, score=s)`) so the stored chunk is not mutated +- Document the scale: cosine similarity in `[-1, 1]`, higher is better. `ChromaRetriever` must convert Chroma's distance — create collections with `metadata={"hnsw:space": "cosine"}` explicitly (Chroma defaults to L2) and return `1 - distance` +- Add `RAGConfig.score_threshold: float | None = None`; `RAGPipeline.query()` drops chunks below it before generation + +## Acceptance criteria + +- [ ] `Chunk.score` field added and documented +- [ ] All three retrievers populate it; tests assert the top result's score ≈ 1.0 for an identical vector and that the stored chunk's `score` stays `None` +- [ ] `ChromaRetriever` creates collections with an explicit cosine space +- [ ] `score_threshold` implemented in the pipeline with a test +- [ ] `CHANGELOG.md` updated under `[Unreleased]` → `### Added` + +## Files to touch + +- `ragframework/base.py` — `Chunk` +- `ragframework/retriever/in_memory.py`, `faiss.py`, `chroma.py` +- `ragframework/config.py`, `ragframework/pipeline/rag.py` +- Tests for each + +## Resources + +- [Chroma distance functions](https://docs.trychroma.com/docs/collections/configure) +- [`dataclasses.replace`](https://docs.python.org/3/library/dataclasses.html#dataclasses.replace) + +**Estimated effort:** Medium (half day) diff --git a/.github/issues/022-directory-loader.md b/.github/issues/022-directory-loader.md new file mode 100644 index 0000000..ea110ce --- /dev/null +++ b/.github/issues/022-directory-loader.md @@ -0,0 +1,46 @@ +--- +title: "DirectoryLoader: ingest a whole folder with per-extension loader dispatch" +labels: ["enhancement", "good first issue"] +--- + +## Description + +Add `DirectoryLoader` that walks a directory (optionally recursive, with glob filtering) and delegates each file to the right loader by extension, returning the concatenated `list[Document]`. + +```python +loader = DirectoryLoader( + loaders={".txt": TextFileLoader(), ".md": MarkdownLoader(), ".pdf": PDFLoader()}, + glob="**/*", + recursive=True, + on_error="skip", # or "raise" +) +pipeline = RAGPipeline(loader=loader, ...) +pipeline.ingest("./knowledge_base") +``` + +## Motivation + +Today `RAGPipeline.ingest()` takes a single file path, so indexing a corpus means writing a loop by hand and choosing loaders manually. This is the single most requested convenience in every RAG library. + +## Acceptance criteria + +- [ ] Class `DirectoryLoader(DocumentLoader)` in `ragframework/document/loaders.py` +- [ ] Sensible default `loaders` mapping (`.txt`, `.md`, `.markdown`, `.pdf` when `pypdf` is importable) built lazily so importing the module never requires optional deps +- [ ] Files with no matching loader are skipped (and counted); `on_error="raise"` re-raises as `LoaderError` with the offending path +- [ ] `source` must be an existing directory, otherwise `LoaderError` +- [ ] Deterministic ordering (sorted paths) so chunk ids are stable across runs +- [ ] Each `Document.metadata` includes `"relative_path"` from the root directory +- [ ] Tests using `tmp_path` with mixed extensions, nested folders, an unreadable file with both `on_error` modes +- [ ] Exported from `ragframework/document/__init__.py`; `CHANGELOG.md` updated + +## Files to touch + +- `ragframework/document/loaders.py` +- `ragframework/document/__init__.py` +- `tests/test_document/test_loaders.py` + +## Resources + +- [`Path.rglob`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) + +**Estimated effort:** Medium (half day) diff --git a/.github/issues/023-html-loader.md b/.github/issues/023-html-loader.md new file mode 100644 index 0000000..20b682a --- /dev/null +++ b/.github/issues/023-html-loader.md @@ -0,0 +1,36 @@ +--- +title: "HTMLLoader for local .html files and web URLs" +labels: ["enhancement", "good first issue"] +--- + +## Description + +Add `HTMLLoader` that extracts readable text from an HTML file or URL, dropping `