Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 273 additions & 0 deletions pubmed/scripts/answer_questions_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
"""Answer questions: vector search → expand via references → pick closest refs.

For each question:
1. Vector-search top-k documents (default 10)
2. For each hit, get its references (pmid→pmcid), fetch their embeddings from Cosmos
3. Pick the ref_k references closest to the query vector (default 2)
4. Combine: up to k + k*ref_k documents (e.g. 10 + 20 = 30)
5. Answer with LLM

Usage:
python answer_questions_ref.py --config config.pubmed.yaml \
--questions complex_questions.json --output answers_refs.json \
--top 10 --ref-k 2
"""

import argparse, csv, json, logging, sys, time

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import: sys is imported but never used. Please remove it to keep the script clean (and to satisfy linters if enabled).

Suggested change
import argparse, csv, json, logging, sys, time
import argparse, csv, json, logging, time

Copilot uses AI. Check for mistakes.
import numpy as np
import yaml
from azure.cosmos import CosmosClient
from azure.identity import AzureCliCredential, get_bearer_token_provider
from openai import AzureOpenAI
from tqdm import tqdm

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
for n in ("azure.core.pipeline.policies.http_logging_policy", "azure.identity", "azure.cosmos"):
logging.getLogger(n).setLevel(logging.WARNING)


def load_pmid_to_pmcid(csv_path: str) -> dict[str, str]:
mapping = {}
with open(csv_path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f, quotechar='"'):
pmcid = (row.get("PMCID") or "").strip()
pmid = (row.get("PMID") or "").strip()
if pmcid and pmid:
mapping[pmid] = pmcid
return mapping


def get_embedding(text: str, embed_client: AzureOpenAI, model: str, dimensions: int, max_input_chars: int = 8000) -> list[float]:
return embed_client.embeddings.create(
input=text[:max_input_chars], model=model, dimensions=dimensions
).data[0].embedding


def vector_search(container, query_vector: list[float], top: int) -> list[dict]:
sql = """
SELECT TOP @top
c.id, c.pmcid, c.title, c.journal_title, c.abstract, c.pub_year,
c.doi, c.full_text, c.embedding, c.references,
VectorDistance(c.embedding, @embedding) AS similarity

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VectorDistance(...) is stored under the name similarity, but Cosmos returns a distance (lower is better). This is misleading in logs and in build_context where it’s printed as a similarity score. Rename to distance (or convert to an actual similarity) to avoid incorrect interpretation of retrieval quality.

Suggested change
VectorDistance(c.embedding, @embedding) AS similarity
VectorDistance(c.embedding, @embedding) AS distance

Copilot uses AI. Check for mistakes.
FROM c ORDER BY VectorDistance(c.embedding, @embedding)
"""
return list(container.query_items(
query=sql,
parameters=[{"name": "@embedding", "value": query_vector}, {"name": "@top", "value": top}],
enable_cross_partition_query=True,
))


def fetch_docs_by_pmcids(container, pmcids: list[str]) -> list[dict]:
if not pmcids:
return []
placeholders = ", ".join(f"@p{i}" for i in range(len(pmcids)))
sql = f"""
SELECT c.id, c.pmcid, c.title, c.journal_title, c.abstract, c.pub_year,
c.doi, c.full_text, c.embedding
FROM c WHERE c.pmcid IN ({placeholders})
"""
params = [{"name": f"@p{i}", "value": p} for i, p in enumerate(pmcids)]
return list(container.query_items(query=sql, parameters=params, enable_cross_partition_query=True))
Comment on lines +62 to +72

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch_docs_by_pmcids pulls full_text (and other large fields) for every referenced PMCID, even though the selection step only needs embeddings to compute similarity. This can dramatically increase RU consumption and latency. Consider fetching only pmcid + embedding for candidate refs, then fetching full metadata/full_text only for the selected ref_k docs.

Copilot uses AI. Check for mistakes.


def refs_to_pmcids(refs: list[dict], pmid_map: dict[str, str]) -> list[str]:
result = []
for r in refs:
pmcid = (r.get("pmcid") or "").strip()
if not pmcid:
pmid = (r.get("pmid") or "").strip()
pmcid = pmid_map.get(pmid, "")
if pmcid:
result.append(pmcid)
return result


