Skip to content

Commit 4ba9af1

Browse files
committed
Evolve Project Aether: Implement Postgres persistence, RQ background jobs for ingestion, and containerization
1 parent 37cb3fa commit 4ba9af1

11 files changed

Lines changed: 216 additions & 87 deletions

File tree

Dockerfile

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
# Install system dependencies
6+
RUN apt-get update && apt-get install -y \
7+
build-essential \
8+
libpq-dev \
9+
&& rm -rf /var/lib/apt/lists/*
10+
11+
# Install Python dependencies
12+
COPY requirements.txt .
13+
RUN pip install --no-cache-dir -r requirements.txt
14+
15+
# Copy application code
16+
COPY . .
17+
18+
# Expose API port
19+
EXPOSE 8000
20+
21+
# Default command (will be overridden by docker-compose)
22+
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]

docker-compose.yml

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,48 @@
1-
version: '3.8'
2-
31
services:
4-
qdrant:
5-
image: qdrant/qdrant:latest
6-
container_name: qdrant
2+
api:
3+
build: .
74
ports:
8-
- "6333:6333"
9-
- "6334:6334"
5+
- "8000:8000"
6+
env_file:
7+
- .env
8+
environment:
9+
- REDIS_HOST=redis
10+
- DATABASE_URL=postgresql://user:password@postgres:5432/aether
11+
depends_on:
12+
- redis
13+
- postgres
14+
volumes:
15+
- ./data:/app/data
16+
17+
worker:
18+
build: .
19+
command: python -m src.worker
20+
env_file:
21+
- .env
22+
environment:
23+
- REDIS_HOST=redis
24+
- DATABASE_URL=postgresql://user:password@postgres:5432/aether
25+
depends_on:
26+
- redis
27+
- postgres
1028
volumes:
11-
- qdrant_data:/qdrant/storage
12-
networks:
13-
- aether_network
29+
- ./data:/app/data
1430

1531
redis:
1632
image: redis:7-alpine
17-
container_name: redis_cache
1833
ports:
1934
- "6379:6379"
20-
volumes:
21-
- redis_data:/data
22-
command: redis-server --save 60 1 --loglevel warning
23-
networks:
24-
- aether_network
2535

26-
networks:
27-
aether_network:
28-
driver: bridge
36+
postgres:
37+
image: postgres:15-alpine
38+
environment:
39+
POSTGRES_DB: aether
40+
POSTGRES_USER: user
41+
POSTGRES_PASSWORD: password
42+
ports:
43+
- "5432:5432"
44+
volumes:
45+
- postgres_data:/var/lib/postgresql/data
2946

3047
volumes:
31-
qdrant_data:
32-
redis_data:
48+
postgres_data:

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ llama-index-vector-stores-qdrant
88
qdrant-client
99
chromadb>=0.6.0
1010
redis
11+
sqlalchemy
12+
psycopg2-binary
13+
rq
14+
alembic
1115
pydantic>=2.0
1216
pydantic-settings
1317
python-dotenv

src/api/app.py

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
from fastapi import FastAPI, HTTPException
1+
from fastapi import FastAPI, HTTPException, Depends
22
from pydantic import BaseModel
33
from src.services.chroma import ChromaService
4-
from src.pipeline.ingestion import IngestionWorkflow
54
from src.pipeline.retrieval import RetrievalWorkflow
65
from src.config.settings import settings
76
from src.utils.logger import logger
7+
from src.db.session import SessionLocal, get_db
8+
from src.models.db import IngestionJob
9+
from src.worker import queue
810
import os
911
from contextlib import asynccontextmanager
12+
from sqlalchemy.orm import Session
1013

1114
# Global variables for chroma service and workflow
1215
chroma_service = None
@@ -17,19 +20,13 @@ async def lifespan(app: FastAPI):
1720
global chroma_service, retrieval_wf
1821

1922
try:
20-
ingestion_wf = IngestionWorkflow()
21-
22-
if os.path.exists(settings.data_dir) and os.listdir(settings.data_dir):
23-
logger.info(f"Initializing ingestion from {settings.data_dir}...")
24-
# ingestion_wf.run returns chroma_service (StopEvent result)
25-
chroma_service = await ingestion_wf.run(input_dir=settings.data_dir)
26-
retrieval_wf = RetrievalWorkflow(chroma_service=chroma_service)
27-
logger.info("API Startup: Ingestion complete and Chroma Cloud index ready.")
28-
else:
29-
logger.warning(f"Data directory '{settings.data_dir}' is empty or missing. API in degraded mode.")
23+
# Initialize search infrastructure on startup
24+
chroma_service = ChromaService()
25+
retrieval_wf = RetrievalWorkflow(chroma_service=chroma_service)
26+
logger.info("API Startup: Chroma Cloud retrieval ready.")
3027
except Exception as e:
3128
logger.error(f"Startup failed: {e}")
32-
logger.warning("API starting in degraded mode due to infrastructure or ingestion error.")
29+
logger.warning("API starting in degraded mode due to infrastructure error.")
3330

3431
yield
3532

@@ -42,9 +39,48 @@ class QueryResponse(BaseModel):
4239
answer: str
4340
from_cache: bool
4441

42+
class IngestResponse(BaseModel):
43+
job_id: str
44+
45+
class JobStatusResponse(BaseModel):
46+
id: str
47+
status: str
48+
4549
@app.get("/health")
4650
async def health():
47-
return {"status": "ok", "index_ready": chroma_service is not None}
51+
return {"status": "ok", "retrieval_ready": retrieval_wf is not None}
52+
53+
@app.post("/ingest", response_model=IngestResponse, status_code=202)
54+
async def ingest_docs(db: Session = Depends(get_db)):
55+
"""
56+
Trigger document ingestion as a background job.
57+
"""
58+
try:
59+
# Create a job record in Postgres
60+
job = IngestionJob(status="PENDING")
61+
db.add(job)
62+
db.commit()
63+
db.refresh(job)
64+
65+
# Enqueue the background task
66+
queue.enqueue("src.jobs.ingestion.process_ingestion", str(job.id))
67+
68+
logger.info(f"Ingestion job {job.id} enqueued.")
69+
return IngestResponse(job_id=str(job.id))
70+
except Exception as e:
71+
logger.error(f"Failed to enqueue ingestion job: {e}")
72+
raise HTTPException(status_code=500, detail="Failed to trigger ingestion.")
73+
74+
@app.get("/jobs/{job_id}", response_model=JobStatusResponse)
75+
async def get_job_status(job_id: str, db: Session = Depends(get_db)):
76+
"""
77+
Check the status of an ingestion job.
78+
"""
79+
job = db.query(IngestionJob).filter(IngestionJob.id == job_id).first()
80+
if not job:
81+
raise HTTPException(status_code=404, detail="Job not found.")
82+
83+
return JobStatusResponse(id=str(job.id), status=job.status)
4884

4985
@app.post("/query", response_model=QueryResponse)
5086
async def query_docs(request: QueryRequest):

src/config/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class Settings(BaseSettings):
2020
redis_port: int = 6379
2121
semantic_cache_threshold: float = 0.85
2222

23+
database_url: str = "postgresql://user:password@postgres:5432/aether"
24+
2325
phoenix_collector_endpoint: str = "http://localhost:6006"
2426
log_level: str = "INFO"
2527

src/core/splitter.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/db/session.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.orm import sessionmaker
3+
from src.config.settings import settings
4+
5+
engine = create_engine(settings.database_url)
6+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
7+
8+
def get_db():
9+
db = SessionLocal()
10+
try:
11+
yield db
12+
finally:
13+
db.close()

src/jobs/ingestion.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import asyncio
2+
from src.db.session import SessionLocal
3+
from src.models.db import IngestionJob, Document
4+
from src.pipeline.ingestion import IngestionWorkflow
5+
from src.utils.logger import logger
6+
from src.config.settings import settings
7+
8+
def process_ingestion(job_id: str):
9+
"""
10+
Background job to process document ingestion.
11+
"""
12+
db = SessionLocal()
13+
job = db.query(IngestionJob).filter(IngestionJob.id == job_id).first()
14+
15+
if not job:
16+
logger.error(f"Job {job_id} not found in database.")
17+
db.close()
18+
return
19+
20+
job.status = "PROCESSING"
21+
db.commit()
22+
23+
try:
24+
logger.info(f"Starting ingestion workflow for job {job_id}")
25+
workflow = IngestionWorkflow()
26+
27+
# Run the workflow
28+
# Note: workflow.run is async, so we need to run it in an event loop
29+
loop = asyncio.get_event_loop()
30+
if loop.is_running():
31+
# This shouldn't happen in a standard RQ worker but just in case
32+
future = asyncio.ensure_future(workflow.run(input_dir=settings.data_dir))
33+
loop.run_until_complete(future)
34+
else:
35+
asyncio.run(workflow.run(input_dir=settings.data_dir))
36+
37+
job.status = "COMPLETED"
38+
logger.info(f"Ingestion job {job_id} completed successfully.")
39+
except Exception as e:
40+
logger.error(f"Ingestion job {job_id} failed: {e}")
41+
job.status = "FAILED"
42+
finally:
43+
db.commit()
44+
db.close()

src/models/db.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import uuid
2+
from datetime import datetime
3+
from sqlalchemy import Column, String, DateTime, ForeignKey
4+
from sqlalchemy.dialects.postgresql import UUID
5+
from sqlalchemy.orm import declarative_base
6+
7+
Base = declarative_base()
8+
9+
class Document(Base):
10+
__tablename__ = "documents"
11+
12+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
13+
filename = Column(String, nullable=True)
14+
status = Column(String, default="PENDING") # PENDING, COMPLETED, FAILED
15+
created_at = Column(DateTime, default=datetime.utcnow)
16+
17+
class IngestionJob(Base):
18+
__tablename__ = "ingestion_jobs"
19+
20+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
21+
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=True)
22+
status = Column(String, default="PENDING") # PENDING, PROCESSING, COMPLETED, FAILED
23+
trace_id = Column(String, nullable=True)
24+
created_at = Column(DateTime, default=datetime.utcnow)

src/pipeline/ingestion.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from typing import List
22
from llama_index.core import Document
3-
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
43
from llama_index.core.workflow import (
54
Workflow,
65
Event,
@@ -9,7 +8,7 @@
98
step,
109
)
1110
from tenacity import retry, stop_after_attempt, wait_exponential
12-
from src.core.splitter import SemanticDoubleMergingSplitter
11+
from llama_index.core.node_parser import SentenceSplitter
1312
from src.config.settings import settings
1413
from src.utils.token_counter import TokenCounter
1514
from src.core.pii import PIIMasker
@@ -33,10 +32,9 @@ class IngestionWorkflow(Workflow):
3332
def __init__(self, **kwargs: dict) -> None:
3433
super().__init__(**kwargs)
3534
self.chroma_service = ChromaService()
36-
self.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5")
37-
self.node_parser = SemanticDoubleMergingSplitter(
38-
embed_model=self.embed_model,
39-
min_chunk_size=500
35+
self.node_parser = SentenceSplitter(
36+
chunk_size=1024,
37+
chunk_overlap=20
4038
)
4139
self.token_counter = TokenCounter()
4240
self.pii_masker = PIIMasker()

0 commit comments

Comments
 (0)