diff --git a/README.md b/README.md index 11e04d3..caaca98 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,102 @@ # FAQ Bot – ChromaDB + LangChain -This project implements a simple FAQ bot that answers user questions based on a predefined FAQ dataset. -The bot uses **ChromaDB** for vector storage and **LangChain** as the single MCP‑tool to process queries. +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. +The bot can ingest FAQ documents, store embeddings in ChromaDB, and answer user questions via a command‑line interface. ## Features -- Persistent vector store (ChromaDB) – data is saved to disk and reused across runs. -- Retrieval‑based QA using LangChain’s `RetrievalQA` chain. -- Simple command‑line interface. -- Unit tests covering vector store creation, bot answering, and unknown‑question handling. +- **Vector storage** – ChromaDB (DuckDB + Parquet backend) +- **Embedding model** – OpenAI embeddings (`text-embedding-3-small`) +- **LLM** – OpenAI Chat (`gpt-4o-mini` by default) +- **MCP‑tool** – LangChain (only one MCP‑tool used) +- **CLI** – `python -m src.main ingest|ask` +- **Unit tests** – `pytest` ## Setup -```bash -# Create a virtual environment (optional but recommended) -python -m venv .venv -source .venv/bin/activate # On Windows use `.venv\Scripts\activate` +1. **Clone the repository** -# Install dependencies -pip install -r requirements.txt -``` + ```bash + 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** + + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` + +3. **Install dependencies** + + ```bash + pip install -r requirements.txt + ``` + +4. **Set OpenAI API key** + + ```bash + export OPENAI_API_KEY="sk-..." + ``` ## Usage -```bash -python src/main.py +### Ingest FAQ file + +Prepare a text file with FAQ pairs in the following format: + +``` +Q: What is Python? +A: Python is a programming language. + +Q: What is ChromaDB? +A: ChromaDB is a vector database. ``` -You will be prompted to type a question. The bot will reply with the best answer from the FAQ dataset. +Run: -## Running Tests +```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 + +Run the test suite: ```bash pytest ``` -All tests should pass. +## Project Structure -## FAQ Dataset +``` +src/ +├── main.py # CLI entry point +├── ingest.py # Ingestion logic +└── retriever.py # Retrieval & answer generation +tests/ +├── test_ingest.py +└── test_retrieval.py +requirements.txt +README.md +``` -The dataset is embedded in the code (3 entries). Feel free to extend it in `src/vector_store.py`. +## Notes -## License +- The bot uses the default OpenAI embeddings and LLM. + If you want to change the model, edit the `OpenAIEmbeddings()` and `OpenAIChat()` calls in `src/ingest.py` and `src/retriever.py`. +- 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. -MIT License \ No newline at end of file +--- + +Happy coding! \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 836a55a..152f5bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ chromadb==0.4.22 -langchain==0.0.346 -openai==0.27.8 -pytest==7.4.3 \ No newline at end of file +langchain==0.1.12 +openai==1.12.0 +pytest==8.2.2 \ No newline at end of file diff --git a/src/ingest.py b/src/ingest.py new file mode 100644 index 0000000..6ba811a --- /dev/null +++ b/src/ingest.py @@ -0,0 +1,72 @@ +""" +Ingestion logic for FAQ documents into ChromaDB. +""" + +import os +from pathlib import Path +from typing import List + +from chromadb import Client +from chromadb.api.types import Documents, EmbeddingFunction +from chromadb.config import Settings + +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.text_splitter import RecursiveCharacterTextSplitter + +def _load_faq_pairs(file_path: Path) -> List[tuple]: + """ + Load FAQ pairs from a text file. + Expected format: + Q: + A: + Each pair separated by a blank line. + """ + pairs = [] + with file_path.open("r", encoding="utf-8") as f: + content = f.read() + raw_pairs = content.strip().split("\n\n") + for raw in raw_pairs: + lines = raw.strip().splitlines() + if len(lines) < 2: + continue + q_line = lines[0].strip() + a_line = lines[1].strip() + if q_line.lower().startswith("q:") and a_line.lower().startswith("a:"): + question = q_line[2:].strip() + answer = a_line[2:].strip() + pairs.append((question, answer)) + return pairs + +def ingest_faq(file_path: Path, client: Client, collection_name: str): + """ + Ingest FAQ pairs into the specified ChromaDB collection. + """ + pairs = _load_faq_pairs(file_path) + if not pairs: + raise ValueError("No valid FAQ pairs found in the file.") + + # Prepare documents and metadata + documents = [] + metadatas = [] + ids = [] + + for idx, (q, a) in enumerate(pairs): + # Combine question and answer for embedding + doc = f"Q: {q}\nA: {a}" + documents.append(doc) + metadatas.append({"question": q, "answer": a}) + ids.append(str(idx)) + + # Use OpenAI embeddings + embedding = OpenAIEmbeddings() + + # Create or get collection + collection = client.get_or_create_collection(name=collection_name) + + # Add documents to collection + collection.add( + documents=documents, + metadatas=metadatas, + ids=ids, + embedding_function=embedding + ) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 9a00b12..e8fe964 100644 --- a/src/main.py +++ b/src/main.py @@ -1,41 +1,67 @@ +#!/usr/bin/env python3 """ -Command‑line interface for the FAQ bot. +FAQ Bot using ChromaDB and LangChain """ -import argparse import os +import sys +import argparse +from pathlib import Path -from .bot import FAQBot +from chromadb import Client +from chromadb.config import Settings + +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.llms.openai import OpenAIChat +from langchain.chains import RetrievalQA +from langchain.vectorstores import Chroma + +from ingest import ingest_faq +from retriever import get_answer + +def init_chroma(collection_name: str) -> Client: + """ + Initialize a ChromaDB client and create a collection if it does not exist. + """ + client = Client(Settings( + 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(): parser = argparse.ArgumentParser(description="FAQ Bot CLI") - parser.add_argument( - "--persist-dir", - type=str, - default="chromadb_persist", - help="Directory to persist ChromaDB data", - ) - parser.add_argument( - "--openai-key", - type=str, - default=os.getenv("OPENAI_API_KEY"), - help="OpenAI API key (optional)", - ) + subparsers = parser.add_subparsers(dest="command", required=True) + + ingest_parser = subparsers.add_parser("ingest", help="Ingest FAQ file into ChromaDB") + ingest_parser.add_argument("faq_file", type=Path, help="Path to FAQ text file") + ingest_parser.add_argument("--collection", type=str, default="faq_collection", help="Chroma collection name") + + query_parser = subparsers.add_parser("ask", help="Ask a question to the FAQ bot") + query_parser.add_argument("question", type=str, help="Your question") + query_parser.add_argument("--collection", type=str, default="faq_collection", help="Chroma collection name") + args = parser.parse_args() - bot = FAQBot(persist_dir=args.persist_dir, openai_api_key=args.openai_key) + # Ensure OpenAI API key is set + if "OPENAI_API_KEY" not in os.environ: + print("Error: OPENAI_API_KEY environment variable not set.", file=sys.stderr) + sys.exit(1) - print("FAQ Bot is ready. Type your question (Ctrl+C to exit).") - while True: - try: - question = input("\n> ") - if not question.strip(): - continue - answer = bot.ask(question) - print(f"\nAnswer: {answer}") - except (KeyboardInterrupt, EOFError): - print("\nGoodbye!") - break + 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__": main() \ No newline at end of file diff --git a/src/retriever.py b/src/retriever.py new file mode 100644 index 0000000..b98d393 --- /dev/null +++ b/src/retriever.py @@ -0,0 +1,42 @@ +""" +Retrieval and answer generation logic using LangChain. +""" + +import os +from typing import Any + +from chromadb import Client +from chromadb.config import Settings + +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.llms.openai import OpenAIChat +from langchain.chains import RetrievalQA +from langchain.vectorstores import Chroma + +def get_answer(question: str, client: Client, collection_name: str, k: int = 3) -> str: + """ + Retrieve relevant FAQ chunks and generate an answer using OpenAIChat. + """ + # Set up embeddings and LLM + embedding = OpenAIEmbeddings() + llm = OpenAIChat(temperature=0) + + # Load vector store + vectorstore = Chroma( + client=client, + collection_name=collection_name, + embedding_function=embedding + ) + + # Build RetrievalQA chain + qa_chain = RetrievalQA.from_chain_type( + llm=llm, + chain_type="stuff", + retriever=vectorstore.as_retriever(search_kwargs={"k": k}), + return_source_documents=True + ) + + # Run chain + result = qa_chain({"question": question}) + answer = result.get("answer", "") + return answer.strip() \ No newline at end of file diff --git a/tests/test_ingest.py b/tests/test_ingest.py new file mode 100644 index 0000000..dbd0aaa --- /dev/null +++ b/tests/test_ingest.py @@ -0,0 +1,44 @@ +import os +import tempfile +import shutil +import json +from pathlib import Path + +import chromadb +from chromadb.config import Settings + +from src.ingest import ingest_faq + +def test_ingest_faq(tmp_path): + # Create a temporary FAQ file + faq_content = """Q: What is Python? +A: Python is a programming language. + +Q: What is ChromaDB? +A: ChromaDB is a vector database.""" + faq_file = tmp_path / "faq.txt" + faq_file.write_text(faq_content, encoding="utf-8") + + # Initialize a temporary ChromaDB client + db_dir = tmp_path / "chromadb" + client = chromadb.Client(Settings( + chroma_db_impl="duckdb+parquet", + persist_directory=str(db_dir) + )) + + collection_name = "test_collection" + + # Ingest + ingest_faq(faq_file, client, collection_name) + + # Verify collection exists and has documents + collection = client.get_collection(name=collection_name) + assert collection.count() == 2 + + # Verify metadata + docs = collection.get(ids=["0", "1"]) + assert docs["metadatas"][0]["question"] == "What is Python?" + assert docs["metadatas"][1]["answer"] == "ChromaDB is a vector database." + + # Clean up + shutil.rmtree(db_dir) \ No newline at end of file diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py new file mode 100644 index 0000000..8a5430a --- /dev/null +++ b/tests/test_retrieval.py @@ -0,0 +1,51 @@ +import os +import tempfile +import shutil +import json +from pathlib import Path + +import chromadb +from chromadb.config import Settings + +from src.ingest import ingest_faq +from src.retriever import get_answer + +def test_retrieval(tmp_path, monkeypatch): + # Mock OpenAI API key + monkeypatch.setenv("OPENAI_API_KEY", "test_key") + + # Create a temporary FAQ file + faq_content = """Q: What is Python? +A: Python is a programming language. + +Q: What is ChromaDB? +A: ChromaDB is a vector database.""" + faq_file = tmp_path / "faq.txt" + faq_file.write_text(faq_content, encoding="utf-8") + + # Initialize a temporary ChromaDB client + db_dir = tmp_path / "chromadb" + client = chromadb.Client(Settings( + chroma_db_impl="duckdb+parquet", + persist_directory=str(db_dir) + )) + + collection_name = "test_collection" + + # Ingest + ingest_faq(faq_file, client, collection_name) + + # Mock OpenAIChat to avoid real API calls + class DummyLLM: + def __call__(self, *args, **kwargs): + return "Dummy answer" + + # Patch the LLM in retriever + monkeypatch.setattr("src.retriever.OpenAIChat", DummyLLM) + + # Retrieve answer + answer = get_answer("What is Python?", client, collection_name) + assert answer == "Dummy answer" + + # Clean up + shutil.rmtree(db_dir) \ No newline at end of file