def closest_select(docs: list[dict], query_vec: list[float], k: int) -> list[dict]:
"""Select k docs closest to query_vec by cosine similarity."""
if len(docs) <= k:
return docs
q = np.array(query_vec, dtype=np.float64)
q_norm = np.linalg.norm(q)
if q_norm < 1e-12:
return docs[:k]
q = q / q_norm
scored = []
for doc in docs:
emb = doc.get("embedding")
if emb and isinstance(emb, list) and len(emb) > 0:
v = np.array(emb, dtype=np.float64)
v_norm = np.linalg.norm(v)
sim = float(np.dot(q, v) / max(v_norm, 1e-12))
scored.append((sim, doc))
scored.sort(key=lambda x: x[0], reverse=True)
return [doc for _, doc in scored[:k]]


def build_context(docs: list[dict], max_chars: int = 230000) -> str:
parts, total = [], 0
for i, doc in enumerate(docs, 1):
title = doc.get("title", "(no title)")
pmcid = doc.get("pmcid", "?")
abstract = doc.get("abstract", "")
full_text = doc.get("full_text", "")
sim = doc.get("similarity")
sim_str = f" (similarity: {sim:.4f})" if sim is not None else ""
section = f"[{i}] PMCID: {pmcid}{sim_str}\nTitle: {title}\n"
if abstract:
section += f"Abstract: {abstract}\n"
if full_text:
remaining = max_chars - total - len(section) - 200
if remaining > 500:
section += f"Full text excerpt: {full_text[:6000][:remaining]}\n"
if total + len(section) > max_chars:
break
parts.append(section)
total += len(section)
return "\n".join(parts)


def answer_question(question: str, context: str, llm_client: AzureOpenAI,
model: str, system_prompt: str, temperature: float,
max_completion_tokens: int, max_retries: int) -> str:
prompt = f"""Based on the following retrieved PubMed articles, answer this question:

Question: {question}

Retrieved Articles:
{context}

Provide a comprehensive answer synthesizing the information from these articles."""
for attempt in range(max_retries):
try:
resp = llm_client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}],
temperature=temperature, max_completion_tokens=max_completion_tokens,
)
return resp.choices[0].message.content.strip()
except Exception as e:
log.warning(f"LLM error (attempt {attempt+1}): {e}")
if attempt < max_retries - 1:
time.sleep(min(5.0 * (2 ** attempt), 60))
else:
return f"Error: {e}"


def main():
pa = argparse.ArgumentParser(description="Answer questions: vector + reference expansion + closest selection")
pa.add_argument("--config", default="config.pubmed.yaml", help="Path to YAML config file")
pa.add_argument("--questions", default=None)
pa.add_argument("--output", default=None)
pa.add_argument("--top", "-k", type=int, default=None, help="Initial vector search docs")
pa.add_argument("--ref-k", type=int, default=None, help="Closest reference docs per hit")
args = pa.parse_args()

with open(args.config) as f:
cfg = yaml.safe_load(f)
cosmos_cfg = cfg.get("cosmos", {})
llm_cfg = cfg.get("llm", {})
embed_cfg = cfg.get("embedding", {})
answer_cfg = cfg.get("answer", {})

# Resolve parameters: CLI overrides > config > defaults
questions_path = args.questions or answer_cfg.get("questions", "questions.json")
output_path = args.output or answer_cfg.get("output", "answers_refs.json")
top_k = args.top if args.top is not None else int(answer_cfg.get("top", 10))
ref_k = args.ref_k if args.ref_k is not None else int(answer_cfg.get("ref_k", 2))

cosmos_uri = cosmos_cfg.get("uri", "")
database_name = cosmos_cfg.get("database_name", "pubmed")
container_name = cosmos_cfg.get("sources", [{}])[0].get("container_name", "articles")
llm_endpoint = llm_cfg.get("llm_endpoint", "")
llm_model = llm_cfg.get("llm_model", "gpt-5.4")
llm_api_version = llm_cfg.get("api_version", "2024-12-01-preview")
llm_token_scope = llm_cfg.get("token_scope", "https://cognitiveservices.azure.com/.default")
llm_temperature = float(llm_cfg.get("temperature", 0.0))
llm_max_tokens = int(llm_cfg.get("max_completion_tokens", 4096))
llm_max_retries = int(llm_cfg.get("max_retries", 5))
embed_endpoint = embed_cfg.get("embed_endpoint", "")
embed_api_key = embed_cfg.get("embed_api_key", "")
embed_api_version = embed_cfg.get("api_version", "2024-12-01-preview")
embed_model = embed_cfg.get("embed_model", "text-embedding-3-small")
embed_dims = int(embed_cfg.get("embed_dimensions", 1536))
embed_max_input_chars = int(embed_cfg.get("max_input_chars", 8000))
pmc_ids_csv = answer_cfg.get("pmc_ids_csv", "")
max_context_chars = int(answer_cfg.get("max_context_chars", 230000))
system_prompt = answer_cfg.get("system_prompt", "You are a biomedical research assistant.").strip()

