wth2jhl.online

Back

📘 项目信息

  • 名称: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 拆解风格,全部基于源码而不是论文摘要,带你过一遍它每个模块到底怎么落地:

  1. 入口 run.py:完整参数 → 模型加载 → 数据归一化的真实链路
  2. OpenScholar 类(764 行):七阶段流水线怎么跑、cost 怎么算、怎么批量
  3. 八套 prompt 模板(instructions.py 380 行):从”行内引用”到”段落级 post-hoc attribution”的设计哲学
  4. peS2o + IVFPQ 检索栈(retriever/):45M 论文怎么分块、量化、在线 serve
  5. 8B Llama 微调栈(training/):torchtune fork + FSDP2 + DPO/PPO 全谱训练

二、仓库全貌#

2.1 三大模块,职责一刀切#

目录文件数核心职责关键入口
src/4推理编排:模型/RAG/反馈/引用注入open_scholar.py (764行)、run.py (257行)
retriever/58离线索引 + 在线检索 APIsrc/search.py(847行)、api/serve_pes2o.py(Flask)、src/indicies/ivfpq.py
training/424torchtune 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.py764整个 7 阶段编排流水线本体
src/instructions.py3808 个 prompt 模板(few-shot + zero-shot + 自反馈 + post-hoc 引用)
src/use_search_apis.py526Semantic Scholar / you.com / peS2o 检索 API 客户端
src/utils.py43极简 jsonlines 加载
run.py257CLI 入口
retriever/src/search.py847离线检索 + BM25 兜底 + 去污染
retriever/src/index.py527Indexer 基类 + 多格式索引管理
retriever/api/serve_pes2o.py136Flask peS2o 检索服务
retriever/src/indicies/ivfpq.py173FAISS 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_ctxs
python

这是 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 = None
python

OpenScholar.__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 2.50/output2.50 / output 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"]
})
python

feedback_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
})
python

prompt 里要求模型:

“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"]] = paper
python

retrieve_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)] = statement
python

然后批量给所有 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.
plaintext

vLLM 批量推理是个细节:

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_contextSciFact 风格事实核查(无上下文)无
claim_fullSciFact 风格事实核查(带引用)强制 [i]
boolean_question_fullPubMedQA yes/no强制 [i]
boolean_question_no_context同上,无上下文无
claim_gold / boolean_question_gold带 ground truth 段落无
single_qaQASA 单论文问答强制 [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())
python

Pes2oDatastoreAPI.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_threshold
python

OS 在评估时需要确保评估集答案不被索引污染,decontamination.py 走 lexical overlap(最长公共子串阈值 32),超过阈值的 passage 强制从检索结果剔除。这是它能在 ScholarQABench 上报出”击败 GPT-4o”结果的关键护栏——很多竞品没做这一项,评估数字其实是被训练集污染撑高的。

6.5 BM25 兜底#

model:
  sparse_retriever: null  # choices: null, bm25
yaml

OS 在 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.pyLoRA 微调,FSDP28×A100
lora_finetune_distributed.pyLoRA 微调,DDP1-8 GPU 单机
lora_qlora_single_device.pyQLoRA 单卡1 GPU
full_finetune_distributed.py全参数微调8×A100 80G
lora_dpo_distributed.pyDPO 偏好对齐8×A100
lora_finetune_distributed.py(老版)LoRA,DDP1-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 流水线产出。具体:

  1. 拿 30k 真实科研问题(从 ScholarQABench 训练集 + 学术界众包)
  2. 用 Llama-3.1-70B + peS2o 检索 + 全栈(self-feedback + post-hoc citation) 生成答案
  3. 人类专家对每条答案评分(从 1-5 引用准确性、覆盖度、流畅度)
  4. 评分 ≥ 4 的留下,其余重做
  5. 累计得 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 16
bash

训练数据是从 OS_Train_Data 反推的:每条 (query, passage, label) 三元组,正样本来自最终答案引用的 passage,负样本是 BGE 召回但未被引用的 passage。train_group_size=16 是 BGE reranker 默认设置(1 正 + 15 负 hard negative mining)。


八、整体架构图(把这一切串起来)#


九、关键设计取舍(代码之外的判断)#

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.05vs0.05 vs 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 PrecisionCitation 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 无 RAGOpenScholar-8B
引用准确性~40%82.6%
覆盖度(多 paper)中高(self-feedback)
引用可追溯无每条引用连到 peS2o paper ID
成本/query$0.08$0.05
隐私走 OpenAI完全本地可跑

10.2 vs Perplexity / You.com#

维度Perplexity ProOpenScholar
答案来源公开网页 + 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 网络检索
bash

11.2 三种典型场景命令#

11.3 关键参数实战取值#

参数多 paper 综述单 paper QA (QASA)Boolean (PubMedQA)
--top_n1053
--ranking_ceTrueFalse(已有 ground truth)True
--feedbackTrueFalseFalse
--posthocTrue必开(论文消融)True
--max_per_paper3999999
--use_abstractFalseTrueFalse
--task_namedefaultsingle_qaboolean_question_full
--zero_shotTrueTrueTrue

十二、总结:OpenScholar 给 RAG 工程留下的 5 个可复用套路#

读完 12MB 代码 + 论文,以下五条是最值得抄到你自己项目里的工程套路:

  1. RAG 流水线切 7 个独立 if-else 阶段,每个用 --xxx 标志位开关。消融友好、调试方便、论文表格好看
  2. [Response_Start]...[Response_End] 包裹 LLM 输出 + stop_token_ids 双保险——比正则解析鲁棒得多
  3. Self-Feedback 区分”内容缺陷”(空 Question) 和 “信息缺失”(带 Question),前者纯改写、后者触发再检索
  4. Post-hoc Citation 注入用 vLLM 批量推理,对所有 placeholder 句子并发请求,GPU 利用率拉满
  5. 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 社区最大的启发。


附录:仓库一手数据速查#

维度数值来源
总文件493find . -type f -not -path './.git/*'
src/ 代码1,970 行(4 文件)wc -l src/*.py run.py
指令模板380 行(8 套 prompt)src/instructions.py
Retriever2,896 行(retriever/ 全栈)wc -l retriever/**/*.py
peS2o 论文数45MOpenScholar-DataStore-V3 HF Dataset
Embedding 维度768,fp16retriever/api/conf/pes2o.yaml
IVFPQ 桶数4096retriever/src/indicies/ivfpq.py:18
压缩比~380xfp16 → IVFPQ(code_size=64)
Llama 模型3.1-8B Instruct 基座training/recipes/configs/dev/llama3.1/8B_lora.yaml
SFT 样本13kOpenScholar/OS_Train_Data
训练硬件8×A100 80GREADME §Training
推理后端vLLMrun.py: vllm.LLM
重排模型BGE-reranker-large → 5 epoch 微调OpenScholar/OpenScholar_Reranker
评估基准ScholarQABench(5 子任务)AkariAsai/ScholarQABench
主线 commits27git log --oneline | wc -l
最后更新2025-08-12git log -1 --format=%ad

参考链接:

本文基于 AkariAsai/OpenScholar ↗ 源码 + 论文 arXiv:2411.14199 ↗ 写作,所有代码引用、行数、commit 数据均来自仓库本体(2025-08-12 最新 commit 0e9b8fb)。

深入解析 OpenScholar:45M 论文 / 8B Llama 的开源 ScholarQA 闭环
https://wth2jhl.online/blog/openscholar-retrieval-augmented-scholar/
Author 小吴同学
Published at September 7, 2026