在文本分类、信息检索与医疗健康文本挖掘交叉的场景里,“稀疏检索 + 语义检索 + LLM 重排序”这套组合已经被越来越多的竞赛方案和生产项目采用。本文以 DS@GT-ARC 在 eRisk 2026 Task 3 上的方案为例,拆解 ADHD 症状句子检测中的稀疏信号、语义信号和 LLM 重排序信号,并给出可落地的代码思路、评估方式和工程排错建议。无论你是在参加类似的 NLP 评测任务,还是想在自己的文本检索系统里引入多路召回和重排序,这篇文章都值得收藏。
1. 背景与核心概念
1.1 什么是 eRisk Task 3
eRisk 是信息检索领域里关注“心理健康风险”预测的相关任务系列,主要利用用户在社交平台上的历史发帖文本,去预测抑郁症、焦虑、饮食障碍、自杀风险、ADHD 等心理健康问题。Task 3 是其中关于 ADHD 症状句子检测的任务。
DS@GT-ARC 团队在 eRisk 2026 Task 3 中做的事情可以概括为:
- 第一阶段:从候选用户中检测哪些用户可能患有 ADHD,并给出按照患病概率排序的排名列表。
- 第二阶段:针对第一阶段识别出的 ADHD 用户,进一步判断他们发布的句子中是否包含注意力不集中、多动、冲动、组织问题、挫折耐受性差这五类典型 ADHD 症状。
这个任务不同于普通的情感分类或主题分类,它是一种“风险信号发现 + 细粒度证据定位”的组合任务。第一阶段解决“谁有风险”,第二阶段解决“风险体现在哪里”。
1.2 稀疏、语义与 LLM 重排序
在文本检索领域,稀疏检索、语义检索和 LLM 重排序是三个层次分明又互补的技术方向。
- 稀疏检索(Sparse Retrieval):核心是基于词汇重叠。常见方法有 TF-IDF、BM25、BoWS(Bag of Words with Stems,词干词袋)等。优点是计算快、可解释性强,缺点是遇到同义改写、语义泛化时容易漏召回。
- 语义检索(Semantic Retrieval):核心是基于向量表示。常见做法是用 Sentence-BERT、E5、BGE 等模型把查询和文档映射到高维向量空间,再用余弦相似度计算相关性。优点是能处理同义表达和语义近似,缺点是训练数据和模型领域适配度会直接影响效果。
- LLM 重排序(LLM Reranking):核心是利用大语言模型的指令理解能力和推理能力,把第一阶段的候选文档重新排序。LLM 可以结合任务定义、约束条件、分类体系等细粒度信息,给出比传统打分函数更灵活的判断。
在 DS@GT-ARC 的方案中,这三条路线并不是彼此孤立的,而是形成了一条“稀疏召回 → 语义召回 → LLM 重排序”的流水线。这也是许多 RAG 应用、企业级搜索系统、知识库问答系统的通用架构。
1.3 为什么需要理解这个方案
ADHD 症状句子的检测难点在于:
- 症状表达高度口语化,用户在社交平台上的发帖通常没有规范医疗术语。
- 不同用户表达同一种症状的表达方式差异很大。
- 正常用户也可能偶尔讨论“注意力差”“没耐心”,需要区分偶发表达和持续性症状。
- 正负样本极不均衡。
因此,单靠传统词频方法或者单靠一个向量模型,很难同时兼顾召回率和排序质量。理解 DS@GT-ARC 的多信号融合方案,能够帮助你在面对类似文本挖掘任务时,快速制定出可行路线。
2. 数据集与任务设定说明
2.1 数据来源与分布
根据论文描述,任务的数据来自 eRisk 2023 的数据集合。训练集包含 141 名 ADHD 用户和 843 名对照用户,正负样本比例大约是 1:6。这种不平衡比例在医疗文本挖掘任务中非常常见。
用户输入的特征是他们在社交平台发帖的文本序列。对于第一阶段,一条样本就是一个用户的全部帖子文本。对于第二阶段,一条样本就是一段带有候选标签的句子或片段。
在实际竞赛和项目中,你还需要对数据做以下检查:
- 用户 ID 是否唯一。
- 帖子时间戳是否完整。
- 文本是否包含大量 URL、表情符号、删除标记。
- 是否存在同一用户的帖子被重复收集。
2.2 五类 ADHD 症状体系
在第二阶段,系统需要根据预定义的症状体系对句子进行排序。这里的体系选自 PCL-5 相关症状框架,并结合 ADHD 特征做了细化。
核心症状包括以下五类:
| 症状类别 | 示例表达 |
|---|---|
| 注意力不集中/注意力问题 | “我开会的时候总是走神,完全不知道刚才讲了什么。” |
| 多动 | “我感觉自己一刻也坐不住,总想走来走去。” |
| 冲动 | “我买东西从来不考虑后果,看到就下单。” |
| 组织问题 | “我的桌面永远乱成一团,计划总是半途而废。” |
| 挫折耐受性差 | “一遇到排队或者等待,我就特别容易发火。” |
这种细粒度分类意味着模型不能只判断“是否和 ADHD 相关”,还必须判定“具体属于哪一类症状”,否则第二阶段的信息增益会非常有限。
2.3 评价指标
eRisk 任务中最常见的评价指标包括:
- P@10:排名列表前 10 个结果中,相关结果占比。
- NDCG@10:排名列表前 10 个结果的归一化折损累计增益。
- R@10:排名列表前 10 个结果中,相关结果占全部相关结果的比例。
这三个指标各有侧重点:
- P@10 关心“推荐结果准不准”。
- NDCG@10 关心“相关结果是不是排在前面”。
- R@10 关心“有没有漏掉太多正确结果”。
在实际评估时,你需要同时关注 Precision 和 Recall,不要只看单一指标。尤其是医疗场景,漏检的代价往往比误报更大。
3. 算法方案拆解
3.1 稀疏信号:BoWS + 逻辑回归
在第一阶段,DS@GT-ARC 使用了基于 BoWS 特征的方法。BoWS 是 Bag of Words with Stemming 的缩写,意思是先对文本做分词、去停用词、词干还原,再构建词频向量。
为什么选择 BoWS 而不是直接使用原始 BoW?
- 词干还原能够把“focus”“focusing”“focused”合并成同一个词干,减少特征稀疏。
- 去停用词可以过滤掉“the”“and”“of”这类无信息量词。
- 词频向量配合逻辑回归,可以得到每个词对 ADHD 判断的权重贡献,可解释性较强。
逻辑回归模型的输出可以作为用户的 ADHD 风险分数。将所有用户按分数降序排列,就得到了第一阶段需要的排序列表。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline docs = [ "I keep losing my focus and cannot finish anything", "I feel restless and need to move all the time", "I am really mad at waiting in line", ] labels = [1, 1, 0] vectorizer = TfidfVectorizer( ngram_range=(1, 2), stop_words="english", sublinear_tf=True, ) model = Pipeline([ ("tfidf", vectorizer), ("lr", LogisticRegression(max_iter=1000)), ]) model.fit(docs, labels)这段代码只用于演示思路。在实际任务中,你需要先阅读 eRisk 官方数据格式,把每个用户的文本拼接成用户级文档,再构建训练集和验证集。
3.2 语义信号:Sentence-BERT 与密集检索
稀疏信号虽然稳定,但遇到“没话找话”“换一种说法”这类语义改写时容易失效。比如用户说“我的脑子像一团浆糊,没办法持续思考”,这个词和“注意力不集中”没有太多词面重叠,但语义上高度相关。
这时可以用语义编码模型把用户文本和症状定义分别编码成向量,再计算相似度。
from sentence_transformers import SentenceTransformer, util model = SentenceTransformer("all-MiniLM-L6-v2") symptom_defs = { "inattention": "difficulty sustaining attention, careless mistakes, losing things", "hyperactivity": "restlessness, inability to stay still, excessive movement", "impulsivity": "acting without thinking, interrupting others, risky decisions", } user_text = "I keep losing my focus and cannot finish anything" user_vec = model.encode(user_text, convert_to_tensor=True) for symptom, definition in symptom_defs.items(): symptom_vec = model.encode(definition, convert_to_tensor=True) score = util.cos_sim(user_vec, symptom_vec).item() print(f"{symptom}: {score:.4f}")在 eRisk 这类文本中,直接用通用 Sentence-BERT 模型做编码往往不够理想。更推荐的做法是:
- 使用领域预训练模型,或者继续在社交平台健康文本上做领域自适应。
- 把症状定义写成多条候选证据,用多向量平均或最大池化来增强稳定性。
- 对用户文本进行窗口切分,而不是只对整段文本编码一次,避免长文本语义被稀释。
3.3 LLM 重排序:从生成到排序
DS@GT-ARC 方案中使用到的 LLM 模型包括 GPT-3.5-turbo、Gemini 1.5 Flash、Llama 3.1 8B、Llama 3.3 70B、Mistral 7B、Mixtral 8x7B 等。这些模型在任务中并不是用来做简单的“是或否”分类,而是承担重排序职责。
重排序的基本思路如下:
- 第一阶段先用稀疏或语义方法召回一批候选句子。
- 对每个候选句子,构建一个包含任务说明、症状定义、用户句子的 Prompt。
- 让 LLM 输出一个相关性评分,或者输出一个包含评分理由的 JSON。
- 根据评分对候选句子重新排序。
使用 LLM 做重排序的一个关键优势是:你可以把“用户讨论 ADHD 症状时常见的表达模式”“这五种症状的边界”“评分标准”等背景知识直接写进 Prompt。
3.4 多路信号融合
为什么不能只依赖一路信号?
- 稀疏信号召回稳定但容易漏掉语义改写。
- 语义信号能覆盖同义表达但训练数据偏差可能造成误判。
- LLM 重排序灵活但成本高、速度慢,不适合对全量候选做排序。
因此,更合理的流程是:
全部候选句子 ↓ 稀疏检索(BM25 / BoWS)—— 快速过滤,保留 Top 200 ↓ 语义检索(Sentence-BERT)—— 跨语言/语义扩展,保留 Top 50 ↓ LLM 重排序 —— 精细打分,保留 Top 10这个流水线既控制了计算成本,又保证了最终排序质量。
4. 核心代码实现
下面给出一个贴近 DS@GT-ARC 思路的完整示例工程。代码以 Python 为主,使用了常见的检索和机器学习库。
4.1 项目结构
adhd_rerank_demo/ ├── data/ │ ├── users.txt │ └── symptoms.json ├── src/ │ ├── sparse.py │ ├── semantic.py │ ├── llm_rerank.py │ └── evaluate.py ├── config.yaml └── main.py4.2 数据预处理
假设用户数据是一个 JSON 文件,每条记录包含用户 ID 和帖子列表。
import json import re def load_users(path: str) -> list[dict]: with open(path, "r", encoding="utf-8") as f: raw = json.load(f) return raw def clean_text(text: str) -> str: text = re.sub(r"http\S+", "", text) text = re.sub(r"@\w+", "", text) text = re.sub(r"\s+", " ", text) return text.strip() def build_user_doc(user: dict) -> dict: content = " ".join(clean_text(p) for p in user["posts"]) return {"user_id": user["user_id"], "text": content}4.3 稀疏检索模块
这里以 BM25 为例,它比 TF-IDF 更适合处理长文本检索。
from rank_bm25 import BM25Okapi import jieba # 如果是中文数据需要分词 def tokenize(text: str, language: str = "en") -> list[str]: if language == "zh": return list(jieba.cut(text)) return text.lower().split() docs = [ "I keep losing my focus and cannot finish anything", "I feel restless and need to move all the time", "I am mad at waiting in line", ] tokenized_docs = [tokenize(doc) for doc in docs] bm25 = BM25Okapi(tokenized_docs) query = "unable to concentrate" scores = bm25.get_scores(tokenize(query))在 eRisk 场景中,你需要把“五类症状定义”作为查询,把用户帖子句子作为候选文档集合。BM25 只负责召回候选,不负责最终判断。
4.4 语义检索模块
语义检索使用的模型需要根据数据语言选择。英文数据可以用all-MiniLM-L6-v2或BAAI/bge-base-en-v1.5,中文数据可以用BAAI/bge-base-zh-v1.5。
from sentence_transformers import SentenceTransformer, util encoder = SentenceTransformer("BAAI/bge-base-zh-v1.5") query_vec = encoder.encode("注意力不集中", convert_to_tensor=True) doc_vecs = encoder.encode(sentences, convert_to_tensor=True) scores = util.cos_sim(query_vec, doc_vecs)[0].tolist()注意:BAAI/bge-*系列模型在计算相似度时通常会给查询添加提示词,具体格式要参考模型卡说明。这里给出的是核心思路,实际项目请结合模型卡提示做调整。
4.5 LLM 重排序模块
使用 OpenAI 风格的 API 接口时,核心是设计好 Prompt 和输出格式。
import json from openai import OpenAI client = OpenAI( api_key="your-api-key", base_url="https://your-endpoint.example.com/v1", ) SYMPTOM_DESC = { "inattention": "注意力不集中:难以持续注意,容易走神,经常丢三落四。", "hyperactivity": "多动:坐不住,总是需要动来动去。", "impulsivity": "冲动:不加思考就行动,容易打断别人。", "organization": "组织问题:计划混乱,桌面杂乱,难以整理任务。", } def llm_rerank(query: str, candidates: list[str], symptom: str) -> list[dict]: results = [] for idx, cand in enumerate(candidates): prompt = f""" 你是一个心理健康文本标注助手。 目标症状:{SYMPTOM_DESC[symptom]} 用户句子:{cand} 请判断:这句话是否与目标症状相关? 只输出 JSON 格式:{{"relevant": 0 或 1, "score": 0.0 到 1.0, "reason": "简短理由"}} """ response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "你是一个严谨的文本标注器。"}, {"role": "user", "content": prompt}, ], temperature=0, response_format={"type": "json_object"}, ) content = json.loads(response.choices[0].message.content) results.append({ "index": idx, "candidate": cand, **content, }) results.sort(key=lambda x: x["score"], reverse=True) return results在生产环境中,强烈建议把温度设置为 0,避免评分抖动。同时给每次调用增加超时限制和重试机制。
4.6 指标计算
实现 NDCG@10、P@10 和 R@10 并不复杂,但要注意边界情况。
def dcg_at_k(relevances: list[int], k: int) -> float: rel = relevances[:k] if not rel: return 0.0 return sum(r / float(rel_idx + 1) for rel_idx, r in enumerate(rel)) def ndcg_at_k(relevances: list[int], k: int) -> float: dcg = dcg_at_k(relevances, k) ideal = sorted(relevances, reverse=True) idcg = dcg_at_k(ideal, k) if idcg == 0: return 0.0 return dcg / idcg def precision_at_k(relevances: list[int], k: int) -> float: return sum(relevances[:k]) / k if k > 0 else 0.0 def recall_at_k(relevances: list[int], k: int, total_relevant: int) -> float: if total_relevant == 0: return 0.0 return sum(relevances[:k]) / total_relevant注意:这里relevances是 0/1 列表,表示排序后前 N 个结果的真实相关性。
4.7 整体流程编排
这里用一个简单的 Python 示例串联整套流程。注意:为了让读者容易理解,这里做了简化,实际项目需要加入异常处理、缓存和日志。
def process_user(user_doc: dict, symptom_defs: dict): text = user_doc["text"] # 1. 稀疏召回 sparse_candidates = sparse_recall(text, symptom_defs, top_k=200) # 2. 语义召回 semantic_candidates = semantic_recall(text, symptom_defs, top_k=50) # 3. 合并候选集 merged = merge_candidates(sparse_candidates, semantic_candidates) # 4. LLM 重排序 reranked = llm_rerank(text, merged, symptom="inattention") return reranked[:10]实际工程中,建议把每一步拆成独立函数,不要让process_user过长。
5. 常见问题与排查思路
5.1 正样本量太少,模型过拟合
现象:训练集上表现很好,测试集上指标大幅下降。
原因:ADHD 用户只有一百多名,模型很容易记住训练集用户特有的表达方式。
排查思路:
- 检查训练集和测试集的用户 ID 是否重叠。
- 使用用户级别的交叉验证,而不是句子级别的随机划分。
- 引入对照组数据做负样本采样,保持比例稳定。
解决方案:
- 使用 BoWS + 逻辑回归作为强基线,不要一开始就用复杂度高的深度模型。
- 在语义模型上使用领域自适应继续预训练。
- 降低模型容量,增加正则化。
5.2 LLM 重排序评分不稳定
现象:同一句话,多次调用 LLM 得到的分数差异很大。
原因:温度参数过高,或者 Prompt 表述模糊。
排查步骤:
- 将
temperature设置为 0。 - 固定输出格式,强制输出 JSON。
- 增加评分锚点,例如给出“完全相关”“部分相关”“不相关”三个示例。
- 对一个候选句子采样多次取平均分。
5.3 稀疏检索漏召回
现象:BM25 召回的候选中没有包含正确答案。
原因:查询词和文档间缺乏词面重合,而 BM25 本质上是词面匹配。
解决方案:
- 扩大召回集合,从 Top 50 扩大到 Top 200。
- 加入同义词扩展,例如把“attention”扩展到“focus”“concentration”。
- 使用伪相关反馈,从第一轮检索结果中提取高频词再查一次。
5.4 候选句子过长导致语义向量失真
现象:一句话超过 512 个 token,编码后语义信息被稀释。
解决方案:
- 对长文本做滑窗切分。
- 同时保留段落级和句子级向量,最后取归一化分数融合。
5.5 API 调用成本过高
现象:LLM 重排序需要对大量候选句子做推理,费用和时间消耗很大。
解决方案:
- 先通过稀疏和语义召回把候选压缩到 10 到 20 条。
- 设置 stage 阈值,例如语义相似度小于 0.4 的句子不进入 LLM 重排序。
- 使用批量推理接口而不是逐条调用。
- 常见开源模型的本地部署可以进一步降低成本。
6. 工程最佳实践与扩展思路
6.1 Prompt 设计要兼顾“任务描述”和“边界定义”
医疗场景下的 LLM 重排序,Prompt 不能只写“这句话是否相关”。还需要:
- 描述 ADHD 症状名词的准确含义。
- 强调“用户表达自己的经历”比“引用网上别人的经历”更有价值。
- 提示模型区分“偶尔懒散”和“长期功能受损”。
一个简单的做法是把症状描述拆成正例、负例、边界例。
6.2 使用缓存机制避免重复推理
对于相同的用户文本和候选句子,LLM 的调用结果是可缓存的。建议用哈希值做缓存键,把 LLM 评分结果存储到本地数据库或 Redis 中。
6.3 引入 Agent 与 MCP 思路
在更复杂的应用中,可以把上述流程封装成一个检索 Agent:
- Agent 接收用户输入,决定先做稀疏检索还是语义检索。
- 当结果不足时,自动调用 Web Search 或数据库查询。
- 当症状类别不明确时,Agent 调用 LLM 进行意图澄清。
MCP(Model Context Protocol)的作用是统一这个 Agent 与外部工具之间的通信协议。它适合把“症状查询工具”“病历数据库”“时间线分析工具”注册成标准的 MCP 工具,让大模型能够按需调用。
6.4 日志和可追溯性
在医疗文本场景,模型输出的可解释性和可追溯性很重要。
- 每次重排序结果都要保存 Prompt 版本、模型版本、温度参数。
- 对每个候选结果保存“为什么排序高/低”的理由摘要。
- 用户级结果需要做二次人工抽检。
6.5 处理跨时间漂移
eRisk 数据来自用户的长期发帖,训练集和测试集可能是不同时间段的数据。社交平台上的流行表达会随时间变化,所以建议:
- 对帖子时间做切分,避免训练集和验证集来自同一时间段。
- 在测试前做“时间敏感特征检查”,例如发现测试数据中出现了大量训练时不存在的新词。
7. 总结与后续学习方向
本文以 DS@GT-ARC 在 eRisk 2026 Task 3 上的方案为背景,梳理了 ADHD 症状句子检测任务中的稀疏检索、语义检索和 LLM 重排序三种信号。核心要点可以总结为以下几点:
- 不要把希望完全寄托在某一种检索模型上,多路召回 + 重排序是更稳定实用的框架。
- 稀疏检索提供稳定基线,语义检索提升语义覆盖能力,LLM 重排序解决细颗粒度判断问题。
- 评估指标不能只看单一指标,P@10、NDCG@10、R@10 需要配合使用。
- 医疗场景中,结果的可解释性和可追溯性比模型精度更重要。
接下来你可以继续学习的内容包括:基于 RAG 的医疗问答系统搭建、LLM 输出结构化解析、跨语言语义检索、以及如何用 MCP 把检索服务接入 Agent 工作流。如果本文对你有帮助,可以收藏备用,后续搭建检索系统时对照着做多路召回和重排序,会少踩很多坑。