Comment on lines +184 to +200

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

llm_model and token_scope are read with .get(...), so an empty string in config will override the intended defaults and later cause Azure OpenAI calls/token acquisition to fail (e.g., model="" or empty scope). Consider using llm_cfg.get(... ) or <default> and/or validating required settings (endpoint, deployment/model, token scope) early with a clear error.

Suggested change
llm_endpoint = llm_cfg.get("llm_endpoint", "")
llm_model = llm_cfg.get("llm_model", "gpt-5.4")
llm_api_version = llm_cfg.get("api_version", "2024-12-01-preview")
llm_token_scope = llm_cfg.get("token_scope", "https://cognitiveservices.azure.com/.default")
llm_temperature = float(llm_cfg.get("temperature", 0.0))
llm_max_tokens = int(llm_cfg.get("max_completion_tokens", 4096))
llm_max_retries = int(llm_cfg.get("max_retries", 5))
embed_endpoint = embed_cfg.get("embed_endpoint", "")
embed_api_key = embed_cfg.get("embed_api_key", "")
embed_api_version = embed_cfg.get("api_version", "2024-12-01-preview")
embed_model = embed_cfg.get("embed_model", "text-embedding-3-small")
embed_dims = int(embed_cfg.get("embed_dimensions", 1536))
embed_max_input_chars = int(embed_cfg.get("max_input_chars", 8000))
pmc_ids_csv = answer_cfg.get("pmc_ids_csv", "")
max_context_chars = int(answer_cfg.get("max_context_chars", 230000))
system_prompt = answer_cfg.get("system_prompt", "You are a biomedical research assistant.").strip()
llm_endpoint = (llm_cfg.get("llm_endpoint") or "").strip()
llm_model = llm_cfg.get("llm_model") or "gpt-5.4"
llm_api_version = llm_cfg.get("api_version") or "2024-12-01-preview"
llm_token_scope = llm_cfg.get("token_scope") or "https://cognitiveservices.azure.com/.default"
llm_temperature = float(llm_cfg.get("temperature", 0.0))
llm_max_tokens = int(llm_cfg.get("max_completion_tokens", 4096))
llm_max_retries = int(llm_cfg.get("max_retries", 5))
embed_endpoint = (embed_cfg.get("embed_endpoint") or "").strip()
embed_api_key = embed_cfg.get("embed_api_key", "")
embed_api_version = embed_cfg.get("api_version") or "2024-12-01-preview"
embed_model = embed_cfg.get("embed_model") or "text-embedding-3-small"
embed_dims = int(embed_cfg.get("embed_dimensions", 1536))
embed_max_input_chars = int(embed_cfg.get("max_input_chars", 8000))
pmc_ids_csv = answer_cfg.get("pmc_ids_csv", "")
max_context_chars = int(answer_cfg.get("max_context_chars", 230000))
system_prompt = (answer_cfg.get("system_prompt") or "You are a biomedical research assistant.").strip()
if not llm_endpoint:
raise ValueError("Missing required LLM configuration: llm_endpoint")
if not llm_model:
raise ValueError("Missing required LLM configuration: llm_model")
if not llm_token_scope:
raise ValueError("Missing required LLM configuration: token_scope")
if not embed_endpoint:
raise ValueError("Missing required embedding configuration: embed_endpoint")

Copilot uses AI. Check for mistakes.
with open(questions_path) as f:
raw = json.load(f)
questions = [item["question"] for item in raw] if raw and isinstance(raw[0], dict) else raw
log.info(f"Loaded {len(questions)} questions")

pmid_map = load_pmid_to_pmcid(pmc_ids_csv) if pmc_ids_csv else {}
log.info(f"Loaded {len(pmid_map)} PMID→PMCID mappings")

