Spaces:
Running
Running
| import re | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| from tenacity import retry, wait_exponential | |
| from langchain_openai import ChatOpenAI, OpenAIEmbeddings | |
| from langchain_chroma import Chroma | |
| from langchain.prompts import ChatPromptTemplate | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| from langchain_community.document_loaders import DirectoryLoader, TextLoader | |
| # ================== CONFIG ================== | |
| load_dotenv(override=True) | |
| MODEL = "gpt-4.1-nano" | |
| COLLECTION_NAME = "docs" | |
| # Hugging Face persistent directory | |
| BASE_DIR = Path(__file__).resolve().parent | |
| KB_DIR = BASE_DIR / "knowledge-base" | |
| DB_DIR = Path("/data/chroma") | |
| RETRIEVAL_K = 20 | |
| FINAL_K = 10 | |
| MAX_HISTORY_TURNS = 6 | |
| wait = wait_exponential(multiplier=1, min=10, max=240) | |
| # ================== EMBEDDINGS ================== | |
| embedding_model = OpenAIEmbeddings(model="text-embedding-3-large") | |
| # ================== VECTOR STORE ================== | |
| def load_or_create_vectorstore() -> Chroma: | |
| if DB_DIR.exists(): | |
| vs = Chroma( | |
| persist_directory=str(DB_DIR), | |
| collection_name=COLLECTION_NAME, | |
| embedding_function=embedding_model, | |
| ) | |
| if vs._collection.count() > 0: | |
| print(f"✅ Loaded Chroma DB ({vs._collection.count()} chunks)") | |
| return vs | |
| print("⚙️ Building Chroma DB from knowledge-base...") | |
| loader = DirectoryLoader( | |
| path=str(KB_DIR), | |
| glob="**/*.md", | |
| loader_cls=TextLoader, | |
| loader_kwargs={"encoding": "utf-8"}, | |
| ) | |
| documents = loader.load() | |
| splitter = RecursiveCharacterTextSplitter( | |
| chunk_size=500, | |
| chunk_overlap=200, | |
| ) | |
| chunks = splitter.split_documents(documents) | |
| vs = Chroma.from_documents( | |
| documents=chunks, | |
| embedding=embedding_model, | |
| persist_directory=str(DB_DIR), | |
| collection_name=COLLECTION_NAME, | |
| ) | |
| print(f"✅ Chroma DB created ({len(chunks)} chunks)") | |
| return vs | |
| vectorstore = load_or_create_vectorstore() | |
| retriever = vectorstore.as_retriever(search_kwargs={"k": RETRIEVAL_K}) | |
| # ================== QUERY REWRITE ================== | |
| rewrite_prompt = ChatPromptTemplate.from_template(""" | |
| You are in a conversation with a user, answering questions about the Subject Data Structures and Algorithms. | |
| You are about to look up information in a Knowledge Base to answer the user's question. | |
| This is the history of your conversation so far with the user: | |
| {history} | |
| And this is the user's current question: | |
| {question} | |
| Respond only with a short, refined question that you will use to search the Knowledge Base. | |
| It should be a VERY short specific question most likely to surface content. | |
| IMPORTANT: Respond ONLY with the precise knowledgebase query, nothing else. | |
| Do not use question words like what, how, why. | |
| """) | |
| rewrite_llm = ChatOpenAI(model=MODEL) | |
| rewrite_chain = rewrite_prompt | rewrite_llm | |
| def rewrite_query(question: str, history: str = "") -> str: | |
| return rewrite_chain.invoke({ | |
| "question": question, | |
| "history": history | |
| }).content.strip() | |
| # ================== RETRIEVAL ================== | |
| def fetch_context_unranked(query: str): | |
| return retriever.invoke(query) | |
| def merge_chunks(chunks1, chunks2): | |
| seen = {doc.page_content for doc in chunks1} | |
| merged = list(chunks1) | |
| for doc in chunks2: | |
| if doc.page_content not in seen: | |
| merged.append(doc) | |
| return merged | |
| # ================== RERANKING ================== | |
| rerank_prompt = ChatPromptTemplate.from_template(""" | |
| You are a document re-ranker. | |
| You are provided with a question and a list of relevant chunks of text from a query of a knowledge base. | |
| The chunks are provided in the order they were retrieved. | |
| You must rank order the provided chunks by relevance to the question, with the most relevant chunk first. | |
| Reply only with the list of ranked chunk ids, nothing else. | |
| Question: | |
| {question} | |
| Chunks: | |
| {chunks} | |
| """) | |
| rerank_llm = ChatOpenAI(model=MODEL) | |
| rerank_chain = rerank_prompt | rerank_llm | |
| def rerank(question: str, chunks): | |
| formatted = "" | |
| for i, chunk in enumerate(chunks, 1): | |
| formatted += f"#Chunk {i}\n{chunk.page_content}\n\n" | |
| response = rerank_chain.invoke({ | |
| "question": question, | |
| "chunks": formatted | |
| }).content | |
| order = list(map(int, re.findall(r"\d+", response))) | |
| return [chunks[i - 1] for i in order if 1 <= i <= len(chunks)] | |
| def fetch_context(question: str, history: str = ""): | |
| rewritten = rewrite_query(question, history) | |
| c1 = fetch_context_unranked(question) | |
| c2 = fetch_context_unranked(rewritten) | |
| merged = merge_chunks(c1, c2) | |
| ranked = rerank(question, merged) | |
| return ranked[:FINAL_K] | |
| # ================== ANSWER GENERATION ================== | |
| SYSTEM_PROMPT = """ | |
| You are a knowledgeable, friendly assistant representing the the company called BlinkNow which helps to answer the questions related to Data Structures and Algorithms. | |
| You are chatting with a user about contents of the Subject Data Structures and Algorithms. | |
| Conversation so far: | |
| {history} | |
| Your answer will be evaluated for accuracy, relevance and completeness, so make sure it only answers the question and fully answers it. | |
| If you don't know the answer, say so. | |
| For context, here are specific extracts from the Knowledge Base that might be directly relevant to the user's question: | |
| {context} | |
| With this context, please answer the user's question. Be accurate, relevant and complete. | |
| """ | |
| answer_prompt = ChatPromptTemplate.from_messages([ | |
| ("system", SYSTEM_PROMPT), | |
| ("human", "{question}") | |
| ]) | |
| answer_llm = ChatOpenAI(model=MODEL) | |
| answer_chain = answer_prompt | answer_llm | |
| def answer_question(question: str, history: str = ""): | |
| # ---- Trim history to avoid token explosion ---- | |
| history_lines = history.strip().split("\n") if history else [] | |
| history = "\n".join(history_lines[-MAX_HISTORY_TURNS * 2:]) | |
| # ---- Retrieve context with history awareness ---- | |
| chunks = fetch_context(question, history) | |
| context = "\n\n".join(doc.page_content for doc in chunks) | |
| response = answer_chain.invoke({ | |
| "question": question, | |
| "context": context, | |
| "history": history | |
| }) | |
| return response.content, chunks | |