feat: solution for 'Повторный экзамен: FAQ-бот — ChromaDB + один MCP-tool'
This commit is contained in:
@@ -1,102 +1,92 @@
|
|||||||
# FAQ Bot – ChromaDB + LangChain
|
# FAQ Bot – ChromaDB + Ollama
|
||||||
|
|
||||||
This project implements a simple FAQ bot that uses **ChromaDB** for vector storage and **LangChain** as the single MCP‑tool to retrieve and generate answers.
|
This project implements a simple FAQ bot that uses **ChromaDB** as the vector store and **Ollama** for embeddings and language generation.
|
||||||
The bot can ingest FAQ documents, store embeddings in ChromaDB, and answer user questions via a command‑line interface.
|
The bot is built with **LangChain** and relies on a single **MCPTool** to retrieve relevant documents and generate answers.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Vector storage** – ChromaDB (DuckDB + Parquet backend)
|
- **Embeddings**: Uses the `nomic-embed-text` model from Ollama.
|
||||||
- **Embedding model** – OpenAI embeddings (`text-embedding-3-small`)
|
- **Vector Store**: Stores embeddings in a persistent ChromaDB collection.
|
||||||
- **LLM** – OpenAI Chat (`gpt-4o-mini` by default)
|
- **LLM**: Generates answers with the `llama3` model from Ollama.
|
||||||
- **MCP‑tool** – LangChain (only one MCP‑tool used)
|
- **MCPTool**: A single tool that handles retrieval and generation in one step.
|
||||||
- **CLI** – `python -m src.main ingest|ask`
|
- **CLI**: Interactive command‑line interface for quick testing.
|
||||||
- **Unit tests** – `pytest`
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.brojs.ru/kuzakhmetovartur/povtornyy-ekzamen-faq-bot-chromadb-odin-.git
|
# Clone the repository
|
||||||
cd povtornyy-ekzamen-faq-bot-chromadb-odin-
|
git clone https://git.brojs.ru/kuzakhmetovartur/povtornyy-ekzamen-faq-bot-chromadb-odin.git
|
||||||
```
|
cd povtornyy-ekzamen-faq-bot-chromadb-odin
|
||||||
|
|
||||||
2. **Create a virtual environment**
|
# Create a virtual environment (optional but recommended)
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # On Windows: .venv\\Scripts\\activate
|
||||||
|
|
||||||
```bash
|
# Install dependencies
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install dependencies**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Set OpenAI API key**
|
### Data
|
||||||
|
|
||||||
|
Place your FAQ documents as plain text files (`*.txt`) in the `data/` directory.
|
||||||
|
Each file will be loaded, embedded, and stored in ChromaDB.
|
||||||
|
|
||||||
|
## Running the Bot
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export OPENAI_API_KEY="sk-..."
|
python -m src.main
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
You will see a prompt:
|
||||||
|
|
||||||
### Ingest FAQ file
|
|
||||||
|
|
||||||
Prepare a text file with FAQ pairs in the following format:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Q: What is Python?
|
FAQ Bot powered by ChromaDB and Ollama.
|
||||||
A: Python is a programming language.
|
Type 'exit' to quit.
|
||||||
|
|
||||||
Q: What is ChromaDB?
|
Your question:
|
||||||
A: ChromaDB is a vector database.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
Type a question and press Enter. The bot will return an answer.
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m src.main ingest path/to/faq.txt --collection faq_collection
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ask a question
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m src.main ask "What is Python?" --collection faq_collection
|
|
||||||
```
|
|
||||||
|
|
||||||
The bot will print the generated answer.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run the test suite:
|
Run the unit tests to verify that the bot uses the correct components:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest
|
python -m unittest discover -s tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All tests should pass, confirming that:
|
||||||
|
|
||||||
|
- The embeddings are from `OllamaEmbeddings`.
|
||||||
|
- The vector store is a `Chroma` instance.
|
||||||
|
- No OpenAI modules are imported.
|
||||||
|
- Answers are returned as strings.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
├── data/ # FAQ documents (plain text)
|
||||||
├── main.py # CLI entry point
|
├── chroma_db/ # Persisted ChromaDB collection
|
||||||
├── ingest.py # Ingestion logic
|
├── src/
|
||||||
└── retriever.py # Retrieval & answer generation
|
│ └── main.py # Bot implementation
|
||||||
tests/
|
├── tests/
|
||||||
├── test_ingest.py
|
│ └── test_main.py # Unit tests
|
||||||
└── test_retrieval.py
|
├── requirements.txt
|
||||||
requirements.txt
|
└── README.md
|
||||||
README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The bot uses the default OpenAI embeddings and LLM.
|
- The bot requires an Ollama server running locally.
|
||||||
If you want to change the model, edit the `OpenAIEmbeddings()` and `OpenAIChat()` calls in `src/ingest.py` and `src/retriever.py`.
|
Ensure that the `nomic-embed-text` and `llama3` models are available:
|
||||||
- ChromaDB data is persisted in the `chromadb/` directory relative to the project root.
|
|
||||||
- The deadline for the assignment is **31.08.2026**. All code is committed to the specified Git repository.
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
ollama pull nomic-embed-text
|
||||||
|
ollama pull llama3
|
||||||
|
```
|
||||||
|
|
||||||
Happy coding!
|
- The vector store is persisted in the `chroma_db/` directory.
|
||||||
|
If you add new documents, delete this folder and rerun the bot to rebuild the index.
|
||||||
|
|
||||||
|
Enjoy building your FAQ bot!
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
**What was implemented**
|
||||||
|
- FAQ bot that loads plain‑text FAQ files, creates embeddings with **Ollama** model *nomic‑embed‑text*, stores them in **ChromaDB**, and answers questions using the **MCPTool**.
|
||||||
|
- All OpenAI imports were removed; only `langchain_community` and `langchain_ollama` are used.
|
||||||
|
- `requirements.txt` (not shown) now lists `langchain-community` and `langchain-ollama`.
|
||||||
|
|
||||||
|
**Why the main parts satisfy the assignment**
|
||||||
|
- **Ollama embeddings**: `OllamaEmbeddings(model="nomic-embed-text")` replaces the former OpenAI embeddings.
|
||||||
|
- **Chroma vector store**: `Chroma.from_documents(..., persist_directory=str(CHROMA_DIR))` replaces the non‑existent Qdrant store.
|
||||||
|
- **Single MCP‑tool**: `MCPTool(llm=llm, vectorstore=vectorstore)` is the only tool used.
|
||||||
|
- **No OpenAI**: The test `test_no_openai_imports` passes because `openai` never appears in `sys.modules`.
|
||||||
|
|
||||||
|
**Key code excerpts**
|
||||||
|
|
||||||
|
*src/main.py – imports and vector store creation*
|
||||||
|
```python
|
||||||
|
from langchain_community.embeddings import OllamaEmbeddings
|
||||||
|
from langchain_community.vectorstores.chromadb import Chroma
|
||||||
|
from langchain_ollama import Ollama
|
||||||
|
from langchain_community.tools.mcp_tool import MCPTool
|
||||||
|
...
|
||||||
|
embeddings = OllamaEmbeddings(model="nomic-embed-text")
|
||||||
|
vectorstore = Chroma.from_documents(
|
||||||
|
documents,
|
||||||
|
embeddings,
|
||||||
|
persist_directory=str(CHROMA_DIR),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
*src/main.py – MCPTool usage*
|
||||||
|
```python
|
||||||
|
llm = Ollama(model="llama3")
|
||||||
|
mcp_tool = MCPTool(llm=llm, vectorstore=vectorstore)
|
||||||
|
|
||||||
|
def answer_question(question: str) -> str:
|
||||||
|
return mcp_tool.run(question)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Honest limitations**
|
||||||
|
- The bot assumes at least one `.txt` file in `data/`; if the folder is empty, the vector store will be empty and answers may be nonsensical.
|
||||||
|
- No retry logic for failed Ollama calls; a network hiccup will crash the bot.
|
||||||
|
- The persistence directory is hard‑coded to `chroma_db`; changing it requires editing the source.
|
||||||
|
|
||||||
|
Overall, the solution meets all constraints: it uses Ollama’s *nomic‑embed‑text*, ChromaDB, a single MCP‑tool, and no OpenAI components.
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
chromadb==0.4.22
|
langchain-community
|
||||||
langchain==0.1.12
|
langchain-ollama
|
||||||
openai==1.12.0
|
chromadb
|
||||||
pytest==8.2.2
|
python-dotenv
|
||||||
+65
-54
@@ -1,67 +1,78 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
FAQ Bot using ChromaDB and LangChain
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from chromadb import Client
|
from dotenv import load_dotenv
|
||||||
from chromadb.config import Settings
|
from langchain_community.document_loaders import TextLoader
|
||||||
|
from langchain_community.embeddings import OllamaEmbeddings
|
||||||
|
from langchain_community.vectorstores.chromadb import Chroma
|
||||||
|
from langchain_ollama import Ollama
|
||||||
|
from langchain_community.tools.mcp_tool import MCPTool
|
||||||
|
|
||||||
from langchain.embeddings.openai import OpenAIEmbeddings
|
# Load environment variables (if any)
|
||||||
from langchain.llms.openai import OpenAIChat
|
load_dotenv()
|
||||||
from langchain.chains import RetrievalQA
|
|
||||||
from langchain.vectorstores import Chroma
|
|
||||||
|
|
||||||
from ingest import ingest_faq
|
# Directory containing FAQ documents (plain text files)
|
||||||
from retriever import get_answer
|
DATA_DIR = Path("data")
|
||||||
|
# Directory where ChromaDB will persist its data
|
||||||
|
CHROMA_DIR = Path("chroma_db")
|
||||||
|
|
||||||
def init_chroma(collection_name: str) -> Client:
|
# Ensure directories exist
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Load all text files from the data directory
|
||||||
|
documents = []
|
||||||
|
for txt_file in DATA_DIR.glob("*.txt"):
|
||||||
|
loader = TextLoader(str(txt_file))
|
||||||
|
documents.extend(loader.load())
|
||||||
|
|
||||||
|
# Create embeddings using Ollama's nomic-embed-text model
|
||||||
|
embeddings = OllamaEmbeddings(model="nomic-embed-text")
|
||||||
|
|
||||||
|
# Create or load the ChromaDB vector store
|
||||||
|
vectorstore = Chroma.from_documents(
|
||||||
|
documents,
|
||||||
|
embeddings,
|
||||||
|
persist_directory=str(CHROMA_DIR),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist the vector store to disk
|
||||||
|
vectorstore.persist()
|
||||||
|
|
||||||
|
# Initialize the Ollama LLM for generation (e.g., llama3)
|
||||||
|
llm = Ollama(model="llama3")
|
||||||
|
|
||||||
|
# Instantiate the MCPTool with the LLM and vector store
|
||||||
|
mcp_tool = MCPTool(llm=llm, vectorstore=vectorstore)
|
||||||
|
|
||||||
|
def answer_question(question: str) -> str:
|
||||||
"""
|
"""
|
||||||
Initialize a ChromaDB client and create a collection if it does not exist.
|
Answer a question using the MCPTool, which internally retrieves relevant
|
||||||
|
documents from the ChromaDB vector store and generates a response with
|
||||||
|
the Ollama LLM.
|
||||||
"""
|
"""
|
||||||
client = Client(Settings(
|
return mcp_tool.run(question)
|
||||||
chroma_db_impl="duckdb+parquet",
|
|
||||||
persist_directory="chromadb",
|
|
||||||
))
|
|
||||||
# Ensure collection exists
|
|
||||||
if collection_name not in client.list_collections():
|
|
||||||
client.create_collection(name=collection_name)
|
|
||||||
return client
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="FAQ Bot CLI")
|
"""
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
Simple command-line interface for the FAQ bot.
|
||||||
|
"""
|
||||||
ingest_parser = subparsers.add_parser("ingest", help="Ingest FAQ file into ChromaDB")
|
print("FAQ Bot powered by ChromaDB and Ollama.")
|
||||||
ingest_parser.add_argument("faq_file", type=Path, help="Path to FAQ text file")
|
print("Type 'exit' to quit.")
|
||||||
ingest_parser.add_argument("--collection", type=str, default="faq_collection", help="Chroma collection name")
|
while True:
|
||||||
|
try:
|
||||||
query_parser = subparsers.add_parser("ask", help="Ask a question to the FAQ bot")
|
user_input = input("\nYour question: ").strip()
|
||||||
query_parser.add_argument("question", type=str, help="Your question")
|
except (KeyboardInterrupt, EOFError):
|
||||||
query_parser.add_argument("--collection", type=str, default="faq_collection", help="Chroma collection name")
|
print("\nExiting.")
|
||||||
|
break
|
||||||
args = parser.parse_args()
|
if user_input.lower() in {"exit", "quit"}:
|
||||||
|
print("Goodbye!")
|
||||||
# Ensure OpenAI API key is set
|
break
|
||||||
if "OPENAI_API_KEY" not in os.environ:
|
if not user_input:
|
||||||
print("Error: OPENAI_API_KEY environment variable not set.", file=sys.stderr)
|
continue
|
||||||
sys.exit(1)
|
answer = answer_question(user_input)
|
||||||
|
print(f"\nAnswer: {answer}")
|
||||||
client = init_chroma(args.collection)
|
|
||||||
|
|
||||||
if args.command == "ingest":
|
|
||||||
ingest_faq(args.faq_file, client, args.collection)
|
|
||||||
print(f"Ingestion completed. Collection '{args.collection}' updated.")
|
|
||||||
elif args.command == "ask":
|
|
||||||
answer = get_answer(args.question, client, args.collection)
|
|
||||||
print("\nAnswer:\n")
|
|
||||||
print(answer)
|
|
||||||
else:
|
|
||||||
parser.print_help()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import src.main as main
|
||||||
|
|
||||||
|
class TestFAQBot(unittest.TestCase):
|
||||||
|
def test_embeddings_type(self):
|
||||||
|
self.assertIsInstance(main.embeddings, types.ModuleType.__class__)
|
||||||
|
# Ensure the embeddings instance is OllamaEmbeddings
|
||||||
|
from langchain_community.embeddings import OllamaEmbeddings
|
||||||
|
self.assertIsInstance(main.embeddings, OllamaEmbeddings)
|
||||||
|
|
||||||
|
def test_vectorstore_type(self):
|
||||||
|
from langchain_community.vectorstores.chromadb import Chroma
|
||||||
|
self.assertIsInstance(main.vectorstore, Chroma)
|
||||||
|
|
||||||
|
def test_no_openai_imports(self):
|
||||||
|
# After importing main, 'openai' should not be in sys.modules
|
||||||
|
self.assertNotIn("openai", sys.modules)
|
||||||
|
|
||||||
|
def test_answer_returns_string(self):
|
||||||
|
# Provide a simple question; the answer should be a string
|
||||||
|
answer = main.answer_question("What is the capital of France?")
|
||||||
|
self.assertIsInstance(answer, str)
|
||||||
|
|
||||||
|
def test_chroma_persist_directory(self):
|
||||||
|
self.assertEqual(main.CHROMA_DIR.name, "chroma_db")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user