cred = AzureCliCredential()
container = CosmosClient(cosmos_uri, credential=cred) \
.get_database_client(database_name).get_container_client(container_name)
embed_client = AzureOpenAI(
azure_endpoint=embed_endpoint, api_key=embed_api_key, api_version=embed_api_version)
llm_client = AzureOpenAI(
Comment on lines +191 to +214

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The embedding client is always created with api_key=embed_api_key even though the repo config supports embedding.use_rbac_auth. This will break when RBAC is enabled (and embed_api_key is empty), and it’s inconsistent with how other code paths authenticate embeddings. Use azure_ad_token_provider when embedding.use_rbac_auth is true, otherwise require embed_api_key.

Copilot uses AI. Check for mistakes.
azure_endpoint=llm_endpoint,
azure_ad_token_provider=get_bearer_token_provider(cred, llm_token_scope),
api_version=llm_api_version)
log.info(f"Strategy: {top_k} vector docs, {ref_k} closest refs each → max {top_k + top_k * ref_k} docs")

results = []
for i, question in enumerate(tqdm(questions, desc="Processing")):
log.info(f"[{i+1}/{len(questions)}] {question[:80]}...")
query_vec = get_embedding(question, embed_client, embed_model, embed_dims, embed_max_input_chars)

# 1. Vector search
hits = vector_search(container, query_vec, top=top_k)
seen_pmcids = {d.get("pmcid") for d in hits}
log.info(f" Retrieved {len(hits)} hits")

# 2. For each hit, get reference PMCIDs, fetch them, pick ref_k closest to query
ref_docs_all = []
if ref_k > 0:
for doc in hits:
refs = doc.get("references") or []
ref_pmcids = [p for p in refs_to_pmcids(refs, pmid_map) if p not in seen_pmcids]
if not ref_pmcids:
continue
fetched = fetch_docs_by_pmcids(container, ref_pmcids)
if not fetched:
continue
selected = closest_select(fetched, query_vec, ref_k)
Comment on lines +230 to +241

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each of the top_k hits, this fetches all referenced PMCIDs from Cosmos before selecting ref_k closest. Since papers can have large reference lists, this can fan out into very large IN (...) queries and many RU-heavy reads per question. Consider capping the number of references considered per hit (or globally), deduplicating across hits before fetching, and/or adding a hard limit like max_ref_candidates in config.

Suggested change
# 2. For each hit, get reference PMCIDs, fetch them, pick ref_k closest to query
ref_docs_all = []
if ref_k > 0:
for doc in hits:
refs = doc.get("references") or []
ref_pmcids = [p for p in refs_to_pmcids(refs, pmid_map) if p not in seen_pmcids]
if not ref_pmcids:
continue
fetched = fetch_docs_by_pmcids(container, ref_pmcids)
if not fetched:
continue
selected = closest_select(fetched, query_vec, ref_k)
# 2. For each hit, get reference PMCIDs, fetch a bounded set, pick ref_k closest to query
ref_docs_all = []
fetched_ref_doc_cache = {}
max_ref_candidates_per_hit = max(ref_k * 10, ref_k)
if ref_k > 0:
for doc in hits:
refs = doc.get("references") or []
ref_pmcids = []
ref_pmcids_seen_for_hit = set()
for pmcid in refs_to_pmcids(refs, pmid_map):
if pmcid in seen_pmcids or pmcid in ref_pmcids_seen_for_hit:
continue
ref_pmcids_seen_for_hit.add(pmcid)
ref_pmcids.append(pmcid)
if len(ref_pmcids) >= max_ref_candidates_per_hit:
break
if not ref_pmcids:
continue
cached = [fetched_ref_doc_cache[pmcid] for pmcid in ref_pmcids if pmcid in fetched_ref_doc_cache]
missing_pmcids = [pmcid for pmcid in ref_pmcids if pmcid not in fetched_ref_doc_cache]
fetched = fetch_docs_by_pmcids(container, missing_pmcids) if missing_pmcids else []
for fetched_doc in fetched:
fetched_pmcid = fetched_doc.get("pmcid")
if fetched_pmcid:
fetched_ref_doc_cache[fetched_pmcid] = fetched_doc
candidates = cached + fetched
if not candidates:
continue
selected = closest_select(candidates, query_vec, ref_k)

