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()