diff --git a/.gitignore b/.gitignore index 15201ac..8a4730d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. #uv.lock +requirements.uv.txt # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. diff --git a/README.md b/README.md index a7fa4b9..343814f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ In order to run own copy of the project one must fulfill the following requireme - [Python 3.12](https://www.python.org/downloads/release/python-3120/) - [Git](https://git-scm.com/) +- [uv](https://github.com/astral-sh/uv) ### Virtual environments @@ -20,7 +21,8 @@ The following sequence of commands creates an environment, activates the environ ```bash python3 -m venv ~/path-to-venv; \ source ~/path-to-venv/bin/activate; \ - pip3 install -r ./requirements.txt + uv pip compile requirements.txt --output-file requirements.uv.txt; \ + uv pip sync ./requirements.uv.txt ``` ## Committing changes to the repo diff --git a/requirements.txt b/requirements.txt index a04acc6..74d4ab0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,15 @@ BeautifulSoup4 black pre-commit - +# RAG python-dotenv tiktoken langchain langchain_community -langchain_text_splitters langchain-ollama langchain-qdrant +qdrant-client scikit-learn - +# api fastapi uvicorn diff --git a/src/api.py b/src/api.py index 7fda411..32c03d8 100644 --- a/src/api.py +++ b/src/api.py @@ -1,15 +1,23 @@ +from contextlib import asynccontextmanager from fastapi import FastAPI import uvicorn -from app import rag_application +import app as rag_module -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + rag_module.initialize() + yield + + +app = FastAPI(lifespan=lifespan) @app.get("/") async def root(question: str | None = None): if question: - answer = rag_application.run(question) + answer = rag_module.rag_application.run(question) return {"question": question, "answer": answer} return { "message": "RAG application is online. Pass a `question` as a query parameter to this endpoint." diff --git a/src/app.py b/src/app.py index d04c02a..7807e18 100644 --- a/src/app.py +++ b/src/app.py @@ -1,7 +1,12 @@ import os from dotenv import load_dotenv +import subprocess +import sys + from langchain_qdrant import QdrantVectorStore +from qdrant_client import QdrantClient +from qdrant_client.http.exceptions import UnexpectedResponse from langchain_ollama import OllamaEmbeddings from langchain_ollama import ChatOllama @@ -10,33 +15,7 @@ load_dotenv() -embedding = OllamaEmbeddings(base_url="http://localhost:11434", model="llama3.2:latest") - -vector_store = QdrantVectorStore.from_existing_collection( - embedding=embedding, - collection_name="documents", - url=os.getenv("QDRANT_URL", "http://localhost:6333"), - api_key=os.getenv("QDRANT__SERVICE__API_KEY", None), -) -retriever = vector_store.as_retriever(k=4) - -prompt = PromptTemplate( - template="""You are an assistant for question-answering tasks. - Use the following documents to answer the question. - If you don't know the answer, just say that you don't know. - Use three sentences maximum and keep the answer concise: - Question: {question} - Documents: {documents} - Answer: - """, - input_variables=["question", "documents"], -) - -llm = ChatOllama( - base_url="http://localhost:11434", model="llama3.2:latest", temperature=0 -) - -rag_chain = prompt | llm | StrOutputParser() +rag_application = None class RAGApplication: @@ -51,4 +30,67 @@ def run(self, question): return answer -rag_application = RAGApplication(retriever, rag_chain) +def initialize(): + """The initialization function must be called once during the application startup.""" + + global rag_application + + qdrant_client = QdrantClient( + url=os.getenv("QDRANT_URL", "https://localhost:6333"), + api_key=os.getenv("QDRANT_API_KEY", None), + ) + + try: + qdrant_client.get_collection(collection_name="rag-app") + except Exception as e: + if isinstance(e, UnexpectedResponse) and getattr(e, "status_code", None) == 404: + print("[i] Qdrant collection `rag-app` not found. Creating embeddings...") + script_dir = os.path.dirname(os.path.abspath(__file__)) + result = subprocess.run( + [sys.executable, os.path.join(script_dir, "embeddings.py")], + capture_output=True, + text=True, + cwd=script_dir, + ) + print(result.stdout) + if result.returncode != 0: + print("[err] embeddings.py failed:") + print(result.stderr) + raise RuntimeError("embeddings.py failed during startup.") + print("[i] Collection `rag-app` created. Continuing with startup...") + else: + raise + + embedding = OllamaEmbeddings( + base_url="http://localhost:11434", model="embeddinggemma:latest" + ) + + vector_store = QdrantVectorStore( + client=qdrant_client, collection_name="rag-app", embedding=embedding + ) + retriever = vector_store.as_retriever(k=4) + + prompt = PromptTemplate( + template="""You are an assistant for question-answering tasks. + Use the following documents to answer the question. + If you don't know the answer, just say that you don't know. + Use three sentences maximum and keep the answer concise: + Question: {question} + Documents: {documents} + Answer: + """, + input_variables=["question", "documents"], + ) + + llm = ChatOllama( + base_url="http://localhost:11434", model="gemma3n:latest", temperature=0 + ) + + rag_chain = prompt | llm | StrOutputParser() + + rag_application = RAGApplication(retriever, rag_chain) + + question = "What operating systems does nx-ng-starter support?" + answer = rag_application.run(question) + print("Question:", question) + print("Answer:", answer) diff --git a/src/embeddings.py b/src/embeddings.py index 36bbfb3..c60d28e 100644 --- a/src/embeddings.py +++ b/src/embeddings.py @@ -18,12 +18,14 @@ doc_splits = text_splitter.split_documents(docs_list) -embedding = OllamaEmbeddings(base_url="http://localhost:11434", model="llama3.2:latest") +embedding = OllamaEmbeddings( + base_url="http://localhost:11434", model="embeddinggemma:latest" +) vector_store = QdrantVectorStore.from_documents( documents=doc_splits, embedding=embedding, - collection_name="documents", + collection_name="rag-app", url=os.getenv("QDRANT_URL", "http://localhost:6333"), - api_key=os.getenv("QDRANT__SERVICE__API_KEY", None), + api_key=os.getenv("QDRANT_API_KEY", None), ) diff --git a/src/init.py b/src/init.py deleted file mode 100644 index b29855f..0000000 --- a/src/init.py +++ /dev/null @@ -1,72 +0,0 @@ -import os -from dotenv import load_dotenv - -from langchain_community.document_loaders import WebBaseLoader -from langchain_text_splitters import RecursiveCharacterTextSplitter - -from langchain_qdrant import QdrantVectorStore -from langchain_ollama import OllamaEmbeddings - -from langchain_ollama import ChatOllama -from langchain_core.prompts import PromptTemplate -from langchain_core.output_parsers import StrOutputParser - -load_dotenv() - -urls = ["https://github.com/rfprod/nx-ng-starter/blob/main/README.md"] -docs = [WebBaseLoader(url).load() for url in urls] -docs_list = [item for sublist in docs for item in sublist] -text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( - chunk_size=250, chunk_overlap=0 -) - -doc_splits = text_splitter.split_documents(docs_list) - -embedding = OllamaEmbeddings(base_url="http://localhost:11434", model="llama3.2:latest") - -vector_store = QdrantVectorStore.from_documents( - documents=doc_splits, - embedding=embedding, - collection_name="documents", - url=os.getenv("QDRANT_URL", "http://localhost:6333"), - api_key=os.getenv("QDRANT__SERVICE__API_KEY", None), -) -retriever = vector_store.as_retriever(k=4) - -prompt = PromptTemplate( - template="""You are an assistant for question-answering tasks. - Use the following documents to answer the question. - If you don't know the answer, just say that you don't know. - Use three sentences maximum and keep the answer concise: - Question: {question} - Documents: {documents} - Answer: - """, - input_variables=["question", "documents"], -) - -llm = ChatOllama( - base_url="http://localhost:11434", model="llama3.2:latest", temperature=0 -) - -rag_chain = prompt | llm | StrOutputParser() - - -class RAGApplication: - def __init__(self, retriever, rag_chain): - self.retriever = retriever - self.rag_chain = rag_chain - - def run(self, question): - documents = self.retriever.invoke(question) - doc_texts = "\\n".join([doc.page_content for doc in documents]) - answer = self.rag_chain.invoke({"question": question, "documents": doc_texts}) - return answer - - -rag_application = RAGApplication(retriever, rag_chain) - -question = "What operating systems does nx-ng-starter support?" -answer = rag_application.run(question) -print("Question:", question) -print("Answer:", answer) diff --git a/src/start.sh b/src/start.sh new file mode 100644 index 0000000..50e1982 --- /dev/null +++ b/src/start.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Install Qdrant vector database +# https://qdrant.tech/documentation/quick-start/ + +docker pull qdrant/qdrant + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +if docker ps | grep -q qdrant; then + echo "Qdrant is already running. Skipping docker run." +else + echo "Starting Qdrant container..." + + if [ -f .env ]; then + # shellcheck source=/dev/null + source .env + else + echo "Warning: .env file not found." + fi + + if [ -z "$QDRANT_API_KEY" ]; then + echo "Error: QDRANT_API_KEY not found in .env and is not set globally" + exit 1 + fi + + if [ -z "$QDRANT_API_KEY_READ_ONLY" ]; then + echo "Error: QDRANT_API_KEY_READ_ONLY not found in .env and is not set globally" + exit 1 + fi + + DATA_DIR="$HOME/qdrant" + mkdir -p "$DATA_DIR" + + # Run Qdrant container + # This starts Qdrant on port 6333 with persistent storage in /var/lib/qdrant/storage + # API key authentication documentation: https://github.com/hdt12a1/qdrant-tutorial/blob/main/api_key_authentication.md + docker run -d \ + --name qdrant-rag-app \ + --restart always \ + -p 6333:6333 \ + -p 6334:6334 \ + -v "$DATA_DIR:/qdrant/storage" \ + -e QDRANT_API_KEY="$QDRANT_API_KEY" \ + -e QDRANT_API_KEY_READ_ONLY="$QDRANT_API_KEY_READ_ONLY" \ + qdrant/qdrant:latest +fi + +cleanup() { + echo -e "${YELLOW}[i]${NC} Qdrant docker container cleanup..." + docker stop qdrant-rag-app >/dev/null 2>&1 || true + docker rm qdrant-rag-app >/dev/null 2>&1 || true + + echo -e "${GREEN}[ok]{$NC} Done." +} +trap cleanup EXIT + +echo -e "${YELLOW}[i]${NC} Qdrant is running with data dir $DATA_DIR. Waiting for Qdrant..." + +MAX_WAIT_SEC=60 +SLEEP_SEC=1 +ELAPSED=0 +STARTUP_STATUS="" + +while [ "$ELAPSED" -lt "$MAX_WAIT_SEC" ]; do + STARTUP_STATUS="$(curl -s -o /dev/null -w "%{http_code}" -H "api-key: $QDRANT_API_KEY" http://localhost:6333/collections || echo "curl_error")" + if [ "$STARTUP_STATUS" = "200" ]; then + break + fi + sleep "$SLEEP_SEC" + ELAPSED=$((ELAPSED + SLEEP_SEC)) +done +if [ "$STARTUP_STATUS" != "200" ]; then + echo -e "${RED}[err]{$NC} Qdrant startup timeout. Last HTTP status: $STARTUP_STATUS" + echo -e "${YELLOW}[i]${NC} Qdrant container logs:" + docker logs --tail 50 qdrant-rag-app || echo -e "${RED}[warn]${NC} Could not fetch qdrant-rag-app Docker logs." + exit 1 +fi + +echo "Qdrant status:" +docker ps | grep qdrant + +echo "Qdrant version check:" +curl -H "api-key: $QDRANT_API_KEY_READ_ONLY" http://localhost:6333 | grep version + +cd "$(dirname "$0")" || exit 1 + +uvicorn api:app --reload diff --git a/tools/qdrant/start.sh b/tools/qdrant/start.sh deleted file mode 100644 index 91ed31d..0000000 --- a/tools/qdrant/start.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash - -# Install Qdrant vector database -# https://qdrant.tech/documentation/quick-start/ - -docker pull qdrant/qdrant - -sudo mkdir -p /var/lib/qdrant/storage - -if docker ps | grep -q qdrant; then - echo "Qdrant is already running. Skipping docker run." -else - echo "Starting Qdrant container..." - - if [ -f .env ]; then - # shellcheck source=/dev/null - source .env - else - echo "Warning: .env file not found." - fi - - if [ -z "$QDRANT__SERVICE__API_KEY" ]; then - echo "Error: QDRANT__SERVICE__API_KEY not found in .env and is not set globally" - exit 1 - fi - - if [ -z "$QDRANT__SERVICE__READ_ONLY_API_KEY" ]; then - echo "Error: QDRANT__SERVICE__READ_ONLY_API_KEY not found in .env and is not set globally" - exit 1 - fi - - # Run Qdrant container - # This starts Qdrant on port 6333 with persistent storage in /var/lib/qdrant/storage - # API key authentication documentation: https://github.com/hdt12a1/qdrant-tutorial/blob/main/api_key_authentication.md - sudo docker run -d \ - --name qdrant \ - --restart always \ - -p 6333:6333 \ - -p 6334:6334 \ - -v /var/lib/qdrant/storage:/qdrant/storage:z \ - -e QDRANT__SERVICE__API_KEY="$QDRANT__SERVICE__API_KEY" \ - -e QDRANT__SERVICE__READ_ONLY_API_KEY="$QDRANT__SERVICE__READ_ONLY_API_KEY" \ - qdrant/qdrant:latest -fi - -echo "Qdrant status:" -docker ps | grep qdrant - -echo "Qdrant version check:" -curl -H "api-key: $QDRANT__SERVICE__READ_ONLY_API_KEY" http://localhost:6333 | grep version