Copilot uses AI. Check for mistakes.
for s in selected:
if s.get("pmcid") not in seen_pmcids:
seen_pmcids.add(s.get("pmcid"))
ref_docs_all.append(s)

all_docs = hits + ref_docs_all
log.info(f" Total docs: {len(hits)} hits + {len(ref_docs_all)} ref expansions = {len(all_docs)}")

# 3. Answer
context = build_context(all_docs, max_chars=max_context_chars)
answer = answer_question(question, context, llm_client,
model=llm_model, system_prompt=system_prompt,
temperature=llm_temperature,
max_completion_tokens=llm_max_tokens,
max_retries=llm_max_retries)

# 4. Collect
retrieved = [{"pmcid": d.get("pmcid"), "title": d.get("title"),
"journal": d.get("journal_title"), "year": d.get("pub_year"),
"similarity": d.get("similarity"), "source": "vector" if d in hits else "reference"}
for d in all_docs]
Comment on lines +259 to +262

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Determining source via "vector" if d in hits else "reference" relies on full dict equality and can misclassify documents if dict contents differ (or if a ref doc happens to compare equal). Track source explicitly (e.g., add a source field when constructing hits / ref_docs_all, or compare by a stable key like pmcid).

Copilot uses AI. Check for mistakes.
results.append({"question": question, "answer": answer, "retrieved_documents": retrieved,
"vector_count": len(hits), "ref_count": len(ref_docs_all)})

with open(output_path, "w") as f:
json.dump(results, f, indent=2, ensure_ascii=False)

log.info(f"Done. Saved {len(results)} answers to {output_path}")


if __name__ == "__main__":
main()
20 changes: 16 additions & 4 deletions pubmed/scripts/config.pubmed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ paths:
# ---------------------------------------------------------------------------
llm:
llm_endpoint: ""
api_version: "2024-05-01-preview"
llm_model: "gpt-5.4-mini"
api_version: "2024-12-01-preview"
llm_model: ""

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

llm.llm_model is set to an empty string here. Scripts that read this via .get("llm_model", <default>) will receive "" and fail when calling Azure OpenAI. Either provide a sensible default deployment name here, or keep it unset and add validation that errors clearly when it’s missing.

Suggested change
llm_model: ""
# Leave llm_model unset here so code that uses .get("llm_model", <default>)
# can fall back to its default deployment name, or validation can clearly
# report that the setting is missing.

Copilot uses AI. Check for mistakes.
temperature: 0.0
max_completion_tokens: 4096
max_retries: 2
max_retries: 5
premium_max_concurrency: 4
prompt_cache_size: 2048
use_rbac_auth: true
Expand All @@ -79,10 +79,11 @@ local_llm:
# ---------------------------------------------------------------------------
embedding:
embed_endpoint: ""
api_version: "2024-05-01-preview"
api_version: "2024-12-01-preview"
embed_model: "text-embedding-3-small"
embed_dimensions: 1536
embed_cache_size: 4096
max_input_chars: 8000
use_rbac_auth: true
embed_api_key: ""
token_scope: "https://cognitiveservices.azure.com/.default"
Expand Down Expand Up @@ -197,3 +198,14 @@ cosmos:
{ "path": "/full_text", "language": "en-US" }
]
}

# ---------------------------------------------------------------------------
# Answer generation settings
# ---------------------------------------------------------------------------
answer:
pmc_ids_csv: "data/PMC-ids.csv"

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

answer.pmc_ids_csv points to data/PMC-ids.csv, but that file doesn’t appear to exist in the repo. Since answer_questions_ref.py tries to open this path when it’s non-empty, the default config will cause a FileNotFoundError at runtime. Consider either committing the CSV, pointing to the correct tracked location, or setting the default to empty and documenting it as optional.

Suggested change
pmc_ids_csv: "data/PMC-ids.csv"
pmc_ids_csv: "" # Optional: set to a tracked/local PMCID mapping CSV when available

Copilot uses AI. Check for mistakes.
max_context_chars: 230000
system_prompt: |
You are a biomedical research assistant. You are given a question and a set of retrieved PubMed articles (title, abstract, and potentially full text excerpts).

Synthesize the information from these articles to provide a comprehensive, evidence-based answer to the question. Cite the papers by their PMCID when referencing specific findings. If the retrieved articles do not contain sufficient information to fully answer the question, state what is covered and what gaps remain.
Loading