
先说结论这个项目解决的是文本检索领域里一个很具体的问题——在大量 ADHD 症状句子中根据一个查询找出最相关的若干条并按相关度合理排序。它不追求把模型本身训得更大而是用“稀疏召回、语义补充、LLM 重排”三阶段管线把不同检索范式的优势拼在一起。这个思路用在本地知识库问答、RAG 召回优化、医学文本辅助检索上都能直接复用。DSGT-ARC at eRisk 2026 Task 3 是 eRisk 2026 竞赛 Task 3 的一个参赛技术方案。eRiskEarly Risk Prediction on the Internet是 CLEF 下长期运行的评测任务核心研究方向是基于网络文本做早期风险预测。Task 3 具体聚焦 ADHD 症状句子检索给定查询或用户描述系统要从症状句子库中返回相关句子并输出可信的排序。从标题可以看到方案主线就是 Sparse、Semantic 和 LLM Reranking 三部分。从工程角度看这个方案没有引入过于复杂的训练流程而是把成熟组件按正确顺序组合起来。BM25 负责精确词项匹配向量检索负责语义泛化LLM 重排负责精细化筛选。这套组合对显存和算力的要求完全取决于你选择的具体模型如果 embedding 和重排模型都走 API那本机只需要一个支持 Python 的普通开发环境如果全部本地部署则需要根据模型尺寸评估 GPU 显存。这篇文章会沿着完整技术路线展开先看任务背景和适用边界再给出环境准备、索引构建、混合召回、分数融合、LLM 重排、接口封装、批量任务和性能观察方法最后整理一份问题排查清单。如果你正准备做搜索排序、RAG 召回优化或者要参加类似的文本检索评测这篇可以直接收藏按步骤在自己数据上跑一遍。1. 核心能力速览能力项说明项目类型文本检索与重排技术方案eRisk 2026 Task 3 参赛方案关键技术SparseBM25、Semantic向量检索、LLM Reranking任务对象ADHD 症状句子检索与相关性排序输入方式查询文本 症状句子库输出方式排序后的句子列表附带相关度顺序是否支持批量任务支持查询可以批量跑接口层和脚本层都能做队列是否支持接口 API支持可以用 FastAPI 封装为 HTTP 服务本机硬件要求BM25 仅 CPU 可跑向量检索可用 CPU推荐 GPULLM 重排取决于模型本地大模型需要较高显存启动方式脚本调用接口化后可用 HTTP 服务启动适合场景RAG 召回优化、医学文本检索、知识库排序、评测赛题复现注意一点上表中的显存参数没有写死因为具体占用完全取决于 embedding 模型和重排模型的选择。比如 embedding 换成 bge-large 系列和换成 bge-m3显存就不是一个量级重排模型用 7B 本地模型和 70B 本地模型也完全不同。先按“单路检索 小模型”搭通再逐步升级是更稳的路径。2. 任务背景与适用边界2.1 eRisk 2026 Task 3 在解决什么问题eRisk 任务的历史背景是从社交媒体文本中识别心理健康风险和早期预警信号。Task 3 到 2026 年聚焦 ADHD 症状句子检索本质上是给一个“查询”和一批“候选症状句子”系统要做相关性判断和排序。举个例子查询可能是“最近上课总走神作业拖到最后一刻”系统中存在“注意维持困难”“任务启动延迟”“组织计划能力下降”等 ADHD 相关症状描述模型需要把最相关的症状句子排到前面。这种任务和常规搜索引擎排序的不同点在于它要排序的对象不是网页而是语义上更细粒度的临床描述句子。句子之间表达相似、措辞多样单靠关键词很可能漏掉语义一致但词面不同的结果所以单路检索很难稳定拿到好效果这也是引入混合召回和 LLM 重排的原因。2.2 适用场景这个方案适合以下几类情况需要做召回和重排实验的检索系统比如 RAG 中的候选召回模块。医学、心理学文本的语义搜索需要从句子级语料中找出相关描述。评测赛题复现和对比实验想用 BM25、向量检索、重排模型分别跑基线再融合。需要把检索能力接口化供上层应用调用的工程场景。2.3 使用边界与合规提醒ADHD 症状句子检索涉及心理健康领域这一点必须单独强调检索和排序结果只用于信息获取、科学研究和辅助筛选阶段不能直接作为临床诊断依据。真实诊疗中症状判断需要专业医生结合完整病史和评估工具完成。数据层面要特别注意三点。第一涉及用户文本、临床描述或社交网络数据时必须完成匿名化和去标识化处理确认数据来源合规且已获得必要授权。第二项目输出只应在受控的测试环境或内部研究环境使用必要时对接口做访问控制。第三任何对外发布、商用或临床相关的使用都需要经过专业审核后再确定边界。3. 环境准备与前置条件3.1 基础环境系统方面Windows、Linux、macOS 都可以。如果涉及本地 GPU 推理Linux 的驱动兼容性通常更省心。Python 建议用 3.10 或更高版本便于使用新版 PyTorch 和 Transformers。建议先建一个虚拟环境避免依赖冲突python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate3.2 Python 依赖核心依赖包括检索、向量编码、重排和接口服务几个部分。下面给出示例安装命令pip install --upgrade pip pip install rank-bm25 sentence-transformers transformers pip install fastapi uvicorn openai pydantic pip install numpy pandas如果要用 GPU 推理需要根据本机 CUDA 版本安装对应版本的 PyTorch。建议先到 PyTorch 官网选择匹配命令避免直接pip install torch装到不合适版本。3.3 模型资源准备这个方案需要两类模型一个是 embedding 模型用于语义向量召回一个是 LLM 模型用于重排。embedding 模型按语种和数据规模选。英文数据可以用 BGE 或 E5 系列中文数据可以用 BAAI/bge-large-zh-v1.5 这类中文模型。如果数据是多语言混合可以尝试 bge-m3它同时支持稀疏和稠密向量后续文章会提到怎么替换。LLM 重排模型可以是本地开源模型也可以是内部部署的 API 服务。本地推理时7B 左右量化版模型是常见选择也可以直接用公司内部或云厂商的模型服务这时本机不承担推理负载。3.4 数据准备数据格式建议使用统一的 JSON 结构。每条记录至少包含 ID 和 text其他元数据按任务需要追加。示例[ { id: adhd_001, text: 难以长期维持注意力容易在任务中频繁切换, category: 注意缺陷 }, { id: adhd_002, text: 经常丢失学习或工作所需物品做事缺乏条理, category: 组织计划 } ]建议把原始句子库和查询集分开存放句子库存放在data/corpus.json查询集存放在data/queries.jsonl方便后续批量测试。4. 完整技术路线稀疏召回、语义召回、LLM 重排4.1 整体流程设计整个检索重排管线可以分成四个阶段候选召回、分数融合、LLM 重排、结果输出。候选召回阶段先用 BM25 做稀疏检索取出词面匹配度高的一批候选同时用 embedding 模型做语义检索取出语义相近的一批候选。两个召回结果通过 RRF 或加权融合合并成一个候选集合。融合后的集合通常控制在 10 到 30 条然后交给 LLM 做精细化的相关性排序。最后把排序结果输出。这种设计的好处是每个模块的职责非常清楚BM25 保证精确匹配不丢向量检索保证同义表达能召回LLM 重排负责消除前两路带来的噪声。4.2 BM25 稀疏召回稀疏检索不依赖 GPU构建索引快结果可解释性强。这里用rank_bm25库实现 BM25。import json from rank_bm25 import BM25Okapi with open(data/corpus.json, r, encodingutf-8) as f: corpus json.load(f) doc_texts [item[text] for item in corpus] # 分词。英文按空格切中文建议替换为 jieba.cut def tokenize(text): return list(text.split()) tokenized_docs [tokenize(doc) for doc in doc_texts] bm25 BM25Okapi(tokenized_docs) def bm25_search(query, top_k50): tokenized_query tokenize(query) scores bm25.get_scores(tokenized_query) ranked sorted( ((idx, float(score)) for idx, score in enumerate(scores)), keylambda x: x[1], reverseTrue ) return ranked[:top_k]中文场景下text.split()只能按空格或标点切效果有限。建议改成 jieba 分词再参与 BM25。4.3 语义向量召回语义召回把文本映射到向量空间用向量距离衡量相关性。这里用sentence-transformers加载 embedding 模型。from sentence_transformers import SentenceTransformer # 中文示例BAAI/bge-large-zh-v1.5 # 英文示例BAAI/bge-large-en-v1.5 encoder SentenceTransformer(BAAI/bge-large-zh-v1.5) # 编码整个语料只需在启动时做一次 doc_embeddings encoder.encode( doc_texts, batch_size32, normalize_embeddingsTrue, show_progress_barTrue ) def semantic_search(query, top_k50): query_embedding encoder.encode([query], normalize_embeddingsTrue) scores doc_embeddings query_embedding.T # shape: (num_docs, 1) flattened scores.squeeze() ranked sorted( ((idx, float(flattened[idx])) for idx in range(len(doc_texts))), keylambda x: x[1], reverseTrue ) return ranked[:top_k]需要注意embedding 模型一旦切换doc_embeddings 必须重新编码。批量任务场景下语料编码结果最好保存到本地文件避免每次启动都重新编码。4.4 分数融合RRF 与加权两路召回的分数范围不一样BM25 分数可能很稀疏向量余弦相似度则在 0 到 1 之间。直接把两个分数相加不稳定建议使用 RRFReciprocal Rank Fusion把排名信息转成分数。def hybrid_recall(query, bm25_top50, dense_top50, final_top20): bm25_hits bm25_search(query, top_kbm25_top) dense_hits semantic_search(query, top_kdense_top) rrf_scores {} for rank, (doc_id, score) in enumerate(bm25_hits): rrf_scores[doc_id] rrf_scores.get(doc_id, 0) 1.0 / (60 rank 1) for rank, (doc_id, score) in enumerate(dense_hits): rrf_scores[doc_id] rrf_scores.get(doc_id, 0) 1.0 / (60 rank 1) candidates sorted(rrf_scores.items(), keylambda x: x[1], reverseTrue) return candidates[:final_top]RRF 里的常数 60 表示排名权重基线越大越弱化高排名的影响。如果后续某种召回在评测里明显更准还可以改成加权 RRF比如1.0 / (60 rank 1) * 1.2给某一路更高权重。4.5 LLM 重排在候选数量被压缩到 20 条左右之后进入 LLM 重排阶段。LLM 重排的目标不是扩展召回而是对已有候选做精细化排序。实现方式是构造一个“查询 候选句子列表”的 prompt让模型输出按相关度排序的句子编号。下面以 OpenAI 兼容接口为例说明调用方式。这里可以接本地部署的 vLLM、Ollama也可以接内部 API 服务。from openai import OpenAI client OpenAI( base_urlhttp://127.0.0.1:8000/v1, # 按实际 LLM 服务地址修改 api_keyEMPTY ) def llm_rerank(query, candidates, corpus, top_n10): sentence_lines [] for idx, (doc_id, score) in enumerate(candidates): sentence_lines.append(f{idx 1}. {corpus[doc_id][text]}) numbered_text \n.join(sentence_lines) prompt f你是 ADHD 症状句子相关性排序助手。 查询{query} 候选句子 {numbered_text} 请判断每一条候选句子与查询的相关性并按相关程度从高到低重新排序。 只输出句子编号序列格式为3,1,5,2,4 response client.chat.completions.create( modelqwen2.5-7b-instruct, # 按实际模型名称修改 messages[ {role: system, content: 你是一个检索排序助手只能输出编号序列。}, {role: user, content: prompt} ], temperature0.0, max_tokens256 ) output response.choices[0].message.content.strip() ordered [] for part in output.replace(, ,).split(,): part part.strip() if not part: continue try: num int(part) if 1 num len(candidates): doc_id candidates[num - 1][0] ordered.append(doc_id) except ValueError: continue return ordered[:top_n] if ordered else [doc_id for doc_id, _ in candidates[:top_n]]实际调用时模型输出有时会带上“排序结果”之类的前缀解析时需要做一次过滤。更稳的做法是要求模型返回 JSON 数组比如[3,1,5,2,4]这样可以用json.loads解析。5. 功能测试与效果验证5.1 测试设计这个方案能不能用要先跑通下面这些测试维度单路 BM25 检索是否正常返回。单路语义检索是否正常返回。混合召回是否能召回两路的并集。LLM 重排输出是否可稳定解析。接口服务能否连续被调用。建议准备 5 到 10 条覆盖不同表达方式的查询先手工看结果相关性再考虑算指标。调试期不要一上来就跑几百条查询先跑通链路再扩大范围。5.2 测试代码示例query 做事情经常拖到最后无法按计划完成 print( BM25 Top 5 ) for doc_id, score in bm25_search(query, top_k5): print(corpus[doc_id][id], round(score, 4), corpus[doc_id][text]) print( Semantic Top 5 ) for doc_id, score in semantic_search(query, top_k5): print(corpus[doc_id][id], round(score, 4), corpus[doc_id][text]) print( Hybrid Top 10 ) candidates hybrid_recall(query, final_top10) for doc_id, score in candidates: print(corpus[doc_id][id], round(score, 4), corpus[doc_id][text]) print( LLM Rerank Top 5 ) ranked llm_rerank(query, candidates, corpus, top_n5) for doc_id in ranked: print(corpus[doc_id][id], corpus[doc_id][text])判断标准是BM25 和语义检索结果不完全重合说明两路都有信息增益混合结果的前几条明显比单路更贴合查询LLM 重排输出能被稳定解析且排序结果在语义上合理。5.3 离线评估指标如果要量化评估用信息检索领域的标准指标比较合适。指标说明适用场景RecallK前 K 条命中的比例判断召回能力PK前 K 条中相关句子的比例判断前部精度MRR第一个相关结果位置的倒数判断命中位置nDCGK综合排序质量指标判断整体排序效果评测时先把查询和标准相关句整理成测试集再对不同策略分别计算结果。可以先跑 BM25 单独结果再跑向量单独结果再跑混合结果最后跑混合加 LLM 重排结果这样能清楚看到每一层带来的提升。5.4 常见失败信号如果 LLM 重排后结果反而变差通常有两个原因一个是候选集合太小重排基本没起到重新排序的作用另一个是 prompt 中的候选数量太多超出模型上下文窗口模型只能截断处理。先限制候选数为 10 到 20再观察输出质量。如果 BM25 检索不到结果多半是分词问题。中文句子没有经过 jieba 分词词面匹配很容易失败。改成 jieba 分词后绝大多数情况能解决。6. 接口 API 与批量任务6.1 用 FastAPI 封装检索服务检索链路跑通后可以封装成 HTTP 服务方便上层应用或前端调用。一个典型的/retrieve接口实现如下from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class RetrieveRequest(BaseModel): query: str top_k: int 10 class RetrieveResponse(BaseModel): query: str results: list app.post(/retrieve, response_modelRetrieveResponse) def retrieve(req: RetrieveRequest): candidates hybrid_recall(req.query, final_top20) ordered llm_rerank(req.query, candidates, corpus, top_nreq.top_k) results [] for rank, doc_id in enumerate(ordered): results.append({ rank: rank 1, doc_id: corpus[doc_id][id], text: corpus[doc_id][text], category: corpus[doc_id].get(category, ) }) return RetrieveResponse(queryreq.query, resultsresults)启动服务uvicorn app:app --host 127.0.0.1 --port 80006.2 curl 调用示例启动后可以用 curl 做一次冒烟测试curl -X POST http://127.0.0.1:8000/retrieve \ -H Content-Type: application/json \ -d {query: 容易分心写作业拖很久, top_k: 5}返回结果是一个 JSON 数组每条包含 rank、doc_id、text 和分类字段。第一次调用会因为 embedding 和 LLM 初始化而偏慢后续调用会快很多。6.3 批量任务设计批量任务分两种场景。第一种是离线批量评测只需要把查询集跑完把结果保存到文件第二种是服务部署后的持续调用需要控制并发和重试。离线批量脚本示例import json import time from concurrent.futures import ThreadPoolExecutor with open(data/queries.jsonl, r, encodingutf-8) as f: queries [json.loads(line)[query] for line in f] def process_query(query): t0 time.perf_counter() candidates hybrid_recall(query, final_top20) ordered llm_rerank(query, candidates, corpus, top_n10) elapsed time.perf_counter() - t0 return { query: query, ranked_doc_ids: ordered, elapsed_ms: round(elapsed * 1000, 2) } results [] with ThreadPoolExecutor(max_workers4) as pool: for r in pool.map(process_query, queries): results.append(r) with open(data/batch_results.json, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2)并发数建议从 1 到 4 起步根据 LLM 服务端的并发能力和本机内存逐步上调。批量任务一定要加日志和异常捕获不能因为单条查询失败就中断整个队列。7. 资源占用与性能观察7.1 三个阶段的开销差异检索链路三个阶段的开销完全不同。BM25 几乎不占用显存也不依赖 GPU构建索引后查询很快。语义检索需要把整个语料编码成向量启动阶段耗时较长后续每次查询只是做一次向量乘法和排序开销可控。LLM 重排是三个阶段里最贵的因为它要针对每个候选句子构造 prompt再让模型生成排序结果。如果只做 RAG 召回可以不用 LLM 重排但如果任务本身把排序质量当作核心指标LLM 重排带来的收益通常明显值得增加这层开销。7.2 显存与性能观察方法建议分别在三个环节打点计时import time t0 time.perf_counter() candidates hybrid_recall(query, final_top20) t1 time.perf_counter() ordered llm_rerank(query, candidates, corpus, top_n10) t2 time.perf_counter() print(f召回融合耗时: {(t1 - t0) * 1000:.1f} ms) print(fLLM 重排耗时: {(t2 - t1) * 1000:.1f} ms) print(f总耗时: {(t2 - t0) * 1000:.1f} ms)GPU 显存查看方式nvidia-smi重点观察两个阶段的峰值语料编码阶段和 LLM 重排阶段。如果显存接近上限优先降低 embedding 编码的batch_size或者把重排模型切换为量化版本。7.3 影响性能的关键参数batch_sizeembedding 编码批次大小越大越快但显存占用越高。final_top进入 LLM 重排的候选数20 条和 50 条的开销差异明显。top_k接口返回条数不影响召回成本但影响输出处理。语料规模超过十万级句子时纯向量全量扫描会变慢建议引入向量索引库做 ANN 检索。并发数量批量任务并发过大会导致 LLM 服务超时或显存溢出。8. 常见问题与排查方法问题现象可能原因排查方式解决方案BM25 检索返回空或结果差中文未分词词面不匹配打印 tokenize 结果改用 jieba 分词加入停用词过滤语义检索结果不合理embedding 模型语种与数据不匹配看返回句子的语义接近度换成对应语种的 embedding 模型混合召回结果和单路差不多两路候选重合度高统计两路结果的交集占比调整 BM25 和向量召回各自的 top_k 或融合权重LLM 重排输出无法解析模型返回了额外文字打印模型原始输出在 prompt 中强制输出 JSON 或不带前缀的编号序列LLM 重排结果反而更差候选数超过模型上下文检查 prompt 字符数和模型上下文窗口减小 final_top或分批重排启动后接口返回慢embedding 或 LLM 模型正在初始化看日志和资源占用启动时预加载模型不要在请求内重复加载显存不足batch_size 过大或模型过大用 nvidia-smi 观察降低 batch_size切换量化模型批量任务中途失败单条查询超时或接口限流查看异常日志增加重试机制限制并发数端口被占用8000 端口已被其他服务占用lsof -i:8000或netstat -ano换端口启动 uvicorn或者关闭占用进程调试阶段最值得先看的还是日志。每个阶段都加上耗时和结果数量的输出能很快定位是召回阶段没召回到还是重排阶段解析失败。不要一开始就追求全流程跑满几百条数据先人工验证 10 条结果确认语义合理后再扩大规模。9. 最佳实践与使用建议第一次做评估先让小参数跑通查询集选 10 条候选数设 10LLM 重排只保留 5 条。这样能快速验证链路也能直观感受每一层搜索和重排的耗时。模型选择和输入语料放在同一个目录下管理建议目录结构固定为project/ ├── data/ │ ├── corpus.json │ └── queries.jsonl ├── models/ │ └── embedding_model/ ├── outputs/ │ └── batch_results.json ├── src/ │ ├── retrieve.py │ └── app.py └── requirements.txt模型文件、输入数据、输出结果分开批量任务就不会因为误删数据或输出文件冲突而翻车。批量任务必须加日志和失败重试。如果调用的是内部 LLM API超时和限流经常出现建议对单条查询做三次重试超过三次后记录到失败列表。涉及心理健康文本或用户行为数据时务必确认数据是否已匿名化是否得到授权。这类文本一旦与用户身份关联就属于敏感数据接口服务应当限制访问范围不对外公开。任何输出都不能直接替代医学诊断发布或商用前要有专业复核。如果要用这个方案进一步提升 RAG 效果可以把 LLM 重排之后的结果直接作为生成阶段的上下文。这样既减少了噪声又控制了输入长度生成质量通常比直接拼接 Top K 条原始句子更稳定。10. 总结与下一步这个方案最值得尝试的点是把稀疏检索、语义检索和 LLM 重排组合成一个可控的检索管线。每一层职责单一替换成本低BM25 换成 Elasticsearch 可以embedding 模型换成 bge-m3 可以LLM 重排换成更强的模型也可以。对于 ADHD 症状句子检索这类需要语义理解和精细排序的任务这套组合比单路检索可靠得多。先把 BM25 和语义检索单路结果跑出来再做混合融合这是第一步。最容易踩的坑有两个一个是中文数据没有分词BM25 直接失效另一个是 LLM 重排输出格式不稳定导致解析失败。把这两个问题提前处理掉后面扩展就很顺畅。后续可以考虑的方向有几个引入 bge-m3 这类多向量模型同时覆盖稀疏和稠密召回在融合阶段加入元数据过滤比如按症状类别约束候选范围在重排阶段使用更强的开源模型或者把排序做成成对比较。如果语料规模继续扩大还需要引入向量索引库和分片部署这些都是从“能跑”走向“能上线”的关键步骤。建议先在自己手头的查询集和句子库上跑一遍用 10 条查询验证全流程再决定要不要接入 LLM 重排。毕竟增加的这层开销是否值得取决于你的任务到底有多依赖排序质量。