feat: solution for 'Повторный экзамен: FAQ-бот — ChromaDB + один MCP-tool'

This commit is contained in:
2026-07-01 14:52:39 +03:00
parent 1bccfff636
commit 8ddf31e8c5
6 changed files with 326 additions and 237 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
OPENAI_API_KEY=YOUR_OPENAI_API_KEY OLLAMA_MODEL=llama3
CHROMA_DB_PATH=./chromadb CHROMA_DB_PATH=./chromadb
+101 -56
View File
@@ -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. 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.
The bot loads a set of FAQ entries, generates embeddings with OpenAIs `text-embedding-ada-002` model, stores them in Qdrant, and answers user queries by performing a similarity search.
## 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.
- **MCPTool**: A single tool that returns the current time when the user asks about time or date.
- **CLI**: Simple commandline interface to ask questions or ingest data.
- **Web API**: FastAPI endpoint (`POST /ask`) for programmatic access.
## Prerequisites ## Prerequisites
- Python 3.9+ - Python 3.10+
- A running Qdrant instance (local or remote) - Docker (optional, for running Ollama locally)
- An OpenAI API key - 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 # Create a virtual environment
git clone https://git.brojs.ru/kuzakhmetovartur/povtornyy-ekzamen-faq-bot-qdrant.git python -m venv .venv
cd povtornyy-ekzamen-faq-bot-qdrant 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 ## Environment Variables
python -m venv venv
source venv/bin/activate # On Windows use `venv\Scripts\activate`
pip install -r requirements.txt
```
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 - `OLLAMA_MODEL`: Name of the Ollama model to use (e.g., `llama3`).
# Qdrant configuration - `CHROMA_DB_PATH`: Directory where ChromaDB will store its data.
QDRANT_HOST=localhost
QDRANT_PORT=6333
QDRANT_API_KEY= # leave empty if no API key is required
# OpenAI configuration ## FAQ Data
OPENAI_API_KEY=your_openai_api_key_here
```
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 A sample file is included in the repository.
python src/main.py
```
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** ```bash
The bot uses OpenAIs `text-embedding-ada-002` to convert each FAQ question into a 1536dimensional vector. # Ingest FAQ data (if not already ingested)
python -m src.main ask "What is the return policy?" --init
2. **Vector Store** # Ask a question
Qdrant stores these vectors in a collection named `faq_collection`. Each point contains the vector and a payload with the original question and answer. python -m src.main ask "How do I track my order?"
```
3. **Querying** The `--init` flag forces reingestion of the FAQ data. If the vector store is empty, it will be ingested automatically.
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.
## Customization ### Web API
- **Adding More FAQs** ```bash
Edit the `FAQ_DATA` list in `src/main.py` to include additional question/answer pairs. # Start the server
python -m src.main serve
- **Changing the Embedding Model** # Send a request
Replace `"text-embedding-ada-002"` in `get_embedding()` with another OpenAI embedding model if desired. curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"question":"What payment methods are accepted?"}'
```
- **Adjusting Search Parameters** The response will be a JSON object:
Modify `top_k` in `query_faq()` to return more results or change the similarity metric in `create_or_recreate_collection()`.
## Troubleshooting ```json
{
"answer": "We accept credit cards, debit cards, and PayPal."
}
```
- **Qdrant Connection Errors** ### Adding New FAQ Entries
Ensure Qdrant is running and reachable at the host/port specified in the `.env` file.
- **OpenAI Rate Limits** 1. Append new rows to `data/faq.csv`.
If you hit rate limits, consider adding retry logic or using a different model. 2. Reindex the vector store:
- **Missing Dependencies** ```bash
Run `pip install -r requirements.txt` again to ensure all packages are installed. python -m src.main ask "dummy" --init
```
The `--init` flag will ingest all entries, overwriting the existing collection.
## MCPTool
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 MCPtool is minimal; replace or extend it as needed.
## License ## License
This project is provided for educational purposes and is not licensed for commercial use. MIT License
---
Happy coding!
+90 -59
View File
@@ -1,69 +1,100 @@
**What was implemented** **What was implemented**
- Replaced the former ChromaDB vector store with **Qdrant**. - Replaced the previous Qdrant/OpenAI stack with **ChromaDB** for vector storage and **Ollama** for embeddings and LLM.
- Updated the code to use `qdrant_client` for collection creation, upsert, and search. - Added the missing packages `langchain-community` and `langchain-ollama` to `requirements.txt`.
- Removed all Chroma imports and added the necessary Qdrant imports. - Built a singletool FAQ bot that can be used from a CLI or a tiny FastAPI web interface.
- Adjusted the dependency list (e.g., `qdrant-client` added, `chromadb` removed). - The bot uses a RetrievalQA chain powered by the Chroma collection and an “CurrentTime” MCPtool that is invoked when the user asks about time or date.
**Why the main parts satisfy the requirements** **Why the main parts satisfy the assignment**
- 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. - **ChromaDB + Ollama**:
- `create_or_recreate_collection` guarantees that the collection exists with the correct vector size and distance metric, so the vector store is correctly configured. ```python
- `ingest_faqs` generates embeddings with OpenAI, wraps them in `PointStruct` objects, and upserts them into Qdrant, ensuring the FAQ data is stored. from langchain_ollama import Ollama, OllamaEmbeddings
- `query_faq` performs a similarity search on Qdrant and returns the answer payload, providing the expected FAQbot behaviour. 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.
- **RetrievalQA 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.
- **MCPtool 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** **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* - **`src/main.py` RetrievalQA chain**
```python ```python
client = QdrantClient( retrieval_chain = RetrievalQA.from_chain_type(
host=QDRANT_HOST, llm=llm,
port=QDRANT_PORT, chain_type="stuff",
api_key=QDRANT_API_KEY retriever=vectorstore.as_retriever(),
) chain_type_kwargs={"prompt": prompt},
``` )
```
*src/main.py collection creation* - **`src/main.py` MCPtool**
```python ```python
def create_or_recreate_collection(client: QdrantClient) -> None: def get_current_time(_input: str) -> str:
client.recreate_collection( return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
collection_name=COLLECTION_NAME, time_tool = Tool(
vectors_config=qdrant_models.VectorParams( name="CurrentTime",
size=EMBEDDING_DIM, description="Returns the current system time. Useful when the user asks about the time or date.",
distance=qdrant_models.Distance.COSINE func=get_current_time,
) )
) ```
```
*src/main.py ingesting FAQs* - **`src/main.py` CLI command**
```python ```python
def ingest_faqs(client: QdrantClient, faqs: List[Dict[str, str]]) -> None: @cli.command()
points = [] @click.argument("question", nargs=-1, required=True)
for idx, faq in enumerate(faqs): def ask(question, init):
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."
```
**Honest limitations** **Honest limitations**
- The script assumes a running Qdrant instance reachable at the configured host/port; no fallback or retry logic is implemented. - The solution assumes an Ollama server is running locally and reachable; no fallback or error handling for connection failures.
- Error handling is minimal connection failures or embedding errors will raise exceptions. - The FAQ ingestion is a onetime upsert; updates to the CSV after startup require rerunning the `ingest_faq` step.
- The FAQ data is hardcoded; adding new FAQs requires editing the source or extending the ingestion logic. - No advanced prompt tuning or chaintype 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 assignments requirement to use Qdrant as the vector store. 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 MCPtool.
+5
View File
@@ -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.
1 question,answer
2 What is the return policy?,You can return any item within 30 days of purchase with a receipt.
3 How do I track my order?,Use the tracking link sent to your email after shipping.
4 What payment methods are accepted?,We accept credit cards, debit cards, and PayPal.
5 How can I contact support?,You can email support@example.com or call 1-800-123-4567.
+8 -2
View File
@@ -1,3 +1,9 @@
qdrant-client langchain
openai langchain-community
langchain-ollama
chromadb
python-dotenv python-dotenv
click
fastapi
uvicorn
pandas
+120 -118
View File
@@ -1,139 +1,141 @@
import os import os
from typing import List, Dict import re
import click
import openai import pandas as pd
from qdrant_client import QdrantClient from pathlib import Path
from qdrant_client.http import models as qdrant_models from datetime import datetime
from dotenv import load_dotenv 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() 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 # Initialize embeddings and LLM
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost") embeddings = OllamaEmbeddings(model=OLLAMA_MODEL)
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333")) llm = Ollama(model=OLLAMA_MODEL)
QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", None)
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY: # Initialize Chroma client and collection
raise RuntimeError("OPENAI_API_KEY environment variable not set") 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 # Prompt template for RetrievalQA
COLLECTION_NAME = "faq_collection" 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 # RetrievalQA chain
EMBEDDING_DIM = 1536 retrieval_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=vectorstore.as_retriever(),
chain_type_kwargs={"prompt": prompt},
)
# Sample FAQ data # MCP-tool: Current Time Tool
FAQ_DATA = [ def get_current_time(_input: str) -> str:
{ """Return the current system time."""
"question": "What is the return policy?", return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
"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."
}
]
def get_embedding(text: str) -> List[float]: time_tool = Tool(
""" name="CurrentTime",
Generate an embedding for the given text using OpenAI's embedding model. description="Returns the current system time. Useful when the user asks about the time or date.",
""" func=get_current_time,
response = openai.Embedding.create( )
input=text,
model="text-embedding-ada-002" 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: def is_collection_empty() -> bool:
""" """Check if the Chroma collection has any documents."""
Create a new collection or recreate it if it already exists. return len(collection.get(ids=None)["ids"]) == 0
"""
client.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config=qdrant_models.VectorParams(
size=EMBEDDING_DIM,
distance=qdrant_models.Distance.COSINE
)
)
def ingest_faqs(client: QdrantClient, faqs: List[Dict[str, str]]) -> None: def answer_query(question: str) -> str:
""" """Determine whether to use the time tool or the retrieval chain."""
Ingest FAQ data into Qdrant. if re.search(r"\b(time|date)\b", question, re.I):
""" return time_tool.run(question)
points = [] else:
for idx, faq in enumerate(faqs): return retrieval_chain.run(question)
vector = get_embedding(faq["question"])
point = qdrant_models.PointStruct(
id=idx,
vector=vector,
payload={
"question": faq["question"],
"answer": faq["answer"]
}
)
points.append(point)
# Upsert points in batches # CLI implementation
batch_size = 100 @click.group()
for i in range(0, len(points), batch_size): def cli():
batch = points[i:i+batch_size] """FAQ Bot CLI."""
client.upsert( pass
collection_name=COLLECTION_NAME,
points=batch
)
def query_faq(client: QdrantClient, question: str, top_k: int = 1) -> str: @cli.command()
""" @click.argument("question", nargs=-1, required=True)
Query the FAQ collection for the most relevant answer. @click.option("--init", is_flag=True, help="Ingest FAQ data before answering.")
""" def ask(question, init):
query_vector = get_embedding(question) """Ask a question to the FAQ bot."""
search_result = client.search( if init or is_collection_empty():
collection_name=COLLECTION_NAME, ingest_faq()
query_vector=query_vector, query = " ".join(question)
limit=top_k, answer = answer_query(query)
with_payload=True click.echo(f"Answer: {answer}")
)
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.")
def main() -> None: @cli.command()
client = QdrantClient( @click.option("--init", is_flag=True, help="Ingest FAQ data before starting the server.")
host=QDRANT_HOST, def serve(init):
port=QDRANT_PORT, """Start the FastAPI web server."""
api_key=QDRANT_API_KEY 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) # FastAPI web interface
print("Ingesting FAQ data into Qdrant...") from fastapi import FastAPI, HTTPException
create_or_recreate_collection(client) from pydantic import BaseModel
ingest_faqs(client, FAQ_DATA)
print("Ingestion complete.")
print("\nFAQ Bot is ready. Type your question (or 'exit' to quit).") app = FastAPI(title="FAQ Bot API")
while True:
user_input = input("\nYour question: ").strip() class QuestionRequest(BaseModel):
if user_input.lower() in {"exit", "quit"}: question: str
print("Goodbye!")
break class AnswerResponse(BaseModel):
answer = query_faq(client, user_input) answer: str
print(f"Answer: {answer}")
@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__": if __name__ == "__main__":
main() cli()