diff --git a/.env.example b/.env.example index a610c5c..9dd1261 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,14 @@ OLLAMA_BASE_URL=http://localhost:11434 EMBEDDING_MODEL=ollama:nomic-embed-text EMBEDDING_DIM=768 -# Vector store (Postgres + pgvector). docker-compose provides this for you. +# Vector store backend: "pgvector" (default) or "qdrant" (needs the qdrant extra). +VECTOR_STORE_BACKEND=pgvector +# Postgres + pgvector. docker-compose provides this for you. DATABASE_URL=postgresql+psycopg://agentforge:agentforge@localhost:5432/agentforge COLLECTION_NAME=agentforge_documents +# Qdrant (used when VECTOR_STORE_BACKEND=qdrant). The qdrant overlay sets the URL. +QDRANT_URL=http://localhost:6333 +QDRANT_API_KEY= # Graph checkpointer: "memory" (in-process) or "postgres" (durable HITL/resume). CHECKPOINT_BACKEND=memory diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8a08f0..dbd15f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: run: docker compose config --quiet - name: docker compose config (+ langfuse overlay) run: docker compose -f docker-compose.yml -f docker-compose.langfuse.yml config --quiet + - name: docker compose config (+ qdrant overlay) + run: docker compose -f docker-compose.yml -f docker-compose.qdrant.yml config --quiet # Validate the Kubernetes manifests render and pass schema validation. k8s-validate: diff --git a/README.md b/README.md index 23bf3d4..32b23e2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ A **banking compliance assistant** ships as the reference example — RAG over p | Language | Python | Backend + agents | | Agent framework | LangChain 1.0 / LangGraph 1.0 | Stable since Oct 2025, no breaking changes until 2.0 | | API | FastAPI | REST + streaming | -| Vector store | Postgres + pgvector | Self-hostable; Qdrant adapter planned | +| Vector store | Postgres + pgvector (default) or Qdrant | Pluggable via `VECTOR_STORE_BACKEND` | | Observability | LangSmith (default) · Langfuse (OSS) | Pluggable backend | | Frontend | Angular | Management console | | Packaging | Docker / Docker Compose | One-command local run | @@ -89,6 +89,13 @@ the end-to-end flow (grounded answers, refusals, streaming, HITL approval). For deploying beyond your laptop — published GHCR images and a release pipeline — see [`deploy/README.md`](deploy/README.md). +Swap the defaults with compose overlays: use Qdrant instead of pgvector, or add a +self-hosted Langfuse for tracing. + +```bash +docker compose -f docker-compose.yml -f docker-compose.qdrant.yml up --build +``` + ## Repo structure The scaffold for every phase is in place — a runnable skeleton you extend. diff --git a/agentforge/config.py b/agentforge/config.py index 190514b..9c92081 100644 --- a/agentforge/config.py +++ b/agentforge/config.py @@ -40,11 +40,16 @@ class Settings(BaseSettings): # Must match the embedding model's output dimensionality. embedding_dim: int = Field(default=768) - # --- Vector store (Postgres + pgvector) ------------------------------ + # --- Vector store ---------------------------------------------------- + # Which backend holds the embeddings: "pgvector" (default) or "qdrant". + vector_store_backend: str = Field(default="pgvector") database_url: str = Field( default="postgresql+psycopg://agentforge:agentforge@localhost:5432/agentforge" ) collection_name: str = Field(default="agentforge_documents") + # Qdrant connection (used when vector_store_backend == "qdrant"). + qdrant_url: str = Field(default="http://localhost:6333") + qdrant_api_key: str | None = None # Graph checkpointer backend: "memory" (in-process, dev/tests) or "postgres" # (durable — HITL state survives restarts and is shared across replicas). # docker-compose / k8s set this to "postgres". diff --git a/agentforge/rag/catalog.py b/agentforge/rag/catalog.py index 4c54636..5b54c40 100644 --- a/agentforge/rag/catalog.py +++ b/agentforge/rag/catalog.py @@ -1,15 +1,16 @@ """Read-only catalog of what's ingested in the vector store. -Powers the console's Knowledge view so RAG grounding is transparent. Queries the -``langchain_postgres`` tables directly (grouping chunks by their ``source`` -metadata) rather than embedding a search, so the listing is exact. +Powers the console's Knowledge view so RAG grounding is transparent. Each backend +needs its own enumeration: pgvector groups rows in SQL; Qdrant scrolls points and +aggregates by source metadata. """ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass -from agentforge.config import get_settings, libpq_url +from agentforge.config import Settings, get_settings, libpq_url # Group the collection's chunks by source document. Joins the embedding rows to # their collection by name so we only count this app's collection. @@ -33,19 +34,50 @@ class DocumentSummary: chunks: int -def list_documents() -> list[DocumentSummary]: - """One row per ingested source document, or ``[]`` if the store is unreachable.""" +def _from_pgvector(settings: Settings) -> list[DocumentSummary]: import psycopg - settings = get_settings() - try: - with psycopg.connect(libpq_url(settings.database_url), connect_timeout=3) as conn: - rows = conn.execute(_SQL, (settings.collection_name,)).fetchall() - except Exception: - # Store not provisioned yet / unreachable — empty catalog, not an error. - return [] - + with psycopg.connect(libpq_url(settings.database_url), connect_timeout=3) as conn: + rows = conn.execute(_SQL, (settings.collection_name,)).fetchall() return [ DocumentSummary(source=row[0] or "(unknown)", title=row[1] or "", chunks=row[2]) for row in rows ] + + +def _from_qdrant(settings: Settings) -> list[DocumentSummary]: + from qdrant_client import QdrantClient + + client = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key) + titles: dict[str, str] = {} + chunks: dict[str, int] = defaultdict(int) + + offset = None + while True: + points, offset = client.scroll( + settings.collection_name, with_payload=True, limit=256, offset=offset + ) + for point in points: + meta = (point.payload or {}).get("metadata", {}) + source = meta.get("source") or "(unknown)" + chunks[source] += 1 + titles.setdefault(source, meta.get("title", "")) + if offset is None: + break + + return [ + DocumentSummary(source=source, title=titles.get(source, ""), chunks=count) + for source, count in sorted(chunks.items()) + ] + + +def list_documents() -> list[DocumentSummary]: + """One row per ingested source document, or ``[]`` if the store is unreachable.""" + settings = get_settings() + try: + if settings.vector_store_backend.lower() == "qdrant": + return _from_qdrant(settings) + return _from_pgvector(settings) + except Exception: + # Store not provisioned yet / unreachable / extra missing — empty catalog. + return [] diff --git a/agentforge/rag/store.py b/agentforge/rag/store.py index c51c097..77bfd8d 100644 --- a/agentforge/rag/store.py +++ b/agentforge/rag/store.py @@ -1,22 +1,26 @@ -"""Vector store over Postgres + pgvector. +"""Vector store over a pluggable backend. -Self-hostable by default. A ``Qdrant`` adapter is a planned drop-in (it would -implement the same tiny surface: ``add_documents`` + ``similarity_search_with_score``). +``VECTOR_STORE_BACKEND`` selects the implementation: + +- ``pgvector`` (default) — Postgres + pgvector, self-hostable with the bundled DB. +- ``qdrant`` — a Qdrant instance (``agentforge[qdrant]`` extra). + +Both expose the same LangChain ``VectorStore`` surface (``add_documents`` + +``similarity_search_with_relevance_scores``), so ingestion and retrieval are +backend-agnostic — only construction differs. """ from __future__ import annotations from functools import lru_cache -from langchain_postgres import PGVector - -from agentforge.config import get_settings +from agentforge.config import Settings, get_settings from agentforge.llm import get_embeddings -@lru_cache -def get_vector_store() -> PGVector: - settings = get_settings() +def _pgvector_store(settings: Settings): + from langchain_postgres import PGVector + return PGVector( embeddings=get_embeddings(), collection_name=settings.collection_name, @@ -25,6 +29,47 @@ def get_vector_store() -> PGVector: ) +def _qdrant_store(settings: Settings): + try: + from langchain_qdrant import QdrantVectorStore + from qdrant_client import QdrantClient + from qdrant_client.models import Distance, VectorParams + except ImportError as exc: # pragma: no cover - optional dependency + raise RuntimeError( + "VECTOR_STORE_BACKEND=qdrant requires the 'qdrant' extra: " + "pip install 'agentforge[qdrant]'" + ) from exc + + client = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key) + # Create the collection on first use (pgvector creates its tables implicitly; + # Qdrant needs the collection + vector params up front). Cosine matches the + # default pgvector distance, so the relevance threshold behaves the same. + if not client.collection_exists(settings.collection_name): + client.create_collection( + collection_name=settings.collection_name, + vectors_config=VectorParams(size=settings.embedding_dim, distance=Distance.COSINE), + ) + return QdrantVectorStore( + client=client, + collection_name=settings.collection_name, + embedding=get_embeddings(), + ) + + +@lru_cache +def get_vector_store(): + settings = get_settings() + backend = settings.vector_store_backend.lower() + if backend == "pgvector": + return _pgvector_store(settings) + if backend == "qdrant": + return _qdrant_store(settings) + raise ValueError( + f"Unknown VECTOR_STORE_BACKEND: {settings.vector_store_backend!r} " + "(use 'pgvector' or 'qdrant')" + ) + + def collection_is_empty() -> bool: """True if the store holds no documents (used to guard auto-ingest). @@ -34,5 +79,5 @@ def collection_is_empty() -> bool: try: return len(get_vector_store().similarity_search("ping", k=1)) == 0 except Exception: - # Table not created yet / store unreachable — treat as empty. + # Collection not created yet / store unreachable — treat as empty. return True diff --git a/docker-compose.qdrant.yml b/docker-compose.qdrant.yml new file mode 100644 index 0000000..3666ece --- /dev/null +++ b/docker-compose.qdrant.yml @@ -0,0 +1,30 @@ +# Use Qdrant as the vector store instead of pgvector. Overlay on the base compose: +# +# docker compose -f docker-compose.yml -f docker-compose.qdrant.yml up --build +# +# Postgres (from the base file) is still used for the durable checkpointer; only +# the embeddings move to Qdrant. `--build` rebuilds the api image with the qdrant +# extra (EXTRAS=qdrant). Auto-ingest populates the Qdrant collection on first boot. + +services: + qdrant: + image: qdrant/qdrant:latest + ports: + - "6333:6333" + volumes: + - qdrant_data:/qdrant/storage + + api: + build: + context: . + args: + EXTRAS: qdrant + environment: + VECTOR_STORE_BACKEND: qdrant + QDRANT_URL: http://qdrant:6333 + depends_on: + qdrant: + condition: service_started + +volumes: + qdrant_data: diff --git a/pyproject.toml b/pyproject.toml index c54a2cb..ff99f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dependencies = [ langfuse = ["langfuse>=2.0,<3.0"] # Local embeddings (sentence-transformers) — avoids any external embedding API. local-embeddings = ["langchain-huggingface>=0.1", "sentence-transformers>=3.0"] +# Qdrant vector-store backend (VECTOR_STORE_BACKEND=qdrant). +qdrant = ["langchain-qdrant>=0.2", "qdrant-client>=1.12"] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/tests/test_store_backend.py b/tests/test_store_backend.py new file mode 100644 index 0000000..6e0b24c --- /dev/null +++ b/tests/test_store_backend.py @@ -0,0 +1,39 @@ +"""Vector-store backend selection. No DB/Qdrant available, so this covers the +default, the unknown-backend guard, and the missing-extra / graceful paths.""" + +from __future__ import annotations + +import pytest + +from agentforge.config import Settings, get_settings +from agentforge.rag import catalog, store + + +@pytest.fixture(autouse=True) +def _clear_store_cache(): + store.get_vector_store.cache_clear() + yield + store.get_vector_store.cache_clear() + + +def test_default_backend_is_pgvector(): + assert Settings().vector_store_backend == "pgvector" + + +def test_unknown_backend_raises(monkeypatch): + monkeypatch.setattr(get_settings(), "vector_store_backend", "weaviate") + with pytest.raises(ValueError, match="Unknown VECTOR_STORE_BACKEND"): + store.get_vector_store() + + +def test_qdrant_backend_requires_extra(monkeypatch): + # qdrant isn't in the dev install, so selecting it must raise a helpful error. + monkeypatch.setattr(get_settings(), "vector_store_backend", "qdrant") + with pytest.raises(RuntimeError, match="qdrant"): + store.get_vector_store() + + +def test_catalog_empty_when_qdrant_unavailable(monkeypatch): + # No qdrant-client installed -> the scroll import fails -> graceful empty list. + monkeypatch.setattr(get_settings(), "vector_store_backend", "qdrant") + assert catalog.list_documents() == []