-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathroutes.py
More file actions
225 lines (193 loc) · 7.3 KB
/
Copy pathroutes.py
File metadata and controls
225 lines (193 loc) · 7.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
from sqlalchemy import text as sql_text
from py_rag_engine.clients import LMStudioClient
from py_rag_engine.generation.lm_studio_chat import generate_answer
from py_rag_engine.ingestion.pipeline import ingest_file
from py_rag_engine.retrieval import (
CrossEncoderReranker,
retrieve_hybrid,
retrieve_hybrid_with_rerank,
retrieve_with_rerank,
)
from py_rag_engine.storage import EmbeddingInput, PostgresEmbeddingStore
from .schemas import (
DocumentInfo,
HealthResponse,
IngestResponse,
QueryRequest,
QueryResponse,
SourceResult,
)
router = APIRouter()
_ALLOWED_SUFFIXES = {".pdf", ".md", ".markdown"}
def _check_file_type(filename: str | None) -> str:
suffix = Path(filename or "").suffix.lower()
if suffix not in _ALLOWED_SUFFIXES:
raise HTTPException(
status_code=415,
detail=f"Unsupported file type '{suffix}'. Accepted: {sorted(_ALLOWED_SUFFIXES)}",
)
return suffix
@router.get("/health", response_model=HealthResponse, tags=["ops"])
def health(request: Request) -> HealthResponse:
"""Check connectivity to PostgreSQL and LM Studio."""
store: PostgresEmbeddingStore = request.app.state.store
client: LMStudioClient = request.app.state.lm_client
try:
with store.engine.connect() as conn:
conn.execute(sql_text("SELECT 1"))
pg_status = "ok"
except Exception:
pg_status = "unreachable"
try:
client.models()
lm_status = "ok"
except Exception:
lm_status = "unreachable"
overall = "ok" if pg_status == "ok" and lm_status == "ok" else "degraded"
return HealthResponse(status=overall, postgres=pg_status, lm_studio=lm_status)
@router.get("/documents", response_model=list[DocumentInfo], tags=["documents"])
def list_documents(request: Request) -> list[DocumentInfo]:
"""List all ingested sources with their chunk count."""
store: PostgresEmbeddingStore = request.app.state.store
stmt = sql_text("""
SELECT metadata->>'source' AS source, COUNT(*) AS chunks
FROM embeddings
WHERE embedding_model = :model
AND metadata ? 'source'
GROUP BY source
ORDER BY source
""")
with store.engine.connect() as conn:
rows = conn.execute(stmt, {"model": store.embedding_model}).mappings()
return [
DocumentInfo(source=Path(row["source"]).name, chunks=int(row["chunks"]))
for row in rows
]
@router.post("/documents", status_code=201, response_model=IngestResponse, tags=["documents"])
def ingest_document(
request: Request,
file: UploadFile = File(..., description="PDF, .md or .markdown file to ingest."),
chunk_size: int = Query(default=1200, ge=100, le=8000, description="Target characters per chunk."),
) -> IngestResponse:
"""Upload and ingest a PDF or Markdown document.
The file is chunked, embedded via LM Studio, and stored in PostgreSQL
with pgvector + full-text search indexes. Re-uploading the same content
is idempotent (deduplication by SHA-256 hash).
"""
suffix = _check_file_type(file.filename)
content: bytes = file.file.read()
if not content:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
store: PostgresEmbeddingStore = request.app.state.store
embed = request.app.state.embed
tmp_fd, tmp_name = tempfile.mkstemp(suffix=suffix)
try:
os.write(tmp_fd, content)
os.close(tmp_fd)
chunks = ingest_file(Path(tmp_name), chunk_size=chunk_size)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
finally:
try:
os.unlink(tmp_name)
except OSError:
pass
if not chunks:
return IngestResponse(source=file.filename or "unknown", chunks_ingested=0, chunk_ids=[])
vectors: list[list[float]] = embed([c.text for c in chunks])
inputs = [
EmbeddingInput(
text=chunk.text,
embedding=vec,
content_hash=chunk.content_hash,
metadata=chunk.metadata.to_dict(),
embedding_model=store.embedding_model,
)
for chunk, vec in zip(chunks, vectors)
]
ids = store.add_embeddings(inputs)
return IngestResponse(
source=file.filename or "unknown",
chunks_ingested=len(ids),
chunk_ids=ids,
)
@router.post("/query", response_model=QueryResponse, tags=["retrieval"])
def query_documents(request: Request, body: QueryRequest) -> QueryResponse:
"""Query the knowledge base with a natural-language question.
The pipeline:
1. Embed the question via LM Studio.
2. **Hybrid** (default): dense ANN (pgvector) + FTS (PostgreSQL tsvector), merged with RRF.
**Dense-only** (`use_hybrid=false`): ANN search only.
3. Cross-encoder re-ranking on the fused candidate pool.
4. Optionally generate a grounded answer with the LM Studio chat model.
"""
store: PostgresEmbeddingStore = request.app.state.store
embed = request.app.state.embed
reranker: CrossEncoderReranker = request.app.state.reranker
client: LMStudioClient = request.app.state.lm_client
try:
query_vec: list[float] = embed([body.question])[0]
except Exception as exc:
raise HTTPException(status_code=503, detail="Embedding service unavailable.") from exc
if body.use_hybrid and body.use_rerank:
results = retrieve_hybrid_with_rerank(
body.question,
query_vec,
store,
reranker,
top_k=body.top_k,
metadata_filter=body.metadata_filter,
)
retrieval_mode = "hybrid_rerank"
score_attr = "rerank_score"
elif body.use_hybrid:
results = retrieve_hybrid(
query_vec,
body.question,
store,
top_k=body.top_k,
metadata_filter=body.metadata_filter,
)
retrieval_mode = "hybrid"
score_attr = "rrf_score"
elif body.use_rerank:
results = retrieve_with_rerank(
body.question,
query_vec,
store,
reranker,
top_k=body.top_k,
metadata_filter=body.metadata_filter,
)
retrieval_mode = "dense_rerank"
score_attr = "rerank_score"
else:
results = store.similarity_search(
query_vec,
top_k=body.top_k,
metadata_filter=body.metadata_filter,
)
retrieval_mode = "dense"
score_attr = "cosine_similarity"
sources = [
SourceResult(
text=r.text,
source=Path(r.metadata.get("source", "")).name or r.metadata.get("source", ""),
page=r.metadata.get("page"),
chunk_index=r.metadata.get("chunk_index"),
score=round(float(getattr(r, score_attr)), 6),
)
for r in results
]
answer: str | None = None
if body.generate_answer and results:
try:
answer = generate_answer(client, body.question, [r.text for r in results])
except Exception:
answer = None
return QueryResponse(answer=answer, sources=sources, retrieval_mode=retrieval_mode)