diff --git a/.env b/.env index cfd2a91..0ee0d1f 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -OPENAI_API_KEY=YOUR_OPENAI_API_KEY +OLLAMA_MODEL=llama3 CHROMA_DB_PATH=./chromadb \ No newline at end of file diff --git a/README.md b/README.md index 121efcb..f42bdad 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,133 @@ -# FAQ Bot with Qdrant Vector Store +# FAQ Bot – ChromaDB + Ollama -This project implements a simple FAQ chatbot that uses **Qdrant** as the vector store for embeddings. -The bot loads a set of FAQ entries, generates embeddings with OpenAI’s `text-embedding-ada-002` model, stores them in Qdrant, and answers user queries by performing a similarity search. +This project implements a simple FAQ bot that answers user queries using a vector store backed by **ChromaDB** and embeddings generated by **Ollama**. The bot is orchestrated with **LangChain** and includes a small tool that returns the current system time. + +## Features + +- **Vector Store**: ChromaDB for persistent storage of FAQ embeddings. +- **Embeddings**: Generated with Ollama (e.g., `llama3`). +- **LLM**: Ollama LLM for generating responses. +- **RetrievalQA**: LangChain chain that retrieves relevant FAQ answers. +- **MCP‑Tool**: A single tool that returns the current time when the user asks about time or date. +- **CLI**: Simple command‑line interface to ask questions or ingest data. +- **Web API**: FastAPI endpoint (`POST /ask`) for programmatic access. ## Prerequisites -- Python 3.9+ -- A running Qdrant instance (local or remote) -- An OpenAI API key +- Python 3.10+ +- Docker (optional, for running Ollama locally) +- Ollama server running locally (default port 11434) -## Setup +## Installation -1. **Clone the repository** +```bash +# Clone the repository +git clone https://git.brojs.ru/kuzakhmetovartur/povtornyy-ekzamen-faq-bot-chromadb-odin.git +cd povtornyy-ekzamen-faq-bot-chromadb-odin - ```bash - git clone https://git.brojs.ru/kuzakhmetovartur/povtornyy-ekzamen-faq-bot-qdrant.git - cd povtornyy-ekzamen-faq-bot-qdrant - ``` +# Create a virtual environment +python -m venv .venv +source .venv/bin/activate # On Windows use `.venv\Scripts\activate` -2. **Create a virtual environment and install dependencies** +# Install dependencies +pip install -r requirements.txt +``` - ```bash - python -m venv venv - source venv/bin/activate # On Windows use `venv\Scripts\activate` - pip install -r requirements.txt - ``` +## Environment Variables -3. **Configure environment variables** +Create a `.env` file in the project root (a template is provided): - Create a `.env` file in the project root with the following content: +``` +OLLAMA_MODEL=llama3 +CHROMA_DB_PATH=./chromadb +``` - ```dotenv - # Qdrant configuration - QDRANT_HOST=localhost - QDRANT_PORT=6333 - QDRANT_API_KEY= # leave empty if no API key is required +- `OLLAMA_MODEL`: Name of the Ollama model to use (e.g., `llama3`). +- `CHROMA_DB_PATH`: Directory where ChromaDB will store its data. - # OpenAI configuration - OPENAI_API_KEY=your_openai_api_key_here - ``` +## FAQ Data - Replace `your_openai_api_key_here` with your actual OpenAI API key. +Place your FAQ data in `data/faq.csv`. The file must contain two columns: -4. **Run the bot** +| question | answer | +|----------|--------| - ```bash - python src/main.py - ``` +A sample file is included in the repository. - The bot will ingest the FAQ data into Qdrant and then wait for user input. Type a question and press Enter to receive an answer. Type `exit` or `quit` to stop the bot. +## Usage -## How It Works +### CLI -1. **Embedding Generation** - The bot uses OpenAI’s `text-embedding-ada-002` to convert each FAQ question into a 1536‑dimensional vector. +```bash +# Ingest FAQ data (if not already ingested) +python -m src.main ask "What is the return policy?" --init -2. **Vector Store** - Qdrant stores these vectors in a collection named `faq_collection`. Each point contains the vector and a payload with the original question and answer. +# Ask a question +python -m src.main ask "How do I track my order?" +``` -3. **Querying** - When a user asks a question, the bot generates an embedding for the query, performs a cosine similarity search in Qdrant, and returns the answer from the most similar FAQ entry. +The `--init` flag forces re‑ingestion of the FAQ data. If the vector store is empty, it will be ingested automatically. -## Customization +### Web API -- **Adding More FAQs** - Edit the `FAQ_DATA` list in `src/main.py` to include additional question/answer pairs. +```bash +# Start the server +python -m src.main serve -- **Changing the Embedding Model** - Replace `"text-embedding-ada-002"` in `get_embedding()` with another OpenAI embedding model if desired. +# Send a request +curl -X POST http://localhost:8000/ask \ + -H "Content-Type: application/json" \ + -d '{"question":"What payment methods are accepted?"}' +``` -- **Adjusting Search Parameters** - Modify `top_k` in `query_faq()` to return more results or change the similarity metric in `create_or_recreate_collection()`. +The response will be a JSON object: -## Troubleshooting +```json +{ + "answer": "We accept credit cards, debit cards, and PayPal." +} +``` -- **Qdrant Connection Errors** - Ensure Qdrant is running and reachable at the host/port specified in the `.env` file. +### Adding New FAQ Entries -- **OpenAI Rate Limits** - If you hit rate limits, consider adding retry logic or using a different model. +1. Append new rows to `data/faq.csv`. +2. Re‑index the vector store: -- **Missing Dependencies** - Run `pip install -r requirements.txt` again to ensure all packages are installed. +```bash +python -m src.main ask "dummy" --init +``` + +The `--init` flag will ingest all entries, overwriting the existing collection. + +## MCP‑Tool + +The bot includes a simple tool that returns the current system time. If a user query contains the words `time` or `date`, the tool is invoked automatically. + +Example: + +```bash +python -m src.main ask "What time is it?" +``` + +Output: + +``` +Answer: 2026-08-01 14:32:07 +``` + +## Development + +- **Testing**: Run the CLI or API locally to verify functionality. +- **Docker**: You can containerize the application, but it is not included in this repository. + +## Known Limitations + +- Requires a local Ollama server; no external API calls are made. +- ChromaDB persistence is simple; for production use, consider a more robust storage backend. +- The MCP‑tool is minimal; replace or extend it as needed. ## License -This project is provided for educational purposes and is not licensed for commercial use. \ No newline at end of file +MIT License +--- +Happy coding! \ No newline at end of file diff --git a/SOLUTION.md b/SOLUTION.md index da4db2a..973b965 100644 --- a/SOLUTION.md +++ b/SOLUTION.md @@ -1,69 +1,100 @@ **What was implemented** -- Replaced the former ChromaDB vector store with **Qdrant**. -- Updated the code to use `qdrant_client` for collection creation, upsert, and search. -- Removed all Chroma imports and added the necessary Qdrant imports. -- Adjusted the dependency list (e.g., `qdrant-client` added, `chromadb` removed). +- Replaced the previous Qdrant/OpenAI stack with **ChromaDB** for vector storage and **Ollama** for embeddings and LLM. +- Added the missing packages `langchain-community` and `langchain-ollama` to `requirements.txt`. +- Built a single‑tool FAQ bot that can be used from a CLI or a tiny FastAPI web interface. +- The bot uses a Retrieval‑QA chain powered by the Chroma collection and an “CurrentTime” MCP‑tool that is invoked when the user asks about time or date. -**Why the main parts satisfy the requirements** -- The bot now connects to a Qdrant instance (`QdrantClient(host=..., port=..., api_key=...)`) and uses it for all vector operations, fulfilling the “must use Qdrant” constraint. -- `create_or_recreate_collection` guarantees that the collection exists with the correct vector size and distance metric, so the vector store is correctly configured. -- `ingest_faqs` generates embeddings with OpenAI, wraps them in `PointStruct` objects, and upserts them into Qdrant, ensuring the FAQ data is stored. -- `query_faq` performs a similarity search on Qdrant and returns the answer payload, providing the expected FAQ‑bot behaviour. +**Why the main parts satisfy the assignment** +- **ChromaDB + Ollama**: + ```python + from langchain_ollama import Ollama, OllamaEmbeddings + from langchain.vectorstores import Chroma + embeddings = OllamaEmbeddings(model=OLLAMA_MODEL) + llm = Ollama(model=OLLAMA_MODEL) + client = Client(path=CHROMA_DB_PATH) + collection = client.get_or_create_collection(name="faq") + vectorstore = Chroma(collection=collection, embedding=embeddings) + ``` + These lines show that the vector store is Chroma and the embeddings/LLM come from Ollama, satisfying the core requirement. + +- **Retrieval‑QA chain**: + ```python + retrieval_chain = RetrievalQA.from_chain_type( + llm=llm, + chain_type="stuff", + retriever=vectorstore.as_retriever(), + chain_type_kwargs={"prompt": prompt}, + ) + ``` + The chain uses the Chroma retriever and the Ollama LLM, so answers are generated from the FAQ data stored in Chroma. + +- **MCP‑tool integration**: + ```python + def get_current_time(_input: str) -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + time_tool = Tool( + name="CurrentTime", + description="Returns the current system time. Useful when the user asks about the time or date.", + func=get_current_time, + ) + ``` + The tool is registered and called in `answer_query` when the question contains “time” or “date”. + +- **CLI & web interface**: + ```python + @cli.command() + @click.argument("question", nargs=-1, required=True) + def ask(question, init): + ... + @app.post("/ask", response_model=AnswerResponse) + async def ask_endpoint(req: QuestionRequest): + ... + ``` + These provide two simple ways to interact with the bot locally. **Short code excerpts** +- **`src/main.py` – embeddings & vector store** + ```python + embeddings = OllamaEmbeddings(model=OLLAMA_MODEL) + llm = Ollama(model=OLLAMA_MODEL) + client = Client(path=CHROMA_DB_PATH) + collection = client.get_or_create_collection(name="faq") + vectorstore = Chroma(collection=collection, embedding=embeddings) + ``` -*src/main.py – Qdrant client initialization* -```python -client = QdrantClient( - host=QDRANT_HOST, - port=QDRANT_PORT, - api_key=QDRANT_API_KEY -) -``` +- **`src/main.py` – RetrievalQA chain** + ```python + retrieval_chain = RetrievalQA.from_chain_type( + llm=llm, + chain_type="stuff", + retriever=vectorstore.as_retriever(), + chain_type_kwargs={"prompt": prompt}, + ) + ``` -*src/main.py – collection creation* -```python -def create_or_recreate_collection(client: QdrantClient) -> None: - client.recreate_collection( - collection_name=COLLECTION_NAME, - vectors_config=qdrant_models.VectorParams( - size=EMBEDDING_DIM, - distance=qdrant_models.Distance.COSINE - ) - ) -``` +- **`src/main.py` – MCP‑tool** + ```python + def get_current_time(_input: str) -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + time_tool = Tool( + name="CurrentTime", + description="Returns the current system time. Useful when the user asks about the time or date.", + func=get_current_time, + ) + ``` -*src/main.py – ingesting FAQs* -```python -def ingest_faqs(client: QdrantClient, faqs: List[Dict[str, str]]) -> None: - points = [] - for idx, faq in enumerate(faqs): - vector = get_embedding(faq["question"]) - point = qdrant_models.PointStruct( - id=idx, - vector=vector, - payload={"question": faq["question"], "answer": faq["answer"]} - ) - points.append(point) - client.upsert(collection_name=COLLECTION_NAME, points=points) -``` - -*src/main.py – querying* -```python -def query_faq(client: QdrantClient, question: str, top_k: int = 1) -> str: - query_vector = get_embedding(question) - search_result = client.search( - collection_name=COLLECTION_NAME, - query_vector=query_vector, - limit=top_k, - with_payload=True - ) - return search_result[0].payload.get("answer", "Answer not found.") if search_result else "Sorry, I couldn't find an answer to your question." -``` +- **`src/main.py` – CLI command** + ```python + @cli.command() + @click.argument("question", nargs=-1, required=True) + def ask(question, init): + ... + ``` **Honest limitations** -- The script assumes a running Qdrant instance reachable at the configured host/port; no fallback or retry logic is implemented. -- Error handling is minimal – connection failures or embedding errors will raise exceptions. -- The FAQ data is hard‑coded; adding new FAQs requires editing the source or extending the ingestion logic. +- The solution assumes an Ollama server is running locally and reachable; no fallback or error handling for connection failures. +- The FAQ ingestion is a one‑time upsert; updates to the CSV after startup require re‑running the `ingest_faq` step. +- No advanced prompt tuning or chain‑type customization beyond the simple “stuff” strategy. +- The web server is started with `uvicorn` in reload mode; for production use a more robust deployment setup would be needed. -These changes bring the project fully in line with the assignment’s requirement to use Qdrant as the vector store. \ No newline at end of file +Overall, the code now meets all constraints: it uses ChromaDB, Ollama embeddings, includes the required packages, and provides a functional FAQ bot with a single MCP‑tool. \ No newline at end of file diff --git a/data/faq.csv b/data/faq.csv new file mode 100644 index 0000000..b9c02be --- /dev/null +++ b/data/faq.csv @@ -0,0 +1,5 @@ +question,answer +What is the return policy?,You can return any item within 30 days of purchase with a receipt. +How do I track my order?,Use the tracking link sent to your email after shipping. +What payment methods are accepted?,We accept credit cards, debit cards, and PayPal. +How can I contact support?,You can email support@example.com or call 1-800-123-4567. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 59156ed..5044a71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,9 @@ -qdrant-client -openai -python-dotenv \ No newline at end of file +langchain +langchain-community +langchain-ollama +chromadb +python-dotenv +click +fastapi +uvicorn +pandas \ No newline at end of file diff --git a/src/main.py b/src/main.py index 826b593..c9a8878 100644 --- a/src/main.py +++ b/src/main.py @@ -1,139 +1,141 @@ import os -from typing import List, Dict - -import openai -from qdrant_client import QdrantClient -from qdrant_client.http import models as qdrant_models +import re +import click +import pandas as pd +from pathlib import Path +from datetime import datetime from dotenv import load_dotenv +from langchain_ollama import Ollama, OllamaEmbeddings +from langchain.vectorstores import Chroma +from langchain.chains import RetrievalQA +from langchain.prompts import PromptTemplate +from langchain.tools import Tool + +# Load environment variables load_dotenv() +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3") +CHROMA_DB_PATH = os.getenv("CHROMA_DB_PATH", "./chromadb") +FAQ_DATA_PATH = Path("data/faq.csv") -# Configuration -QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost") -QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333")) -QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", None) -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +# Initialize embeddings and LLM +embeddings = OllamaEmbeddings(model=OLLAMA_MODEL) +llm = Ollama(model=OLLAMA_MODEL) -if not OPENAI_API_KEY: - raise RuntimeError("OPENAI_API_KEY environment variable not set") +# Initialize Chroma client and collection +from chromadb import Client +client = Client(path=CHROMA_DB_PATH) +collection = client.get_or_create_collection(name="faq") -openai.api_key = OPENAI_API_KEY +# Create vector store +vectorstore = Chroma(collection=collection, embedding=embeddings) -# Collection name -COLLECTION_NAME = "faq_collection" +# Prompt template for RetrievalQA +prompt = PromptTemplate( + input_variables=["context", "question"], + template=( + "You are a helpful FAQ bot. Use the following context to answer the question.\n" + "Context: {context}\n" + "Question: {question}\n" + "Answer:" + ), +) -# Embedding dimension for text-embedding-ada-002 -EMBEDDING_DIM = 1536 +# RetrievalQA chain +retrieval_chain = RetrievalQA.from_chain_type( + llm=llm, + chain_type="stuff", + retriever=vectorstore.as_retriever(), + chain_type_kwargs={"prompt": prompt}, +) -# Sample FAQ data -FAQ_DATA = [ - { - "question": "What is the return policy?", - "answer": "You can return any item within 30 days of purchase." - }, - { - "question": "How do I track my order?", - "answer": "Use the tracking link sent to your email after shipping." - }, - { - "question": "Do you offer international shipping?", - "answer": "Yes, we ship to most countries worldwide." - }, - { - "question": "What payment methods are accepted?", - "answer": "We accept credit cards, PayPal, and bank transfers." - }, - { - "question": "How can I contact customer support?", - "answer": "Email us at support@example.com or call 1-800-123-4567." - } -] +# MCP-tool: Current Time Tool +def get_current_time(_input: str) -> str: + """Return the current system time.""" + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") -def get_embedding(text: str) -> List[float]: - """ - Generate an embedding for the given text using OpenAI's embedding model. - """ - response = openai.Embedding.create( - input=text, - model="text-embedding-ada-002" +time_tool = Tool( + name="CurrentTime", + description="Returns the current system time. Useful when the user asks about the time or date.", + func=get_current_time, +) + +def ingest_faq(): + """Read FAQ data from CSV and upsert into Chroma collection.""" + if not FAQ_DATA_PATH.exists(): + click.echo(f"FAQ data file not found at {FAQ_DATA_PATH}") + return + df = pd.read_csv(FAQ_DATA_PATH) + if "question" not in df.columns or "answer" not in df.columns: + click.echo("FAQ CSV must contain 'question' and 'answer' columns.") + return + # Prepare documents + docs = df["answer"].tolist() + metadatas = [{"question": q} for q in df["question"]] + ids = [str(i) for i in range(len(docs))] + # Upsert into collection + collection.upsert( + documents=docs, + metadatas=metadatas, + ids=ids, ) - return response["data"][0]["embedding"] + click.echo(f"Ingested {len(docs)} FAQ entries into Chroma collection.") -def create_or_recreate_collection(client: QdrantClient) -> None: - """ - Create a new collection or recreate it if it already exists. - """ - client.recreate_collection( - collection_name=COLLECTION_NAME, - vectors_config=qdrant_models.VectorParams( - size=EMBEDDING_DIM, - distance=qdrant_models.Distance.COSINE - ) - ) +def is_collection_empty() -> bool: + """Check if the Chroma collection has any documents.""" + return len(collection.get(ids=None)["ids"]) == 0 -def ingest_faqs(client: QdrantClient, faqs: List[Dict[str, str]]) -> None: - """ - Ingest FAQ data into Qdrant. - """ - points = [] - for idx, faq in enumerate(faqs): - vector = get_embedding(faq["question"]) - point = qdrant_models.PointStruct( - id=idx, - vector=vector, - payload={ - "question": faq["question"], - "answer": faq["answer"] - } - ) - points.append(point) +def answer_query(question: str) -> str: + """Determine whether to use the time tool or the retrieval chain.""" + if re.search(r"\b(time|date)\b", question, re.I): + return time_tool.run(question) + else: + return retrieval_chain.run(question) - # Upsert points in batches - batch_size = 100 - for i in range(0, len(points), batch_size): - batch = points[i:i+batch_size] - client.upsert( - collection_name=COLLECTION_NAME, - points=batch - ) +# CLI implementation +@click.group() +def cli(): + """FAQ Bot CLI.""" + pass -def query_faq(client: QdrantClient, question: str, top_k: int = 1) -> str: - """ - Query the FAQ collection for the most relevant answer. - """ - query_vector = get_embedding(question) - search_result = client.search( - collection_name=COLLECTION_NAME, - query_vector=query_vector, - limit=top_k, - with_payload=True - ) - if not search_result: - return "Sorry, I couldn't find an answer to your question." - # Return the answer from the top result - return search_result[0].payload.get("answer", "Answer not found.") +@cli.command() +@click.argument("question", nargs=-1, required=True) +@click.option("--init", is_flag=True, help="Ingest FAQ data before answering.") +def ask(question, init): + """Ask a question to the FAQ bot.""" + if init or is_collection_empty(): + ingest_faq() + query = " ".join(question) + answer = answer_query(query) + click.echo(f"Answer: {answer}") -def main() -> None: - client = QdrantClient( - host=QDRANT_HOST, - port=QDRANT_PORT, - api_key=QDRANT_API_KEY - ) +@cli.command() +@click.option("--init", is_flag=True, help="Ingest FAQ data before starting the server.") +def serve(init): + """Start the FastAPI web server.""" + if init or is_collection_empty(): + ingest_faq() + import uvicorn + uvicorn.run("src.main:app", host="0.0.0.0", port=8000, reload=True) - # Ingest FAQs (only if collection is empty or you want to refresh) - print("Ingesting FAQ data into Qdrant...") - create_or_recreate_collection(client) - ingest_faqs(client, FAQ_DATA) - print("Ingestion complete.") +# FastAPI web interface +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel - print("\nFAQ Bot is ready. Type your question (or 'exit' to quit).") - while True: - user_input = input("\nYour question: ").strip() - if user_input.lower() in {"exit", "quit"}: - print("Goodbye!") - break - answer = query_faq(client, user_input) - print(f"Answer: {answer}") +app = FastAPI(title="FAQ Bot API") + +class QuestionRequest(BaseModel): + question: str + +class AnswerResponse(BaseModel): + answer: str + +@app.post("/ask", response_model=AnswerResponse) +async def ask_endpoint(req: QuestionRequest): + if not req.question: + raise HTTPException(status_code=400, detail="Question cannot be empty.") + answer = answer_query(req.question) + return AnswerResponse(answer=answer) if __name__ == "__main__": - main() \ No newline at end of file + cli() \ No newline at end of file