File size: 6,439 Bytes
20b15f3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | """
qa.py
-----
Interactive Q&A over a single research paper. Handles vectorization
automatically -- just point it at a PDF:
python qa.py /path/to/paper.pdf
First run on a given PDF builds the vectorstore (may take a moment while the
embedding model loads); every run after that loads the cached vectorstore
instantly since it's keyed by the PDF's content hash.
Requires a Groq API key. Create a .env file in this same directory containing:
GROQ_API_KEY=your-key-here
Install once (on top of vectorize.py's requirements):
pip install --break-system-packages langchain langchain-community \
langchain-classic langchain-groq python-dotenv
Note: LangChain 1.0+ split ContextualCompressionRetriever and
CrossEncoderReranker out of the core `langchain` package into the new
`langchain-classic` package -- that's why langchain-classic is required above.
Swap LLM providers: replace get_llm() below with e.g. ChatOpenAI or
ChatAnthropic if you'd rather not use Groq.
"""
import os
import sys
from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain_classic.retrievers.document_compressors import CrossEncoderReranker
from langchain_classic.retrievers.contextual_compression import ContextualCompressionRetriever
from vectorizeer import build_vectorstore
load_dotenv() # reads GROQ_API_KEY from a .env file in the current directory
QA_SYSTEM_PROMPT = """You are a research assistant helping someone deeply understand a specific paper.
Treat every question as a chance to teach, not just retrieve -- they want both
the facts and why those facts matter.
Ground every answer strictly in the excerpts below. Do not use outside knowledge,
and do not fill gaps with what a similar paper would typically say. If the
excerpts don't contain the answer, say so plainly rather than guessing.
When you answer:
- Be comprehensive: explain the relevant method, result, or claim fully rather
than a one-line summary. If the question touches a mechanism (an algorithm, a
fine-tuning task, an experimental setup), walk through how it actually works,
not just what it's called.
- Surface significance: don't just report what the paper found -- explain why
it matters. What problem does it solve, what breaks without it, how does it
compare to prior approaches, what does it enable going forward.
- Stay accessible: write for someone smart but not necessarily a specialist in
this exact subfield. Define acronyms and technical terms the first time you
use them, and prefer plain language wherever it loses no precision.
- Cite as you go: every claim should be traceable to a page number from the
tagged excerpts below. Weave citations naturally into the explanation rather
than listing them at the end.
- Stay focused: comprehensive isn't the same as padded. Cut anything that
doesn't help the reader actually understand the paper better.
EXCERPTS:
{context}
"""
def get_llm():
api_key = os.environ.get("SECOND_GROQ_API_KEY")
if not api_key:
raise EnvironmentError(
"GROQ_API_KEY not found. Add it to a .env file in this directory "
"(GROQ_API_KEY=your-key-here) -- load_dotenv() picks it up automatically."
)
return ChatGroq(model="llama-3.3-70b-versatile", temperature=0)
def get_retriever(vectorstore, k: int = 15, top_n: int = 5, device: str = "cpu"):
"""Retrieve k candidates by similarity, then rerank down to the best top_n
with a small cross-encoder -- this is the single biggest accuracy lever
on top of the chunking itself.
device is pinned to "cpu" by default rather than left to auto-detect. The
retriever outlives any single call, so on HF ZeroGPU -- where a GPU exists
only inside an @spaces.GPU window -- a cuda-resident cross-encoder here would
fail the moment it's used. Reranking 15 short chunks is ~a second on CPU."""
base_retriever = vectorstore.as_retriever(search_kwargs={"k": k})
reranker_model = HuggingFaceCrossEncoder(
model_name="BAAI/bge-reranker-base",
model_kwargs={"device": device},
)
compressor = CrossEncoderReranker(model=reranker_model, top_n=top_n)
return ContextualCompressionRetriever(base_compressor=compressor, base_retriever=base_retriever)
def format_docs(docs) -> str:
parts = []
for d in docs:
tag = f"[Section: {d.metadata.get('section', '?')} | Page: {d.metadata.get('page', '?')}]"
parts.append(f"{tag}\n{d.page_content}")
return "\n\n---\n\n".join(parts)
def build_chain(llm):
prompt = ChatPromptTemplate.from_messages([
("system", QA_SYSTEM_PROMPT),
MessagesPlaceholder("chat_history"),
("human", "{question}"),
])
return prompt | llm
def main():
if len(sys.argv) < 2:
print("Usage: python qa.py /path/to/paper.pdf")
sys.exit(1)
pdf_path = sys.argv[1]
if not os.path.isfile(pdf_path):
print(f"File not found: {pdf_path}")
sys.exit(1)
vectorstore = build_vectorstore(pdf_path)
retriever = get_retriever(vectorstore)
llm = get_llm()
chain = build_chain(llm)
chat_history = []
print("\nReady. Ask questions about the paper (type 'exit' to quit).\n")
while True:
try:
question = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
break
if question.lower() in ("exit", "quit"):
break
if not question:
continue
docs = retriever.invoke(question)
context = format_docs(docs)
print("\nAssistant: ", end="", flush=True)
answer = ""
for chunk in chain.stream({
"question": question,
"chat_history": chat_history,
"context": context,
}):
token = chunk.content
if token:
print(token, end="", flush=True)
answer += token
print("\n")
sources = sorted(set(f"p.{d.metadata.get('page')}" for d in docs), key=lambda s: s)
print(f"[sources: {', '.join(sources)}]\n")
chat_history.append(HumanMessage(content=question))
chat_history.append(AIMessage(content=answer))
if __name__ == "__main__":
main() |