# Description of Changes
Flesh out the RAG system and connect it to the PDF Question Agent so it
can respond to questions about PDFs of an extremely large size.
I'd expect lots more work will need to be done to finish off the RAG
system to really be what we need, but this should be a reasonable start
which will let us connect it to tools and have the ingestion mostly
handled automatically. I'm leaving file deletion and proper file ID
management to be done in a future PR. We also need to consider whether
all tools should retrieve content exclusively via RAG, or whether it's
beneficial to have tools sometimes fetch the direct content and other
times fetch it from RAG.
A diagram of the expected interaction is as follows:
```mermaid
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend<br/>(ChatPanel)
participant J as Java<br/>(AiWorkflowService)
participant O as Engine:<br/>OrchestratorAgent
participant QA as Engine:<br/>PdfQuestionAgent
participant RAG as Engine:<br/>RagService + SqliteVecStore
participant V as VoyageAI<br/>(embeddings)
participant L as LLM<br/>(Claude / etc.)
U->>FE: types "Summarise this PDF"<br/>(PDF already uploaded)
FE->>J: POST /api/v1/ai/orchestrate/stream<br/>multipart: fileInputs[], userMessage
Note over J: ByteHashFileIdStrategy<br/>id = sha256(bytes)[:16]
J->>O: POST /api/v1/orchestrator<br/>{ files:[{id,name}], userMessage }
O->>L: route via fast model
L-->>O: delegate_pdf_question
O->>QA: PdfQuestionRequest
loop for each file
QA->>RAG: has_collection(file.id)
RAG-->>QA: false
end
QA-->>O: NeedIngestResponse(files_to_ingest)
O-->>J: { outcome:"need_ingest", filesToIngest:[...] }
Note over J: onNeedIngest
loop per file
J->>J: PDFBox: extract page text
J->>O: POST /api/v1/rag/documents<br/>(long-running timeout)
O->>RAG: chunk + stage documents
O->>V: embed_documents (batches of 256)
V-->>O: embeddings
O->>RAG: add_documents
O-->>J: { chunks_indexed: N }
end
Note over J: retry with resumeWith=pdf_question
J->>O: POST /api/v1/orchestrator
Note over O: fast-path to PdfQuestionAgent
O->>QA: PdfQuestionRequest
Note over QA: build RagCapability<br/>pinned to file IDs
QA->>L: run(prompt) with search_knowledge tool
loop up to max_searches
L->>QA: search_knowledge(query)
QA->>V: embed_query
V-->>QA: query vector
QA->>RAG: search(vector, collections=[file.id])
RAG-->>QA: top-k chunks
QA-->>L: formatted chunks
end
Note over QA: once budget spent,<br/>prepare() hides the tool
L-->>QA: PdfQuestionAnswerResponse
QA-->>O: answer
O-->>J: { outcome:"answer", answer, evidence }
J-->>FE: SSE "result"
FE->>U: assistant bubble
```
138 lines
5.1 KiB
Python
138 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import psycopg
|
|
from pgvector.psycopg import register_vector_async
|
|
|
|
from stirling.rag.store import Document, SearchResult, VectorStore
|
|
|
|
|
|
class PgVectorStore(VectorStore):
|
|
"""PostgreSQL + pgvector backed store.
|
|
|
|
Connects to an external Postgres instance (DSN provided via config) and uses the
|
|
`vector` extension for similarity search. The schema is created on first use.
|
|
"""
|
|
|
|
def __init__(self, dsn: str) -> None:
|
|
if not dsn:
|
|
raise ValueError("pgvector backend requires a non-empty DSN (STIRLING_RAG_PGVECTOR_DSN)")
|
|
self._dsn = dsn
|
|
self._initialized = False
|
|
|
|
async def _connect(self) -> psycopg.AsyncConnection:
|
|
conn = await psycopg.AsyncConnection.connect(self._dsn)
|
|
await register_vector_async(conn)
|
|
return conn
|
|
|
|
async def _ensure_schema(self) -> None:
|
|
if self._initialized:
|
|
return
|
|
async with await self._connect() as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
|
await cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS rag_documents (
|
|
id TEXT NOT NULL,
|
|
collection TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
embedding vector NOT NULL,
|
|
PRIMARY KEY (id, collection)
|
|
)
|
|
"""
|
|
)
|
|
await cur.execute("CREATE INDEX IF NOT EXISTS idx_rag_collection ON rag_documents(collection)")
|
|
await conn.commit()
|
|
self._initialized = True
|
|
|
|
async def add_documents(
|
|
self,
|
|
collection: str,
|
|
documents: list[Document],
|
|
embeddings: list[list[float]],
|
|
) -> None:
|
|
if len(documents) != len(embeddings):
|
|
raise ValueError(f"Got {len(documents)} documents but {len(embeddings)} embeddings")
|
|
if not documents:
|
|
return
|
|
|
|
await self._ensure_schema()
|
|
async with await self._connect() as conn:
|
|
async with conn.cursor() as cur:
|
|
for doc, emb in zip(documents, embeddings):
|
|
await cur.execute(
|
|
"""
|
|
INSERT INTO rag_documents (id, collection, text, metadata, embedding)
|
|
VALUES (%s, %s, %s, %s::jsonb, %s)
|
|
ON CONFLICT (id, collection)
|
|
DO UPDATE SET
|
|
text = EXCLUDED.text,
|
|
metadata = EXCLUDED.metadata,
|
|
embedding = EXCLUDED.embedding
|
|
""",
|
|
(doc.id, collection, doc.text, json.dumps(doc.metadata), emb),
|
|
)
|
|
await conn.commit()
|
|
|
|
async def search(
|
|
self,
|
|
collection: str,
|
|
query_embedding: list[float],
|
|
top_k: int = 5,
|
|
) -> list[SearchResult]:
|
|
await self._ensure_schema()
|
|
async with await self._connect() as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute(
|
|
"""
|
|
SELECT id, text, metadata, 1 - (embedding <=> %s) AS score
|
|
FROM rag_documents
|
|
WHERE collection = %s
|
|
ORDER BY embedding <=> %s
|
|
LIMIT %s
|
|
""",
|
|
(query_embedding, collection, query_embedding, top_k),
|
|
)
|
|
rows = await cur.fetchall()
|
|
|
|
return [
|
|
SearchResult(
|
|
document=Document(id=r[0], text=r[1], metadata=r[2] or {}),
|
|
score=float(r[3]),
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
async def delete_collection(self, collection: str) -> None:
|
|
await self._ensure_schema()
|
|
async with await self._connect() as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute("DELETE FROM rag_documents WHERE collection = %s", (collection,))
|
|
await conn.commit()
|
|
|
|
async def list_collections(self) -> list[str]:
|
|
await self._ensure_schema()
|
|
async with await self._connect() as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute("SELECT DISTINCT collection FROM rag_documents ORDER BY collection")
|
|
rows = await cur.fetchall()
|
|
return [r[0] for r in rows]
|
|
|
|
async def has_collection(self, collection: str) -> bool:
|
|
await self._ensure_schema()
|
|
async with await self._connect() as conn:
|
|
async with conn.cursor() as cur:
|
|
await cur.execute(
|
|
"SELECT 1 FROM rag_documents WHERE collection = %s LIMIT 1",
|
|
(collection,),
|
|
)
|
|
row = await cur.fetchone()
|
|
return row is not None
|
|
|
|
async def close(self) -> None:
|
|
# Connections are opened and closed per call, so nothing persistent to release.
|
|
return None
|