-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
272 lines (238 loc) · 10.6 KB
/
Copy pathapp.py
File metadata and controls
272 lines (238 loc) · 10.6 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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
import uuid
import os
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
import chromadb
from ollama import Client
app = FastAPI(
title="Nextwork RAG API",
description="A RAG (Retrieval-Augmented Generation) API using ChromaDB and Ollama",
version="1.0.0"
)
# Configuration from environment variables
CHROMA_DB_PATH = os.getenv("CHROMA_DB_PATH", "./db")
CHROMA_COLLECTION_NAME = os.getenv("CHROMA_COLLECTION_NAME", "docs")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "tinyllama")
OLLAMA_HOST_RAW = os.getenv("OLLAMA_HOST", "localhost:11434")
# Initialize ChromaDB client and collection
try:
chroma = chromadb.PersistentClient(path=CHROMA_DB_PATH)
collection = chroma.get_or_create_collection(CHROMA_COLLECTION_NAME)
except Exception as e:
raise RuntimeError(
f"Failed to initialize ChromaDB at path '{CHROMA_DB_PATH}': {str(e)}. "
f"Ensure the directory exists and is writable."
)
# Initialize Ollama client
# Parse OLLAMA_HOST - client expects hostname:port format, not URL
ollama_host = OLLAMA_HOST_RAW.replace("http://", "").replace("https://", "")
try:
ollama_client = Client(host=ollama_host)
# Test connection by checking available models
models = ollama_client.list()
model_names = [m.name for m in models.models] if hasattr(models, 'models') else []
if OLLAMA_MODEL not in model_names:
print(f"Warning: Model '{OLLAMA_MODEL}' not found in Ollama. Available models: {model_names}")
except Exception as e:
print(f"Warning: Could not connect to Ollama at {ollama_host}: {str(e)}")
print("The API will start but queries may fail. Ensure Ollama is running and accessible.")
class QueryRequest(BaseModel):
q: str
n_results: int = 1 # Number of results to return (default: 1, max: 10)
include_scores: bool = False # Whether to include relevance scores
use_best_only: bool = True # If True, only use best result for AI answer; if False, combine all results
class AddRequest(BaseModel):
text: str
@app.get("/")
def root():
"""Health check endpoint."""
return {"status": "ok", "message": "Nextwork RAG API is running"}
@app.post("/add", status_code=status.HTTP_201_CREATED)
def add_knowledge(request: AddRequest):
"""Add new content to the knowledge base dynamically."""
if not request.text or not request.text.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Text cannot be empty. Please provide non-empty text content."
)
try:
# Generate a unique ID for this document
doc_id = str(uuid.uuid4())
# Add the text to Chroma collection
collection.add(documents=[request.text], ids=[doc_id])
return {
"status": "success",
"message": "Content added to knowledge base",
"id": doc_id
}
except chromadb.errors.InvalidDimensionException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid document format: {str(e)}. This may occur if the collection has existing documents with different embedding dimensions."
)
except Exception as e:
error_msg = str(e)
if "connection" in error_msg.lower() or "network" in error_msg.lower():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Database connection error: {error_msg}. Check if ChromaDB is accessible and the database path '{CHROMA_DB_PATH}' is correct."
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add content: {error_msg}"
)
@app.post("/query")
def query(request: QueryRequest):
"""
Query the knowledge base and get an AI-generated answer.
Supports multiple results and relevance scores for better context retrieval.
"""
if not request.q or not request.q.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Query cannot be empty. Please provide a question to search the knowledge base."
)
# Validate n_results
if request.n_results < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="n_results must be at least 1"
)
if request.n_results > 10:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="n_results cannot exceed 10 for performance reasons"
)
try:
# Query ChromaDB for relevant context
results = collection.query(
query_texts=[request.q],
n_results=request.n_results,
include=["documents", "distances", "metadatas"]
)
# Extract results
documents = results.get("documents", [])
distances = results.get("distances", [])
metadatas = results.get("metadatas", [])
ids = results.get("ids", [])
if not documents or len(documents) == 0 or len(documents[0]) == 0:
doc_count = collection.count()
if doc_count == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No documents found in knowledge base. Add content using the /add endpoint first."
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No relevant context found for your query. The knowledge base has {doc_count} document(s), but none match your question. Try rephrasing your query or adding more relevant content."
)
# Prepare results with metadata
search_results = []
for i in range(len(documents[0])):
result_item = {
"id": ids[0][i] if ids and len(ids) > 0 and len(ids[0]) > i else None,
"text": documents[0][i],
}
if request.include_scores and distances and len(distances) > 0 and len(distances[0]) > i:
# ChromaDB returns distances (lower is better), convert to similarity score
distance = distances[0][i]
similarity = 1.0 / (1.0 + distance) # Convert distance to similarity (0-1 scale)
result_item["relevance_score"] = round(similarity, 4)
result_item["distance"] = round(distance, 4)
if metadatas and len(metadatas) > 0 and len(metadatas[0]) > i:
result_item["metadata"] = metadatas[0][i]
search_results.append(result_item)
# Prepare context for AI generation
if request.use_best_only:
# Use only the best (first) result
context = search_results[0]["text"]
else:
# Combine all results
context = "\n\n".join([f"[Result {i+1}]: {r['text']}" for i, r in enumerate(search_results)])
# Generate answer using Ollama
try:
answer = ollama_client.generate(
model=OLLAMA_MODEL,
prompt=f"Context:\n{context}\n\nQuestion: {request.q}\n\nAnswer clearly and concisely:"
)
# Build response
response = {
"answer": answer.response,
"results_count": len(search_results)
}
if request.include_scores or not request.use_best_only:
response["results"] = search_results
return response
except Exception as ollama_error:
error_msg = str(ollama_error)
if "connection" in error_msg.lower() or "refused" in error_msg.lower():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Cannot connect to Ollama at {ollama_host}. Ensure Ollama is running and accessible. Error: {error_msg}"
)
elif "model" in error_msg.lower() and "not found" in error_msg.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Model '{OLLAMA_MODEL}' not found in Ollama. Install it with: ollama pull {OLLAMA_MODEL}"
)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Ollama generation failed: {error_msg}"
)
except HTTPException:
raise
except Exception as e:
error_msg = str(e)
if "connection" in error_msg.lower() or "network" in error_msg.lower():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Database connection error: {error_msg}. Check if ChromaDB is accessible."
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to process query: {error_msg}"
)
@app.delete("/delete/{doc_id}", status_code=status.HTTP_200_OK)
def delete_document(doc_id: str):
"""Delete a document from the knowledge base by ID."""
if not doc_id or not doc_id.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Document ID cannot be empty"
)
try:
# Check if document exists
try:
results = collection.get(ids=[doc_id])
if not results["ids"] or len(results["ids"]) == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Document with ID '{doc_id}' not found in the knowledge base."
)
except HTTPException:
raise
except Exception as e:
# If get fails, try delete anyway (idempotent operation)
pass
# Delete the document
collection.delete(ids=[doc_id])
return {
"status": "success",
"message": f"Document '{doc_id}' deleted successfully",
"id": doc_id
}
except HTTPException:
raise
except Exception as e:
error_msg = str(e)
if "connection" in error_msg.lower() or "network" in error_msg.lower():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Database connection error: {error_msg}. Check if ChromaDB is accessible."
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete document: {error_msg}"
)