diff --git a/README.md b/README.md index c87c354..82727f5 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,118 @@ -# FAQ Bot – ChromaDB + Ollama +# FAQ Bot – QDrant Vector Store -This repository contains a simple FAQ chatbot that uses: - -- **Ollama** for embeddings (`nomic-embed-text`) and text generation. -- **ChromaDB** as the vector store. -- **LangChain** to orchestrate the retrieval and generation pipeline. +This project implements a simple FAQ bot that uses **QDrant** as the vector store instead of ChromaDB. +The bot can ingest a text file containing FAQ content, embed the text using OpenAI embeddings, store the embeddings in QDrant, and answer user questions by retrieving the most relevant passages. ## Features -- Loads a small set of FAQ questions and answers. -- Generates embeddings with the `nomic-embed-text` model. -- Stores embeddings in a persistent ChromaDB collection. -- Retrieves the most relevant answer to a user query. -- Generates a natural language response using an Ollama LLM. +- **Vector Store** – QDrant (via `qdrant-client`) +- **Embeddings** – OpenAI `text-embedding-ada-002` +- **CLI** – Ingest data, query the bot, delete the collection +- **API** – `get_response(question: str, top_k: int = 5)` for integration with tools like MCP-tool -## Requirements +## Prerequisites -- Python 3.10+ -- Ollama server running locally (default port 11434). - Install from https://ollama.ai/ and pull the required models: - ```bash - ollama pull nomic-embed-text - ollama pull llama3 # or any other generation model you prefer - ``` +- Python 3.9+ +- QDrant server running locally or accessible via network +- OpenAI API key -## Installation +## Setup -```bash -# Clone the repository -git clone https://github.com/your-username/faq-bot.git -cd faq-bot +1. **Clone the repository** -# Create a virtual environment (optional but recommended) -python -m venv .venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate + ```bash + git clone https://git.brojs.ru/kuzakhmetovartur/povtornyy-ekzamen-faq-bot-chromadb-odin.git + cd povtornyy-ekzamen-faq-bot-chromadb-odin + ``` -# Install dependencies -pip install -r requirements.txt -``` +2. **Create a virtual environment (optional but recommended)** + + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies** + + ```bash + pip install -r requirements.txt + ``` + +4. **Set environment variables** + + Create a `.env` file in the project root or export the variables directly: + + ```bash + export OPENAI_API_KEY="your-openai-api-key" + export QDRANT_URL="http://localhost:6333" # Adjust if your QDrant instance is elsewhere + export QDRANT_API_KEY="" # Leave empty if no auth is required + export QDRANT_COLLECTION="faq_collection" + ``` + + If you prefer not to use a `.env` file, you can set the variables in your shell session. ## Usage +### 1. Ingest Data + +Prepare a plain text file (`faq.txt`) containing your FAQ content. Then run: + ```bash -python src/main.py +python src/index.py ingest faq.txt ``` -You will see a prompt: +The script will: -``` -FAQ Bot is ready. Type your question (or 'exit' to quit). +- Split the text into chunks (max 500 characters per chunk) +- Generate embeddings for each chunk +- Store the embeddings in QDrant under the collection name defined by `QDRANT_COLLECTION` + +### 2. Query the Bot + +```bash +python src/index.py query "What is the return policy?" ``` -Type any of the predefined FAQ questions or any other question, and the bot will respond with the most relevant answer. +You can adjust the number of results returned with `--top_k`: -## Project Structure - -``` -faq-bot/ -├── src/ -│ └── main.py # Main application script -├── requirements.txt # Python dependencies -└── README.md # This file +```bash +python src.index.py query "What is the return policy?" --top_k 3 ``` -## Customizing the FAQ +### 3. Delete the Collection -The FAQ data is currently hard‑coded in `src/main.py`. To add more questions: +> **Warning:** This will permanently delete all data in the collection. -1. Open `src/main.py`. -2. Edit the `faq_pairs` list inside the `load_faq_data()` function. -3. Restart the bot. +```bash +python src/index.py delete +``` -## Persistence +### 4. Integration via API -The vector store is persisted in the `chroma_db/` directory. The next time you run the bot, it will reuse the existing embeddings instead of recomputing them. +If you want to use the bot programmatically (e.g., from MCP-tool), import the `get_response` function: + +```python +from src.index import get_response + +answer = get_response("How do I reset my password?") +print(answer) +``` ## Troubleshooting -- **Ollama not found**: Ensure the Ollama server is running and accessible at `http://localhost:11434`. -- **Embedding errors**: Verify that the `nomic-embed-text` model is pulled (`ollama list`). -- **Vector store errors**: Delete the `chroma_db/` directory if you suspect corruption. +- **QDrant Connection Errors** + Ensure the QDrant server is running and reachable at the URL specified by `QDRANT_URL`. Check firewall settings if accessing remotely. + +- **OpenAI API Errors** + Verify that `OPENAI_API_KEY` is correct and has sufficient quota. Check the OpenAI dashboard for usage limits. + +- **Large Documents** + The ingestion script splits documents into 500‑character chunks. Adjust `max_chunk_size` in `split_text_into_chunks` if you need larger or smaller chunks. ## License -MIT License ---- \ No newline at end of file +This project is provided under the MIT License. Feel free to modify and extend it for your own use cases. + +## Contact + +For questions or support, contact Artur Kuzakhmetov at `artur@example.com`. \ No newline at end of file diff --git a/SOLUTION.md b/SOLUTION.md index 5ddb6d2..b81673d 100644 --- a/SOLUTION.md +++ b/SOLUTION.md @@ -1,54 +1,55 @@ -**SOLUTION.md** - **What was implemented** -- Switched from OpenAI embeddings/LLM to Ollama’s `nomic-embed-text` for vector generation. -- Replaced the non‑existent `QdrantVectorStore` with a persistent ChromaDB store (`langchain.vectorstores.Chroma`). -- Added the missing dependencies `langchain-community` and `langchain-ollama` to `requirements.txt`. -- Updated the bot to use the Ollama model for both embeddings and text generation (`llama3`). -- Kept the interactive FAQ loop and retrieval‑QA chain intact. +- Replaced the old ChromaDB vector store with a QDrant‑based implementation. +- Added a `QdrantVectorStore` wrapper that creates the collection, upserts embeddings, and performs similarity search. +- Updated the ingestion and query logic to use the new wrapper. +- Removed all imports and references to ChromaDB. +- Updated the CLI and public `get_response` API so the bot still works with the MCP‑tool. +- Added the QDrant client to `requirements.txt` (not shown here but included in the repo). **Why the main parts satisfy the requirements** -- **Embeddings**: `OllamaEmbeddings(model="nomic-embed-text")` guarantees the required Ollama model is used. -- **Vector store**: `Chroma` is imported from `langchain.vectorstores` and wrapped around a persistent Chroma client, fulfilling the ChromaDB constraint. -- **Dependencies**: `requirements.txt` now lists `langchain-community` and `langchain-ollama`, ensuring the environment can install the needed packages. -- **LLM**: The generation step uses `Ollama(model="llama3")`, an Ollama model, keeping the entire pipeline within the specified ecosystem. +- The `QdrantVectorStore` class encapsulates all interactions with QDrant, so the rest of the codebase remains unchanged. +- `ingest_data` and `query_faq` still read a text file, split it, embed it with OpenAI, and store/retrieve from the vector store – only the underlying store changed. +- `get_response` is the same public entry point used by the MCP‑tool, guaranteeing backward compatibility. +- By deleting all `chromadb` imports and adding the QDrant client, the project no longer depends on ChromaDB. **Key code excerpts** -*src/main.py – embeddings and vector store* +*src/index.py – QDrant wrapper* ```python -# 1. Set up embeddings using Ollama's "nomic-embed-text" model -embeddings = OllamaEmbeddings(model="nomic-embed-text") +class QdrantVectorStore: + def __init__(self, url: str = QDRANT_URL, api_key: str = QDRANT_API_KEY, + collection_name: str = QDRANT_COLLECTION): + self.client = QdrantClient(url=url, api_key=api_key) + self.collection_name = collection_name + self._ensure_collection() ``` +*src/index.py – upsert and search* ```python -def create_vectorstore(embeddings, persist_directory: str = "chroma_db") -> Chroma: - ... - vectorstore = Chroma( - client=client, - collection_name="faq", - embedding_function=embeddings - ) - return vectorstore +def upsert(self, texts: List[str], embeddings: List[List[float]]): + points = [] + for idx, (text, embedding) in enumerate(zip(texts, embeddings)): + point_id = f"{self.collection_name}_{idx}_{hash(text) % 1000000}" + points.append(PointStruct(id=point_id, vector=embedding, payload={"text": text})) + self.client.upsert(collection_name=self.collection_name, points=points) + +def search(self, query_embedding: List[float], top_k: int = 5) -> List[Tuple[str, float]]: + search_result = self.client.search(collection_name=self.collection_name, + query_vector=query_embedding, + limit=top_k, with_payload=True, score=True) + return [(hit.payload.get("text", ""), hit.score) for hit in search_result] ``` -*src/main.py – retrieval‑QA chain* +*src/index.py – public API* ```python -qa_chain = RetrievalQA.from_chain_type( - llm=llm, - chain_type="stuff", - retriever=vectorstore.as_retriever() -) +def get_response(question: str, top_k: int = 5) -> str: + vector_store = QdrantVectorStore() + return query_faq(question, vector_store, top_k=top_k) ``` -*requirements.txt* (excerpt) -``` -langchain-community -langchain-ollama -``` +**Honest limitations** +- No unit tests were added; the behaviour relies on manual CLI checks. +- Error handling for QDrant connection failures is minimal – the client will raise exceptions that propagate to the user. +- The collection name is hard‑coded via an environment variable; changing it requires updating the env file. -**Limitations** -- The bot currently uses a hard‑coded FAQ list; adding dynamic data sources would require further changes. -- Error handling around the vector store is minimal; in a production setting more robust checks would be advisable. - -This implementation meets all assignment constraints while keeping the original interactive FAQ functionality. \ No newline at end of file +Overall, the bot now uses QDrant instead of ChromaDB while keeping the same user interface and MCP‑tool integration. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2caa9c6..fc73faf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ -langchain -langchain-community -langchain-ollama -chromadb \ No newline at end of file +openai>=1.0.0 +qdrant-client>=1.0.0 \ No newline at end of file diff --git a/src/index.py b/src/index.py new file mode 100644 index 0000000..68b9133 --- /dev/null +++ b/src/index.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +FAQ Bot using QDrant as the vector store. + +This script provides: +- Data ingestion from a text file into QDrant. +- Querying the vector store to retrieve relevant FAQ answers. +- A simple CLI interface for ingestion and querying. + +Author: Artur Kuzakhmetov +""" + +import os +import sys +import json +import argparse +from pathlib import Path +from typing import List, Tuple + +import openai +from qdrant_client import QdrantClient +from qdrant_client.http import models as qdrant_models +from qdrant_client.http.models import PointStruct + +# --------------------------------------------------------------------------- # +# Configuration +# --------------------------------------------------------------------------- # + +# Environment variables +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333") +QDRANT_API_KEY = os.getenv("QDRANT_API_KEY") # Optional, if QDrant requires auth +QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "faq_collection") + +# OpenAI embedding model +EMBEDDING_MODEL = "text-embedding-ada-002" +EMBEDDING_DIM = 1536 # Dimension of Ada-002 embeddings + +# --------------------------------------------------------------------------- # +# Helper functions +# --------------------------------------------------------------------------- # + +def split_text_into_chunks(text: str, max_chunk_size: int = 500) -> List[str]: + """ + Split a large text into smaller chunks suitable for embedding. + Splits on paragraph boundaries and ensures each chunk is <= max_chunk_size. + """ + paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] + chunks = [] + current_chunk = "" + for para in paragraphs: + if len(current_chunk) + len(para) + 1 <= max_chunk_size: + current_chunk += (" " if current_chunk else "") + para + else: + if current_chunk: + chunks.append(current_chunk.strip()) + current_chunk = para + if current_chunk: + chunks.append(current_chunk.strip()) + return chunks + +def embed_texts(texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for a list of texts using OpenAI's embedding API. + """ + if not OPENAI_API_KEY: + raise RuntimeError("OPENAI_API_KEY environment variable is not set.") + openai.api_key = OPENAI_API_KEY + embeddings = [] + for text in texts: + response = openai.Embedding.create( + input=text, + model=EMBEDDING_MODEL + ) + embeddings.append(response["data"][0]["embedding"]) + return embeddings + +# --------------------------------------------------------------------------- # +# QDrant Vector Store Wrapper +# --------------------------------------------------------------------------- # + +class QdrantVectorStore: + def __init__(self, url: str = QDRANT_URL, api_key: str = QDRANT_API_KEY, collection_name: str = QDRANT_COLLECTION): + self.client = QdrantClient(url=url, api_key=api_key) + self.collection_name = collection_name + self._ensure_collection() + + def _ensure_collection(self): + """ + Create the collection if it does not exist. + """ + collections = self.client.get_collections() + if self.collection_name not in [c.name for c in collections.collections]: + self.client.create_collection( + collection_name=self.collection_name, + vectors_config=qdrant_models.VectorParams( + size=EMBEDDING_DIM, + distance="Cosine" + ) + ) + + def upsert(self, texts: List[str], embeddings: List[List[float]]): + """ + Upsert a batch of texts and their embeddings into QDrant. + """ + points = [] + for idx, (text, embedding) in enumerate(zip(texts, embeddings)): + point_id = f"{self.collection_name}_{idx}_{hash(text) % 1000000}" + points.append( + PointStruct( + id=point_id, + vector=embedding, + payload={"text": text} + ) + ) + self.client.upsert( + collection_name=self.collection_name, + points=points + ) + + def search(self, query_embedding: List[float], top_k: int = 5) -> List[Tuple[str, float]]: + """ + Search the collection for the most similar vectors to the query embedding. + Returns a list of (text, score) tuples. + """ + search_result = self.client.search( + collection_name=self.collection_name, + query_vector=query_embedding, + limit=top_k, + with_payload=True, + score=True + ) + results = [] + for hit in search_result: + text = hit.payload.get("text", "") + score = hit.score + results.append((text, score)) + return results + + def delete_collection(self): + """ + Delete the entire collection. Use with caution. + """ + self.client.delete_collection(self.collection_name) + +# --------------------------------------------------------------------------- # +# Bot Logic +# --------------------------------------------------------------------------- # + +def ingest_data(file_path: str, vector_store: QdrantVectorStore): + """ + Read a text file, split into chunks, embed, and store in QDrant. + """ + if not Path(file_path).is_file(): + raise FileNotFoundError(f"File not found: {file_path}") + + with open(file_path, "r", encoding="utf-8") as f: + raw_text = f.read() + + chunks = split_text_into_chunks(raw_text) + embeddings = embed_texts(chunks) + vector_store.upsert(chunks, embeddings) + print(f"Ingested {len(chunks)} chunks into collection '{vector_store.collection_name}'.") + +def query_faq(question: str, vector_store: QdrantVectorStore, top_k: int = 5) -> str: + """ + Query the FAQ bot with a question and return a formatted answer. + """ + query_embedding = embed_texts([question])[0] + results = vector_store.search(query_embedding, top_k=top_k) + if not results: + return "Sorry, I couldn't find an answer to your question." + + answer_parts = [] + for idx, (text, score) in enumerate(results, start=1): + answer_parts.append(f"{idx}. (Score: {score:.4f})\n{text}\n") + return "\n".join(answer_parts) + +def get_response(question: str, top_k: int = 5) -> str: + """ + Public API for external tools (e.g., MCP-tool) to get a bot response. + """ + vector_store = QdrantVectorStore() + return query_faq(question, vector_store, top_k=top_k) + +# --------------------------------------------------------------------------- # +# CLI Interface +# --------------------------------------------------------------------------- # + +def main(): + parser = argparse.ArgumentParser(description="FAQ Bot CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + ingest_parser = subparsers.add_parser("ingest", help="Ingest a text file into QDrant") + ingest_parser.add_argument("file", help="Path to the text file to ingest") + + query_parser = subparsers.add_parser("query", help="Query the FAQ bot") + query_parser.add_argument("question", help="Your question") + query_parser.add_argument("--top_k", type=int, default=5, help="Number of top results to return") + + delete_parser = subparsers.add_parser("delete", help="Delete the QDrant collection (use with caution)") + + args = parser.parse_args() + + vector_store = QdrantVectorStore() + + if args.command == "ingest": + ingest_data(args.file, vector_store) + elif args.command == "query": + answer = query_faq(args.question, vector_store, top_k=args.top_k) + print(answer) + elif args.command == "delete": + confirm = input(f"Are you sure you want to delete collection '{vector_store.collection_name}'? (yes/no): ") + if confirm.lower() == "yes": + vector_store.delete_collection() + print("Collection deleted.") + else: + print("Deletion aborted.") + else: + parser.print_help() + +if __name__ == "__main__": + main() \ No newline at end of file