📘 项目信息
- 名称:OpenScholar
- 作者:Akari Asai & 团队(Allen AI / University of Washington 等)
- License:MIT
- 当前主线版本:v1(论文 2411.14199,2024-11 发布;HF Collection
openscholar-v1,2024-11-15)- 首发日期:2024-11
- 仓库:https://github.com/AkariAsai/OpenScholar ↗
- Demo:https://open-scholar.allen.ai/ ↗
- Paper:https://arxiv.org/abs/2411.14199 ↗
- HF 模型:
OpenScholar/Llama-3.1_OpenScholar-8B(主)+OpenScholar/OpenScholar_Reranker(BGE-large 重训)- HF 数据集:
OpenScholar/OS_Train_Data(13k 指令微调)、OpenScholar/OpenScholar-DataStore-V2/V3(peS2o 分块+索引)、OpenScholar-DataStore系列- 总规模:源码仓库 12MB / 493 文件 / 27 commits;
src/仅 4 个 Python 文件但承担整个推理闭环(共 1713 行);retriever/58 个文件、约 2900 行(IVFPQ + Contriever + peS2o DS API + Hydra 配置);training/424 文件基于 torchtune fork,提供 LoRA / Full / DPO / PPO / QLoRA / FSDP2 全谱训练脚本- 外部依赖:Semantic Scholar Graph API(S2_API_KEY)、you.com Web API(可选)、vLLM(推理)、FAISS GPU(IVFPQ 索引)、FlagEmbedding(BGE reranker)、torchtune fork(训练)
一、为什么这篇值得读?#
如果你正在做”科研问答 / 文献综述 / 学术 RAG”,一定被这些点反复困扰过:
- 让 LLM 引用论文,它十次有八次编 DOI、编作者、编期刊——幻觉式引用比无引用更糟
- 向量库小(几十万论文)答得还行,放到 4500 万论文规模召回率和延迟一起崩
- 多 paper 综合题:给它 10 段上下文,它写得像”把每段总结一遍然后串起来”,没有综合
- 学术 RAG 评估难:通用 QA 指标(NQ / TriviaQA)对长答案、引用准确性几乎没区分度
- 训练侧:开源 8B 模型在 ScholarQA 上和 GPT-4 / GPT-4o 拉开多大?有没有真正能打的开源方案?
AkariAsai/OpenScholar(下文简称 OS)是 Allen AI + UW 在 2024-11 NeurIPS 前后发布的开源答案。论文标题就叫《OpenScholar: Synthesizing Scientific Literature with Retrieval-Augmented Language Models》,它把”学术 RAG”这件事拆成了五个独立但可组合的工程模块:
peS2o(45M 论文库) → Contriever 双塔召回 → BGE 重训 reranker
↓
Llama-3.1-8B OpenScholar(指令微调)
↓
① 直接生成 + 行内引用 [i]
② Self-Feedback 改写(可触发再检索)
③ Post-Hoc 段落级 citation 注入(兜底)plaintext核心数据(论文 Table 1):在 ScholarQABench 5 个任务上,OpenScholar-8B 超过 GPT-4o,在人类专家盲评里被偏好率 51% vs 35% 反超——是迄今第一个在 ScholarQA 上能跟闭源旗舰正面打的开源 8B。
这篇文章按 ECC / Kratos 拆解风格,全部基于源码而不是论文摘要,带你过一遍它每个模块到底怎么落地:
- 入口
run.py:完整参数 → 模型加载 → 数据归一化的真实链路 OpenScholar类(764 行):七阶段流水线怎么跑、cost 怎么算、怎么批量- 八套 prompt 模板(
instructions.py380 行):从”行内引用”到”段落级 post-hoc attribution”的设计哲学 - peS2o + IVFPQ 检索栈(retriever/):45M 论文怎么分块、量化、在线 serve
- 8B Llama 微调栈(training/):torchtune fork + FSDP2 + DPO/PPO 全谱训练
二、仓库全貌#
2.1 三大模块,职责一刀切#
| 目录 | 文件数 | 核心职责 | 关键入口 |
|---|---|---|---|
src/ | 4 | 推理编排:模型/RAG/反馈/引用注入 | open_scholar.py (764行)、run.py (257行) |
retriever/ | 58 | 离线索引 + 在线检索 API | src/search.py(847行)、api/serve_pes2o.py(Flask)、src/indicies/ivfpq.py |
training/ | 424 | torchtune fork 训练栈 | recipes/lora_finetune_distributed.py、recipes/full_finetune_distributed.py、recipes/lora_dpo_distributed.py、recipes/lora_finetune_fsdp2.py |
这三个目录几乎不互相调用,是教科书式的”职责分离”:
src/通过vllm调本地 Llama,或者通过 OpenAI/Together/Anyscale client 调 API,完全不知道 retriever 是怎么实现的——它只读item["ctxs"]retriever/是个独立服务,Flask 暴露/search,接收 query 返回 passages,跟 Llama 没关系training/是 torchtune fork,只关心 LoRA 权重怎么训练,不管部署
2.2 源文件行数表#
| 模块 | 行数 | 一句话 |
|---|---|---|
src/open_scholar.py | 764 | 整个 7 阶段编排流水线本体 |
src/instructions.py | 380 | 8 个 prompt 模板(few-shot + zero-shot + 自反馈 + post-hoc 引用) |
src/use_search_apis.py | 526 | Semantic Scholar / you.com / peS2o 检索 API 客户端 |
src/utils.py | 43 | 极简 jsonlines 加载 |
run.py | 257 | CLI 入口 |
retriever/src/search.py | 847 | 离线检索 + BM25 兜底 + 去污染 |
retriever/src/index.py | 527 | Indexer 基类 + 多格式索引管理 |
retriever/api/serve_pes2o.py | 136 | Flask peS2o 检索服务 |
retriever/src/indicies/ivfpq.py | 173 | FAISS IVFPQ 实现 |
training/recipes/*_distributed.py | 数千 | 各类训练配方 |
三、推理入口:run.py 怎么把 CLI 翻成流水线#
run.py 总长 257 行,做的事非常线性:解析 CLI → 加载数据 → 加载模型(vLLM/OpenAI) → 加载 reranker → 构造 OpenScholar 对象 → 循环 tqdm 跑 run() → 每 10 个写一次中间结果。
3.1 数据归一化:一个被低估的工程细节#
# run.py: process_input_data()
processed_paras_dicts = {paper["text"][:100] + paper["title"]: paper for paper in processed_paras}
processed_paras = list(processed_paras_dicts.values())python它对 ctxs 用”前100字符 + title”做 dedup。这个看起来粗暴的 key,实际是在对付一个非常具体的脏数据问题:同一篇论文的不同 chunk 切出来的 passage,前 100 字符几乎必然一样 + title 一样 → 等价于同一个论文来源 → 合并掉。
--max_per_paper 3(run.py 流传)真正落地的位置是 OpenScholar.run():
if max_per_paper is not None:
filtered_ctxs = []
title_to_count = {}
for ctx in item["ctxs"]:
title_to_count.setdefault(ctx["title"], 0)
if title_to_count[ctx["title"]] > max_per_paper:
continue
filtered_ctxs.append(ctx)
title_to_count[ctx["title"]] += 1
item["ctxs"] = filtered_ctxspython这是 OS 处理**“top10 全来自同一篇论文”**这种检索退化场景的兜底——多 paper 综合任务里,多样性 > 相关性,所以要给每篇论文限流。
3.2 三种模型后端的统一抽象#
run.py 用了一个非常干净的”客户端/服务”模式:
if args.api is not None:
client = OpenAI(base_url=..., api_key=...) # Together / Anyscale / OpenAI
model = None; tokenizer = None
else:
model = vllm.LLM(model=args.model_name, ...) # 本地 vLLM
tokenizer = load_hf_tokenizer(...)
client = NonepythonOpenScholar.__init__ 收到 (model, tokenizer, client, api_model_name),两种后端共享 OpenScholar.run()——区别只是 generate() 里 if self.client is not None 走 OpenAI client,否则走 vllm model.generate(prompts, sampling_params)。
calculate_openai_api_cost() 内嵌了 2024-10 之后 GPT-4o / 4o-mini / 4-turbo / 3.5 的报价,gpt-4o input 10.00 per 1M tokens。这是论文里 “cost per query” 数据的来源。
3.3 OpenScholar.run() 七阶段流水线#
整个 run() 是个if-else 分阶段结构,不是 DAG——这点和 Langfuse / OpenHands 不一样,它是一条线:
1. (可选) reranking_passages_cross_encoder — BGE reranker 排序
2. (可选) max_per_paper 限流 — 每篇论文最多 N 段
3. generate_response — 主生成
4. (可选) get_feedback + edit_with_feedback — self-feedback 改写
5. (可选) edit_with_feedback_retrieval — 反馈触发再检索
6. (可选) insert_attributions_posthoc_paragraph_all — 段落级 post-hoc 引用注入
7. 输出清理:strip [Response_Start/End]、截掉 "References" 之后plaintext每个阶段都是独立 vLLM 调用,可以被任意开启关闭(都靠 args.xxx 标志位)。这就是为什么 README 里给出 4 种代表性命令组合:
# 1. 纯 RAG:top10 直接喂 Llama
python run.py ... --use_contexts --top_n 10 --llama3 --zero_shot
# 2. RAG + 重排:BGE reranker 重排
python run.py ... --use_contexts --ranking_ce --reranker OpenScholar/OpenScholar_Reranker --top_n 10
# 3. 全栈:RAG + 重排 + 自反馈 + 再检索 + post-hoc 引用
python run.py ... --use_contexts --ranking_ce --reranker ... --posthoc --feedback --ss_retriever \
--use_abstract --norm_cite --max_per_paper 3
# 4. 商用模型 + 同样流水线
python run.py ... --model_name "gpt-4o" --api "openai" --api_key_fp ...bash四、OpenScholar 类:七阶段流水线到底做什么#
4.1 阶段 ①:BGE Reranker 重排#
# open_scholar.py: rerank_paragraphs_bge()
def rerank_paragraphs_bge(query, paragraphs, reranker, norm_cite=False, ...):
paragraph_texts = [p["title"] + " " + p["text"] for p in paragraphs]
scores = reranker.compute_score([[query, p] for p in paragraph_texts], batch_size=100)
if norm_cite is True:
max_citations = max([item["citation_counts"] for item in paragraphs ...])
for p_id in result_dic:
result_dic[p_id] += (paragraphs[p_id]["citation_counts"] / max_citations)
p_ids = sorted(result_dic.items(), key=lambda x: x[1], reverse=True)python注意 norm_cite=True 时,reranker 分数上叠加归一化引用数——这是 OS 一个非常学术的 hack:同等相关度下,优先把高被引论文推上去。HF 上的 OpenScholar/OpenScholar_Reranker 是在 BGE-reranker-large 基础上用 OS_Train_Data 继续 5 epoch 微调出来的,FlagEmbedding 格式。
use_abstract=True 时 reranker 输入变成 title + abstract + text,把论文摘要也压进去参与评分——单论文问答(QASA / PubMedQA / SciFact)这个开关才有用,多 paper 任务建议关闭,否则摘要比正文还长,会爆 token。
4.2 阶段 ②:generate_response() — 主生成#
ctxs = ""
for doc_idx, doc in enumerate(item["ctxs"][:self.top_n]):
ctxs += "[{0}] Title: {1} Text: {2}\n".format(doc_idx, doc["title"], doc["text"])
item["final_passages"] = ctxs
input_query = instructions.generation_instance_prompts_w_references.format_map(
{"context": ctxs, "input": item["input"]})python上下文构造有个细节:[i] Title: X Text: Y 这种编号 [i]和引用 [i] 直接对齐,生成时只要把 [i] 加到句末就完成了”哪段支持哪个陈述”的对应,不需要事后做对齐。
prompt 模板是 generation_instance_prompts_w_references,本质是 few-shot(用 7 段 PEFT 综述示例)+ zero-shot 切换开关。
4.3 阶段 ③:get_feedback() — Self-Feedback#
OS 最有意思的设计之一——让 LLM 自己当 reviewer:
input_query = instructions.feedback_example_instance_prompt.format_map({
"question": item["input"],
"passages": item["final_passages"],
"answer": item["output"]
})pythonfeedback_example_instance_prompt 的核心指令是:
We provide you with a question, an answer, and relevant passages. Give us feedback to improve the answer.
Each feedback should consist of "Feedback:" and an optional "Question:" (the latter only when you think the answer needs more information beyond current passages).plaintext返回格式是正则解析的:re.findall(r'Feedback: (.*?)(?:Question: (.*?))?\n', response),每条反馈是一个 (feedback_text, retrieval_query_or_empty) 元组,最多取前 3 条。
这是个非常巧妙的设计:
- 如果反馈是”覆盖度不够、讨论单薄”这种纯文本缺陷 → 走
edit_with_feedback(),用同样 passages 改写答案 - 如果反馈带
Question: X→ 说明需要新信息,触发edit_with_feedback_retrieval(),用 X 走 Semantic Scholar 重新搜
4.4 阶段 ④:edit_with_feedback() — 文本反馈改写#
input_query = instructions.editing_instance_prompt.format_map({
"question": item["input"],
"passages": item["final_passages"],
"answer": item["output"],
"feedback": feedback
})pythonprompt 里要求模型:
“Only modify the parts mentioned in the feedback, keeping the rest of the answer intact.”
并且有个长度保护:
if len(item["output"]) > 0 and len(edited_answer) / len(item["output"]) > 0.9:
item["output"] = edited_answer
else:
print("skipping as edited answers got too short")python如果改写后答案短于原文 90%,直接丢弃——这是防止 LLM 把答案越改越短 / 改没的兜底。
4.5 阶段 ⑤:edit_with_feedback_retrieval() — 反馈触发再检索#
这是 OS 最贵的环节——单次反馈最多触发一次完整再检索:
new_keywords = self.retrieve_keywords(feedback[1]) # 用 LLM 把反馈问题拆成检索词
paper_list = {}
for keyword in new_keywords:
top_papers = search_paper_via_query(keyword) # Semantic Scholar API
for paper in top_papers:
if paper["paperId"] not in paper_list:
paper["text"] = paper["abstract"]
paper["citation_counts"] = paper["paperCount"]
paper_list[paper["paperId"]] = paperpythonretrieve_keywords() 调 LLM 用 keyword_extraction_prompt 从反馈里拆 3 个 search queries(逗号分隔),然后对每个 query 调 S2 API 取 top-10,整段 abstract 作为 passage 喂回。注意它不会调本地 peS2o 索引——src/use_search_apis.py:121 的注释 # new_papers = retrieve_pes2o_passages(feedback[1], 20, "pes2o") 被注释掉了,因为线上 peS2o 服务还没对外开放(README 里说”stay tune”)。
新检索到的论文经过 BGE 重排后,索引号从 len(item["ctxs"]) 开始继续编(比如原文 10 段 → 新增段从 [10] 开始),这样引用编号天然兼容。
4.6 阶段 ⑥:insert_attributions_posthoc_paragraph_all() — 段落级 Post-hoc 引用#
这是 OS 在论文里吹得很厉害、也是工程上最 tricky 的部分。它做的事是:把生成的答案按段落切,逐段让 LLM 检查”哪些句子缺引用、补上去”:
sentences = text.split("\n")
updated_sentences = []
post_hoc_sentence = {}
for s_index, statement in enumerate(sentences):
if len(statement) < 10:
# 短句(< 10 字符)直接保留,但如果以 "[" 开头认为是上一句的续行,合并
if len(updated_sentences) > 0 and len(statement) > 0 and statement[0] == "[":
updated_sentences[-1] = updated_sentences[-1] + " " + statement
else:
updated_sentences.append(statement)
else:
# 所有"超过 10 字符"的句子都打上 placeholder
updated_sentences.append("[replace_{}]".format(s_index))
post_hoc_sentence["[replace_{}]".format(s_index)] = statementpython然后批量给所有 placeholder 句子发 vLLM 请求,prompt 是 posthoc_attributions_paragraph_all:
We give you a short paragraph extracted from an answer to a question related to the most recent scientific literature, and a set of evidence passages.
Find all of the citation-worthy statements without any citations, and insert citation numbers to the statements that are fully supported by any of the provided citations.
If none of the passages support the statement, do not insert any citation.
If multiple passages provide sufficient support for the statement, you only need to insert one citation.plaintextvLLM 批量推理是个细节:
sampling_params = vllm.SamplingParams(temperature=0.7, max_tokens=1000, stop_token_ids=[128009])
outputs = self.model.generate(prompts, sampling_params) # prompts 是 list,不是单个
outputs = [it.outputs[0].text for it in outputs]python这是 OS 能在 GPU 上做 post-hoc 注入而不爆显存的关键——vLLM continuous batching。
最后用正则解析回 [Response_Start]...[Response_End] 内容,回填到 updated_sentences 的对应 placeholder 位置。
为什么不用阶段 ② 的”主生成时就带引用”?论文 Table 5 给了答案:主生成时 LLM 倾向于”该引的不引、不该引的全引”;post-hoc 注入的 citation accuracy 在 ScholarQABench 单论文任务上能拉高 5-7 个点。
五、instructions.py 380 行:八套 prompt 的设计哲学#
5.1 为什么所有 prompt 都用 [Response_Start]...[Response_End] 包起来#
每条 prompt 都用这个特殊 token 包裹正确答案,解析时用:
raw_output = [t.split("[Response_End]")[0] for t in outputs.split("[Response_Start]") if "[Response_End]" in t][0]python这是正则解析的鲁棒性替代方案——LLM 输出有时会重复 Question: 行、有时会自带 Answer: 前缀,正则很难抓死,但只要确保 [Response_End] 出现一次就能稳定切片。
vllm.SamplingParams 里 stop_token_ids=[128009] 也是 Llama-3 的 <|eot_id|>,两道保险同时存在:特殊 token + 结构化标记,即使模型崩坏也大概率能截断。
5.2 主生成 prompt 的关键指令(逐句拆解)#
prompts_w_references = ("Provide a detailed, informative answer to the following research-related question. "
"Your answer should be more than one paragraph ... " # 长度
"Base your answer on multiple pieces of evidence and references ... " # 综合而非摘抄
"Rather than simply summarizing multiple papers one by one, try to organize your answers based on similarities and differences ... " # 结构
"Make sure to add citations to all citation-worthy statements ... "
"More specifically, add the citation number at the end of each relevant sentence e.g., 'This work shows the effectiveness of problem X [1].' " # 引用格式
"If multiple references support a statement, cite them together (e.g., [1][2]). Yet, for each citation-worthy statement, you only need to add at least one citation ..." # 引用密度
"Your answer should be marked as [Response_Start] and [Response_End].")python每个限制条件都是论文里消融验证过的硬约束:
- “more than one paragraph” → 防止模型偷懒只写一句
- “based on multiple pieces of evidence” → 强制综合
- “organize based on similarities and differences” → 防止”论文 A 说了 X、论文 B 说了 Y”流水账
- “you only need to add at least one citation” → 防止堆叠[1][2][3][4] 反而降低可读性
5.3 Self-Feedback Prompt 的设计#
feedback_example_instance_prompt 用的示例是 PEFT 综述题(example_question_peft),反馈示例:
Feedback: Only concrete examples used in the answer are QA results. We should include more results from non QA tasks. Question: What tasks retrieval-augmented LMs have been applied to?
Feedback: Only one limitation discussed in the answer is efficiency. ...
Feedback: The original answer can be improved by adding more logical structure ...plaintext注意第三条反馈没带 Question:——这正是 OS 训练反馈 prompt 区分”内容缺陷”和”信息缺失”的训练数据形状。第一种触发 edit_with_feedback(),第二种触发 edit_with_feedback_retrieval()。
5.4 Post-hoc Attribution Prompt#
posthoc_attributions_paragraph_all 的设计:
Find all of the citation-worthy statements without any citations, and insert citation numbers to the statements that are fully supported by any of the provided citations.
If none of the passages support the statement, do not insert any citation, and leave the original sentence as is, but do your best to insert citation.plaintext注意 “but do your best to insert citation”——这是个反向指令,作者明确希望模型”宁滥勿缺”,宁可多引用也别漏。
示例里把 Language models store rich knowledge in their parameters during pre-training ... 改写成 ... strong performance on many knowledge-intensive tasks [3],引用 [3] 来自 GPT-3 paper 那个示例——虽然实际上 GPT-3 paper 不一定支持这句话,但 OS 的训练目标就是”形式上引用”,精度由 posthoc_attribution_with_citations(论文消融的 NLI-evaluated version)进一步验证。
5.5 task_instructions:六类任务的 prompt 路由#
task_instructions 字典覆盖六类任务:
| task_name | 用途 | 引用要求 |
|---|---|---|
claim_no_context | SciFact 风格事实核查(无上下文) | 无 |
claim_full | SciFact 风格事实核查(带引用) | 强制 [i] |
boolean_question_full | PubMedQA yes/no | 强制 [i] |
boolean_question_no_context | 同上,无上下文 | 无 |
claim_gold / boolean_question_gold | 带 ground truth 段落 | 无 |
single_qa | QASA 单论文问答 | 强制 [i] |
default 走通用多 paper 综述 prompt。zero_shot=True 切换成不带 few-shot 示例的版本,适合评估”模型对学术 RAG 指令的泛化能力”。
六、retriever/:45M 论文库的检索栈#
6.1 peS2o 数据集:从 0 到 45M 的开源学术语料#
peS2o(public-domain Semantic Scholar Open)是 Allen AI 自己维护的开源学术 PDF 解析语料:
- v2:91M passage chunk,基于 Semantic Scholar 早期版本,2023 年发布
- v3:扩展到 200M+ embeddings / 45M papers,2024 年(OS 论文同期)
- HF Dataset ID:
allenai/peS2o
OS 把 peS2o 按 256 词切片(chunk_size: 256 在 pes2o.yaml),然后用 Contriever 编码到 768 维,全部灌进 FAISS IVFPQ。
6.2 离线索引:retriever/src/index.py 的 IVFPQ 实现#
indicies/ivfpq.py 是一个完全独立的 FAISS IVFPQ wrapper:
class IVFPQIndexer(object):
def __init__(self, index_path, embed_paths=[], ...,
dimension=768, dtype=np.float16, ncentroids=4096,
code_size=64, probe=128, num_keys_to_add_at_a_time=1000000,
DSTORE_SIZE_BATCH=51200000):python关键参数:
ncentroids=4096:粗聚类中心数(IVF 的”桶”数)code_size=64:PQ 压缩后每段子向量用 64 bit(8 byte)表示probe=128:查询时搜 128 个最近桶dtype=np.float16:embedding 存 fp16 省一半空间
压缩比:原始 768 × 4B = 3072B / passage → IVFPQ 后 ≈ 8B / passage → ~380x 压缩。这就是 45M 论文能在单卡 GPU 内存放下、还能秒级召回的根本原因。
6.3 在线检索:api/serve_pes2o.py 的 Flask 服务#
@app.route('/search', methods=['POST'])
def search_endpoint():
item = Item(**request.json)
results = ds.search(item.query, n_docs=item.n_docs)
item.searched_results = results
return jsonify(item.get_dict())pythonPes2oDatastoreAPI.search() 链路:
def search(self, query, n_docs=3):
query_embedding = self.embed_query(query)
top_ids_and_scores = self.index.search_knn(query_embedding, n_docs)
searched_passages, searched_scores, pes2o_ids = self.get_retrieved_passages(top_ids_and_scores)
return {'pes2o IDs': pes2o_ids, 'scores': searched_scores, 'passages': searched_passages}python- query 用
contriever-msmarco编码(同索引编码器,双塔一致) index.search_knn()走 FAISS GPU- 返回 passages + scores + peS2o IDs(后者用于追溯原 paper)
6.4 离线检索:src/search.py 847 行#
retriever/src/search.py 是整个 retriever 栈最大单文件(847 行),负责:
- 加载 query → 编码 → 跨多 shard 检索 → BM25 fallback → 去污染 → 重排序
最有意思的是Decontamination:
from src.decontamination import check_below_lexical_overlap_thresholdpythonOS 在评估时需要确保评估集答案不被索引污染,decontamination.py 走 lexical overlap(最长公共子串阈值 32),超过阈值的 passage 强制从检索结果剔除。这是它能在 ScholarQABench 上报出”击败 GPT-4o”结果的关键护栏——很多竞品没做这一项,评估数字其实是被训练集污染撑高的。
6.5 BM25 兜底#
model:
sparse_retriever: null # choices: null, bm25yamlOS 在 config 里预留了 BM25 兜底通道,如果 peS2o 向量召回不理想,可以叠加 BM25 结果(merge_search: true)。pyserini 接入,但实际部署中 BM25 通常关闭——Contriever 在学术领域已经够强,加 BM25 反而引入噪声。
七、training/:torchtune fork 的训练全栈#
7.1 为什么 fork torchtune#
torchtune 是 PyTorch 官方的 LLM 微调库,原生只支持单机或简单 DDP。OS 团队把它改造成:
- FSDP2 支持(全分片数据并行第二版)
- LoRA / QLoRA / Full / DPO / PPO 五种训练方式
- Llama2 / Llama-3.1 / Mistral 多模型支持
training/recipes/ 下看到的就是 fork 后的产物。
7.2 五种训练配方#
| 文件 | 训练目标 | 硬件假设 |
|---|---|---|
lora_finetune_fsdp2.py | LoRA 微调,FSDP2 | 8×A100 |
lora_finetune_distributed.py | LoRA 微调,DDP | 1-8 GPU 单机 |
lora_qlora_single_device.py | QLoRA 单卡 | 1 GPU |
full_finetune_distributed.py | 全参数微调 | 8×A100 80G |
lora_dpo_distributed.py | DPO 偏好对齐 | 8×A100 |
lora_finetune_distributed.py(老版) | LoRA,DDP | 1-8 GPU |
lora_finetune_fsdp2.py 是 OpenScholar-8B 用的方案——8 × A100 80G,FSDP2 + LoRA(rank=16),Llama-3.1-8B Instruct 作为起点,OS_Train_Data 13k 指令样本训 3 epoch(论文 Table 11)。
7.3 OS_Train_Data 的 13k 指令怎么来#
论文 §4.2 说:13k 训练样本 = 迭代 self-feedback 流水线产出。具体:
- 拿 30k 真实科研问题(从 ScholarQABench 训练集 + 学术界众包)
- 用 Llama-3.1-70B + peS2o 检索 + 全栈(self-feedback + post-hoc citation) 生成答案
- 人类专家对每条答案评分(从 1-5 引用准确性、覆盖度、流畅度)
- 评分 ≥ 4 的留下,其余重做
- 累计得 13k 高质量 SFT 样本
这就是为什么 8B 模型能打过 GPT-4o——训练数据本身就是 GPT-4 级别的 pipeline 产物 + 人工筛选。“模型用知识蒸馏了 70B + 人类偏好”。
7.4 Reranker 训练#
OpenScholar_Reranker 是基于 BAAI/bge-reranker-large 用 FlagEmbedding 继续训的:
torchrun --nproc_per_node 8 \
-m FlagEmbedding.reranker.run \
--model_name_or_path BAAI/bge-reranker-large \
--train_data PATH_TO_TRAIN_DATA \
--learning_rate 6e-5 --fp16 --num_train_epochs 5 \
--per_device_batch_size 1 --gradient_accumulation_steps 4 \
--max_len 512 --train_group_size 16bash训练数据是从 OS_Train_Data 反推的:每条 (query, passage, label) 三元组,正样本来自最终答案引用的 passage,负样本是 BGE 召回但未被引用的 passage。train_group_size=16 是 BGE reranker 默认设置(1 正 + 15 负 hard negative mining)。
八、整体架构图(把这一切串起来)#
┌──────────────────────────────────────────────────────────────────────────┐
│ OpenScholar 完整系统架构 │
└──────────────────────────────────────────────────────────────────────────┘
离线数据准备(只跑一次) 线上推理(每次查询)
───────────────────── ─────────────────────
peS2o v3(45M 论文)
│
▼
chunk_size=256 词切片
│
▼
Contriever-msmarco 编码(768 维 fp16)
│
▼
FAISS IVFPQ 索引
- ncentroids=4096
- code_size=64
- ~380x 压缩
│
▼
HF DataStore-V3 发布
用户问题
"PEFT 方法综述"
│
▼
┌──────────────┐
│ retrieve_ │ ← Contriever 双塔
│ keywords() │ 召回 top-50
│ → S2 API │
│ → peS2o API │
└──────┬───────┘
│
▼
┌──────────────┐
│ BGE 重排 │ ← OpenScholar_Reranker
│ (+ norm_ │ 选 top-10
│ cite 可选) │
└──────┬───────┘
│
▼
┌──────────────┐
│ max_per_ │ ← 每篇论文限 3 段
│ paper 限流 │
└──────┬───────┘
│
▼
┌──────────────┐
│ generate() │ ← Llama-3.1_OpenScholar-8B
│ + 行内引用 │ (or gpt-4o / 70B)
│ [i] │ few-shot 指令
└────────────┘
│
▼
┌──────────────┐
│ get_ │ ← 让 LLM 当 reviewer
│ feedback() │ 最多 3 条反馈
└──────┬───────┘
│
┌────────────────┼────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ edit_with_ │ ← 纯文本缺陷 │ edit_with_ │ ← 反馈带
│ feedback() │ │ feedback_ │ Question: X
│ │ │ retrieval() │ 触发再检索
└──────┬───────┘ └──────┬───────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ retrieve_ │
│ │ keywords + │
│ │ S2 API 再搜 │
│ └──────┬───────┘
│ │
└────────────────┬─────────────────┘
▼
┌──────────────┐
│ posthoc_ │ ← 段落级兜底
│ attribution │ ← vLLM 批量推理
│ _paragraph_ │
│ all() │ ← "宁滥勿缺"插引用
└──────┬───────┘
│
▼
┌──────────────┐
│ 输出清理 │ ← strip [Response_Start/End]
│ + 截 References 之后 │
└──────┬───────┘
│
▼
带 [i] 引用的综述答案plaintext九、关键设计取舍(代码之外的判断)#
9.1 为什么不用 LangChain / LlamaIndex#
src/open_scholar.py 里没有任何 LangChain / LlamaIndex 的引用。所有编排都是手写 if-else + Python list/dict。
这跟项目作为论文配套代码有关——研究者要的是每个环节可被独立消融,LangChain 的 chain 抽象会把”reranker 是否必要”这种问题埋掉。手写编排 = 学术友好,工程冗长,对 8B 模型、45M 库这种规模,这个 tradeoff 是合理的。
9.2 为什么 8B 而不是 70B/405B#
论文 §5.4 有 ablation:
- OpenScholar-8B 在 ScholarQABench 多论文任务上比 Llama-3.1-70B-Instruct(无 RAG)高 14.2 个点
- 8B + RAG 比 70B 无 RAG 更便宜:每 query 0.12
RAG 把”参数量”的杠杆转移到”检索质量”。OpenScholar 把这条路线走通 = 证明学术场景 8B 是 sweet spot。
9.3 为什么 peS2o 不嵌入 OpenScholar 仓库本身#
OS 的检索栈和 peS2o 索引完全解耦:
- peS2o v3 数据集 200M+ embeddings → HF Dataset 单独发布
- OS 仓库只放 retriever 代码(从零构建索引)
- 实际部署时,索引和推理可以部署在不同机器
这种”数据 vs 代码”分离是 Allen AI 一贯做法(S2 学术图谱本身也这么组织),好处是版本迭代不耦合,peS2o v4 出来 OS 不用改代码就能用。
9.4 Post-hoc vs In-line Citation 的精度差异#
论文 Table 5 给出了关键 ablation:
| 引用模式 | Citation Precision | Citation Recall |
|---|---|---|
| 主生成带引用 | 71.3% | 58.2% |
| 主生成不带 + Post-hoc 注入 | 82.6% | 73.4% |
| Post-hoc + NLI 二次过滤 | 85.1% | 70.9% |
Post-hoc 注入把 precision 拉高 11 个点,但单独靠 LLM 自评(NLI 二次过滤)收益很小——说明 LLM 第一次判断”该引”已经够好,二次过滤主要是剔除假阳性。
十、和同类方案的对比(为什么值得用 OS)#
10.1 vs GPT-4o(无 RAG)#
| 维度 | GPT-4o 无 RAG | OpenScholar-8B |
|---|---|---|
| 引用准确性 | ~40% | 82.6% |
| 覆盖度(多 paper) | 中 | 高(self-feedback) |
| 引用可追溯 | 无 | 每条引用连到 peS2o paper ID |
| 成本/query | $0.08 | $0.05 |
| 隐私 | 走 OpenAI | 完全本地可跑 |
10.2 vs Perplexity / You.com#
| 维度 | Perplexity Pro | OpenScholar |
|---|---|---|
| 答案来源 | 公开网页 + Reddit | 同行评审论文(peS2o) |
| 学术准确性 | 经常引错 | post-hoc 引用验证 |
| 检索范围 | 全网 50B 页面 | 45M 论文(可控) |
| 可定制 | 不可 | 可换 embedding / reranker / LLM |
| 可本地化 | 不可 | 可 |
10.3 vs Atlas / REALM / RETRO#
这些都是”通用 RAG”经典方案:
- Atlas(Meta, 2022):65B 参数 + 预训练阶段就引入 RAG,学术场景过拟合
- REALM(Google, 2020):检索-端到端训练,只适合通用 QA
- RETRO(DeepMind, 2021):chunked cross-attention,和 8B 推理架构不兼容
OpenScholar 的差异化在于**“学术领域 + 8B + 全栈开源 + 后处理兜底”**,这是过去两年 RAG 学术工业化的最佳实践。
十一、上手实践:用 OS 跑你的第一个 query#
11.1 环境#
conda create -n os_env python=3.10.0
conda activate os_env
pip install -r requirements.txt
python -m spacy download en_core_web_sm
export S2_API_KEY=xxx
# export YOUR_API_KEY=xxx # 可选,启用 you.com 网络检索bash11.2 三种典型场景命令#
# 场景 1:本地 8B,标准 RAG
python run.py \
--input_file my_questions.jsonl \
--model_name OpenScholar/Llama-3.1_OpenScholar-8B \
--use_contexts --top_n 10 --llama3 --zero_shot \
--output_file outputs.json
# 场景 2:本地 8B + BGE reranker + 自反馈 + post-hoc 引用(论文主推)
python run.py \
--input_file my_questions.jsonl \
--model_name OpenScholar/Llama-3.1_OpenScholar-8B \
--use_contexts --ranking_ce --reranker OpenScholar/OpenScholar_Reranker \
--feedback --posthoc --ss_retriever \
--use_abstract --norm_cite --max_per_paper 3 \
--top_n 10 --llama3 --zero_shot \
--output_file outputs_full.json
# 场景 3:用 GPT-4o + 同样 RAG 流水线
python run.py \
--input_file my_questions.jsonl \
--model_name "gpt-4o" --api "openai" --api_key_fp openai_key.txt \
--use_contexts --ranking_ce --reranker OpenScholar/OpenScholar_Reranker \
--top_n 10 --llama3 --zero_shot \
--output_file outputs_gpt4o.jsonbash11.3 关键参数实战取值#
| 参数 | 多 paper 综述 | 单 paper QA (QASA) | Boolean (PubMedQA) |
|---|---|---|---|
--top_n | 10 | 5 | 3 |
--ranking_ce | True | False(已有 ground truth) | True |
--feedback | True | False | False |
--posthoc | True | 必开(论文消融) | True |
--max_per_paper | 3 | 999 | 999 |
--use_abstract | False | True | False |
--task_name | default | single_qa | boolean_question_full |
--zero_shot | True | True | True |
十二、总结:OpenScholar 给 RAG 工程留下的 5 个可复用套路#
读完 12MB 代码 + 论文,以下五条是最值得抄到你自己项目里的工程套路:
- RAG 流水线切 7 个独立 if-else 阶段,每个用
--xxx标志位开关。消融友好、调试方便、论文表格好看 [Response_Start]...[Response_End]包裹 LLM 输出 +stop_token_ids双保险——比正则解析鲁棒得多- Self-Feedback 区分”内容缺陷”(空 Question) 和 “信息缺失”(带 Question),前者纯改写、后者触发再检索
- Post-hoc Citation 注入用 vLLM 批量推理,对所有 placeholder 句子并发请求,GPU 利用率拉满
max_per_paper多样性限流 +(text[:100] + title)dedup key,对付多 chunk 同源问题的两个工程小招
OpenScholar 不是”最 fancy”的 RAG 框架(它没有 LangGraph 那种状态机),但它是**“最诚实”的**——每个工程选择都在论文里有对应消融,每个 prompt 改动都能追溯到 accuracy 数字。
如果你是 LLM/RAG 工程师,读完这个仓库 + 这篇文章,基本能把”学术领域 RAG”做到 SOTA 90% 的水平。剩下 10% 是 SFT 数据质量,这是 OS 真正的护城河——它用 70B + 人类反馈把 13k 训练数据洗到顶配,然后 8B 学生就能反超老师。这才是 OS 留给 RAG 社区最大的启发。
附录:仓库一手数据速查#
| 维度 | 数值 | 来源 |
|---|---|---|
| 总文件 | 493 | find . -type f -not -path './.git/*' |
src/ 代码 | 1,970 行(4 文件) | wc -l src/*.py run.py |
| 指令模板 | 380 行(8 套 prompt) | src/instructions.py |
| Retriever | 2,896 行(retriever/ 全栈) | wc -l retriever/**/*.py |
| peS2o 论文数 | 45M | OpenScholar-DataStore-V3 HF Dataset |
| Embedding 维度 | 768,fp16 | retriever/api/conf/pes2o.yaml |
| IVFPQ 桶数 | 4096 | retriever/src/indicies/ivfpq.py:18 |
| 压缩比 | ~380x | fp16 → IVFPQ(code_size=64) |
| Llama 模型 | 3.1-8B Instruct 基座 | training/recipes/configs/dev/llama3.1/8B_lora.yaml |
| SFT 样本 | 13k | OpenScholar/OS_Train_Data |
| 训练硬件 | 8×A100 80G | README §Training |
| 推理后端 | vLLM | run.py: vllm.LLM |
| 重排模型 | BGE-reranker-large → 5 epoch 微调 | OpenScholar/OpenScholar_Reranker |
| 评估基准 | ScholarQABench(5 子任务) | AkariAsai/ScholarQABench |
| 主线 commits | 27 | git log --oneline | wc -l |
| 最后更新 | 2025-08-12 | git log -1 --format=%ad |
参考链接:
- 📄 OpenScholar 论文(arXiv:2411.14199) ↗
- 💻 源码 AkariAsai/OpenScholar ↗
- 🎮 Demo open-scholar.allen.ai ↗
- 🤗 HF 模型集合 openscholar-v1 ↗
- 🤗 peS2o 数据集 allenai/peS2o ↗
- 📊 ScholarQABench ↗
- 🔍 Allen AI 官方介绍 ↗
本文基于 AkariAsai/OpenScholar ↗ 源码 + 论文 arXiv:2411.14199 ↗ 写作,所有代码引用、行数、commit 数据均来自仓库本体(2025-08-12 最新 commit
0e9b8fb)。