Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion agentforge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
60 changes: 46 additions & 14 deletions agentforge/rag/catalog.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 []
65 changes: 55 additions & 10 deletions agentforge/rag/store.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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).

Expand All @@ -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
30 changes: 30 additions & 0 deletions docker-compose.qdrant.yml
Original file line number Diff line number Diff line change
@@ -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:
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions tests/test_store_backend.py
Original file line number Diff line number Diff line change
@@ -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() == []