import os import sqlite3 import numpy as np import requests from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import List, Optional # --- CONFIGURATION --- OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/embed") OLLAMA_GENERATE_URL = os.environ.get("OLLAMA_GENERATE_URL", "http://localhost:11434/api/generate") EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "qwen3-embedding:0.6b") DB_PATH = os.environ.get("DB_PATH", "/root/work/knowledge-base/knowledge_base.db") app = FastAPI( title="Juniper Technical Document RAG Engine", description="REST API for querying the technical documentation index and generating RAG answers.", version="1.0.0" ) # Enable CORS for frontend integration app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- MODELS --- class SearchRequest(BaseModel): query: str top_k: int = 5 source: Optional[str] = None # "kb" or "book" class SearchResultItem(BaseModel): id: int source_type: str source_name: str title: str url: Optional[str] = None page_num: Optional[int] = None text: str score: float class GenerateRequest(BaseModel): query: str top_k: int = 5 source: Optional[str] = None llm_model: str = "qwen3-embedding:0.6b" # Default fallback, can be overridden by user to e.g. "qwen2.5" class GenerateResponse(BaseModel): query: str answer: str sources: List[SearchResultItem] # --- UTILITIES --- def get_query_embedding(query_text: str) -> List[float]: """Fetches query embedding from Ollama.""" try: response = requests.post(OLLAMA_URL, json={ "model": EMBEDDING_MODEL, "input": query_text }, timeout=30) response.raise_for_status() data = response.json() return data.get("embeddings", [])[0] except Exception as e: raise HTTPException( status_code=503, detail=f"Error getting query embedding from Ollama: {str(e)}. Please ensure Ollama is running and has the embedding model '{EMBEDDING_MODEL}' pulled." ) def load_table_data(cursor, table_name: str, source_type: str): """Loads chunks and embeddings from database.""" if table_name == "knowledge_base": cursor.execute("SELECT id, kb_id, salesforce_id, title, url, prefix_content, embedding FROM knowledge_base") rows = cursor.fetchall() data = [] embeddings = [] for row in rows: doc_id, kb_id, salesforce_id, title, url, prefix_content, emb_bytes = row emb = np.frombuffer(emb_bytes, dtype=np.float32) if len(emb) == 1024: embeddings.append(emb) data.append({ "id": doc_id, "source_type": source_type, "source_name": salesforce_id, "title": f"{kb_id} : {title}" if not title.startswith(kb_id) else title, "url": url, "page_num": None, "text": prefix_content }) return data, embeddings else: cursor.execute(f"SELECT id, file_name, page_num, prefix_content, embedding FROM {table_name}") rows = cursor.fetchall() data = [] embeddings = [] for row in rows: doc_id, file_name, page_num, prefix_content, emb_bytes = row emb = np.frombuffer(emb_bytes, dtype=np.float32) if len(emb) == 1024: embeddings.append(emb) data.append({ "id": doc_id, "source_type": source_type, "source_name": file_name, "title": file_name, "url": None, "page_num": page_num, "text": prefix_content }) return data, embeddings def get_full_content(item: dict) -> str: """Retrieves full content of the document page or KB article.""" if item['source_type'] == 'kb': kb_id = item['source_name'] file_path = f"/root/work/knowledge-base/juniper-kb/kb_markdown/{kb_id}.md" if os.path.exists(file_path): try: with open(file_path, 'r', encoding='utf-8') as f: return f.read() except Exception: pass # Fallback to combining chunks from SQLite conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT text_content FROM knowledge_base WHERE kb_id = ? ORDER BY chunk_index", (kb_id,)) chunks = [row[0] for row in c.fetchall()] conn.close() return "\n\n".join(chunks) else: file_name = item['source_name'] page_num = item['page_num'] table_name = "network_book" conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(f"SELECT text_content FROM {table_name} WHERE file_name = ? AND page_num = ? ORDER BY chunk_index", (file_name, page_num)) chunks = [row[0] for row in c.fetchall()] conn.close() return "\n\n".join(chunks) def run_retrieval(query: str, top_k: int, source: Optional[str]) -> List[SearchResultItem]: """Helper function to perform vector similarity search.""" if not os.path.exists(DB_PATH): raise HTTPException(status_code=500, detail=f"Database file not found at {DB_PATH}. Please run indexing first.") query_emb = np.array(get_query_embedding(query), dtype=np.float32) conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() all_data = [] all_embeddings = [] tables_to_load = [ ("knowledge_base", "kb"), ("network_book", "book") ] if source: tables_to_load = [t for t in tables_to_load if t[1] == source] for table_name, source_type in tables_to_load: try: data, embs = load_table_data(cursor, table_name, source_type) all_data.extend(data) all_embeddings.extend(embs) except sqlite3.OperationalError: continue conn.close() if not all_embeddings: return [] embs_matrix = np.array(all_embeddings, dtype=np.float32) q_norm = np.linalg.norm(query_emb) m_norms = np.linalg.norm(embs_matrix, axis=1) m_norms[m_norms == 0] = 1e-10 if q_norm == 0: q_norm = 1e-10 similarities = np.dot(embs_matrix, query_emb) / (m_norms * q_norm) top_indices = np.argsort(similarities)[::-1][:top_k] results = [] for idx in top_indices: item = all_data[idx] score = float(similarities[idx]) results.append(SearchResultItem( id=item["id"], source_type=item["source_type"], source_name=item["source_name"], title=item["title"], url=item["url"], page_num=item["page_num"], text=item["text"], score=score )) return results # --- ENDPOINTS --- @app.get("/") @app.get("/health") def health_check(): """Provides index statistics and service health status.""" db_exists = os.path.exists(DB_PATH) kb_count = 0 book_count = 0 if db_exists: try: conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT COUNT(*) FROM knowledge_base") kb_count = c.fetchone()[0] c.execute("SELECT COUNT(*) FROM network_book") book_count = c.fetchone()[0] conn.close() except sqlite3.OperationalError: pass return { "status": "healthy", "database_connected": db_exists, "database_path": DB_PATH, "stats": { "juniper_kb_chunks": kb_count, "network_book_chunks": book_count, "total_chunks": kb_count + book_count }, "model_config": { "embedding_model": EMBEDDING_MODEL, "ollama_url": OLLAMA_URL } } @app.post("/search", response_model=List[SearchResultItem]) def search_documents(req: SearchRequest): """Searches indexed documents for matching chunks using similarity search.""" return run_retrieval(req.query, req.top_k, req.source) @app.post("/generate", response_model=GenerateResponse) def generate_rag_answer(req: GenerateRequest): """Retrieves context chunks and prompts Ollama LLM to synthesize a detailed answer.""" # 1. Retrieve most relevant context sources = run_retrieval(req.query, req.top_k, req.source) if not sources: return GenerateResponse( query=req.query, answer="No relevant context documents found in the database. Please verify your query or index.", sources=[] ) # 2. Build context block context_blocks = [] for idx, s in enumerate(sources, 1): context_blocks.append(f"[{idx}] Source: {s.title} ({s.source_type})\nContent: {s.text}") context_str = "\n\n".join(context_blocks) # 3. Build detailed prompt for the LLM prompt = ( "You are an expert network engineering AI assistant. Answer the user's question based strictly on the provided technical documents.\n" "If you do not know the answer or if it's not present in the context, state that you don't know based on the documents.\n\n" "=== TECHNICAL DOCUMENTS CONTEXT ===\n" f"{context_str}\n" "====================================\n\n" f"Question: {req.query}\n\n" "Answer (be structured, clear, and reference your sources like [1], [2] when appropriate):" ) # 4. Request generation from Ollama try: response = requests.post(OLLAMA_GENERATE_URL, json={ "model": req.llm_model, "prompt": prompt, "stream": False }, timeout=90) response.raise_for_status() answer = response.json().get("response", "") except Exception as e: answer = ( f"[RAG Context Retrieval Successful, but LLM Generation failed]\n" f"Error communicating with Ollama generation model '{req.llm_model}': {str(e)}.\n" f"Please verify Ollama has the model pulled or specify a running model name in your request." ) return GenerateResponse( query=req.query, answer=answer, sources=sources ) if __name__ == "__main__": import uvicorn uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)