ADHD症状检索实战:稀疏检索+语义召回+LLM重排序混合管道
假如你正在做心理健康领域的文本挖掘比如从社交平台公开语料中识别“注意缺陷多动障碍ADHD”相关症状你会发现一个典型的工程困境用户表达极其口语化“脑子一团浆糊”“事情永远拖到最后”“明明想专注却坐不住”这些句子和标准症状词表几乎没有重合词传统关键词检索大概率直接漏掉而如果把全部候选都交给大模型逐一打分又面临推理成本、延迟和上下文长度三重压力。eRisk 2026 Task 3 的“ADHD 症状句子”任务本质上就是这样一个信息检索与排序问题给定症状查询从用户生成内容中找回相关句子。从团队方案 “DSGT-ARC” 的题目看它走的是一条值得单独拆解的技术路线——Sparse、Semantic 和 LLM Reranking 三段式管道。这篇文章不打算复述竞赛论文而是把它当成一个完整 NLP 工程案例来分析为什么要用混合召回为什么要靠大模型兜底精排管道里哪些环节最容易踩坑以及一套能直接在自己数据集上跑起来的最小实现。读完你会得到三个东西一是对信息检索中 Sparse/Semantic/LLM Reranking 三件套的清晰理解二是一套可复制的 Python 检索管道代码三是心理健康文本场景下沉甸甸的工程建议。1. 这篇文章真正要解决的问题先说结论ADHD 症状句子检索表面看是一个文本分类或语义匹配问题实际更贴近“句子级信息检索”。系统拿到的是某条症状描述比如 inattention、executive dysfunction要在一个很大的候选取证库里找出表达该症状的句子。难点有两个层面。第一层是语义鸿沟。ADHD 人群的文字表达往往不直接使用 DSM-5 里的临床术语而是用生活化、情绪化的语言描述体验。比如“我经常把作业拖到最后一刻才做”表达的是 procrastination 和执行功能障碍但和“procrastination”这个词没有字面交集。经典 BM25 在这类场景下会大面积漏召回。第二层是长尾工程成本。嵌入模型能部分缓解词汇鸿沟但对“包含明显症状信号、需要结合语义语境判断”的句子向量相似度并不总可靠。最理想的做法是把候选句子全部丢给参数量更大的 LLM 去判断但在真实数据集上候选量可能是几万甚至几十万条逐条生成式打分的时间和 API 费用不可接受。于是有了这条业界已经验证过多次的架构先用稀疏检索和语义检索做高效召回得到一个小规模的候选集合再用 LLM 在候选集上做精细重排。DSGT-ARC 方案的核心就是这个思路它不是某个孤立技巧而是一套可以复用到搜索、RAG、比赛任务上的通用范式。什么样的读者最需要这篇文章如果你在做 RAG 检索优化、对话系统知识召回、心理健康文本分析或者想参加类似评测任务但不想从零试错那么这篇文章里的概念拆解、管道设计和代码实现能帮你少走很多弯路。如果你只想快速了解大模型重排序怎么做第 5 节的代码可以直接复用。2. Sparse、Semantic 与 LLM Reranking三个关键概念2.1 稀疏检索 Sparse Retrieval信息检索里最早被大规模使用的就是稀疏表示。它的核心思路是对文本做词项切分然后用词频、逆文档频率等统计量给每个词加权最终用向量表示一篇文章其中大部分维度是 0所以叫“稀疏”。BM25 是目前最经典的稀疏排序函数。它的公式里有两个关键参数k1 控制词频饱和度b 控制文档长度归一化程度。近义词“无法集中注意力”和“很难专注”在 BM25 看来是两套完全不同的词它们的相似度为 0这是稀疏检索的致命短板即词汇鸿沟问题。但稀疏检索依然被广泛使用原因是三个字稳、快、省。它不需要 GPU不需要提前训练模型对精确术语、专有名词、医学术语的匹配非常可靠。在健康文本场景下“ADHD”“hyperfocus”“RSD”这类词BM25 能精确命中。在一个混合检索管道里BM25 的价值更多是保证底线召回确保那些语义模型可能忽略的稀有词不被漏掉。2.2 语义检索 Semantic Retrieval语义检索的思路和稀疏检索完全不同。它不再依赖词面重合而是把句子整体映射到一个稠密向量空间中让语义接近的句子距离更近。常用实现是基于 Transformer 的嵌入模型比如 Sentence-BERT、E5、BGE 系列。以 Sentence-BERT 为代表的 Bi-Encoder 结构查询和文档分别编码为两个向量再用余弦相似度计算相关性。这种设计的优点是可以提前把文档向量化并建索引查询时只需编码 query 一次然后做向量检索性能很好。它在很多场景下能克服词汇鸿沟比如“脑子像浆糊”和“思维迟缓”虽然词面完全不同但在语义空间里可能是相近的。缺点是嵌入模型对领域数据敏感。通用预训练模型可能不理解 ADHD 相关的特殊表达、网络俚语和口语化症状描述。所以实际做语义召回时要么选择在心理健康语料上继续训练过的模型要么准备一批领域内相似样本做领域自适应微调。另一个问题是嵌入模型对否定、程度副词等细节不够敏感“我没有注意力问题”和“我有注意力问题”的向量可能非常接近。2.3 LLM 重排序 LLM Reranking重排序阶段使用的 LLM可以是闭源 API也可以是本地部署的开源模型。它不再像 Bi-Encoder 那样把查询和文档分别编码而是把查询和候选句子拼在一起让模型直接判断相关程度。因为模型能看到完整上下文所以它对语义细节、否定、反讽、隐含症状的判别能力明显更强这就是“精排”的意义。LLM 重排序在实现上大体有三种策略Pointwise让模型对每个候选句子独立输出一个相关度评分。逻辑最简单适合第一版跑通。Pairwise一次给模型两个候选句子让它输出更相关的那一个。理论上精度更高但比较次数多总调用量成倍增长。Listwise把多个候选句子一起输入模型让它输出一个排序后的列表。性能最优但受上下文窗口限制。从比赛和工程实践来看先用 Pointwise 做初筛再用 Pairwise 或 Listwise 做最后微调是比较稳妥的节奏。考虑到候选集通常已经通过召回压缩到几十条以内即使每条候选调用一次 LLM成本也可控。这里还要提一个容易混淆的点Cross-Encoder交叉编码器经常被混进“LLM Reranking”。两者思路类似都是把查询和文档拼接后共同编码但 Cross-Encoder 通常指 BERT 规模的判别式模型输出一个相关性分数而标题里说的 LLM Reranking 更多指生成式大模型通过指令或打分 token 判断相关性。工程上如果预算紧张可以先用小型 Cross-Encoder 精排再用大模型只处理 Top-10性价比最高。2.4 三者的关系与分工用一个简单类比来理解三者关系假设你要从一个大型仓库里找“描述拖延症”的文件。稀疏检索相当于先看每份文件里有没有“拖延”“deadline”“进度”这些字眼快的代价是会漏掉“我一直在刷手机却不想动”这种表达。语义检索像一个有经验的图书管理员能根据大概意思找到相似内容但偶尔也会把无关内容误拿进来。LLM 重排序则像最后请来一位专家把前两步找出来的候选文件挨个精读最终给出可信的判断。所以整条管道是互补关系不是替代关系。三者配合的意义在于用最低成本淘汰绝大多数无关数据再用最强模型保证最终结果质量。这个思想在 Facebook 的 ANN 检索、RAG 主流框架、各类评测任务里几乎成了标准动作。方案匹配粒度优点缺点典型工具Sparse BM25词项统计无 GPU 要求、精确术语强、可解释词汇鸿沟、无法处理同义表达rank_bm25、ElasticsearchSemantic句子向量语义近义识别强、可预先索引对领域表达敏感、细节判别弱Sentence-BERT、BGELLM Rerank全文本理解上下文理解强、可处理复杂语义成本高、延迟高、依赖调用量控制OpenAI API、本地 LLM3. 系统架构设计从 Query 到 Top-K3.1 整体流程一个完整的 ADHD 症状句检索系统大致分为四个环节查询处理、双路召回、候选融合、LLM 精排。查询处理阶段要做的不是简单把症状词扔进检索器而是对查询做展开和改写。比如原始查询是“inattention”可以扩展成“很难集中注意力”“容易走神”“上课听不进去”等表达。查询扩展可以在关键词层面做也可以借助 LLM 生成同义表达能明显提升召回率。双路召回阶段稀疏检索和语义检索同时运行。两条通道各自返回 Top-K 候选句子然后做并集融合去掉重复项。融合时如果某条候选只在一条通道出现它依然有机会进入精排阶段这保留了单路模型可能错杀、但另一路能捞回来的可能性。精排阶段把融合候选集输入 LLM 重排序模块。LLM 逐个判断候选句子与症状查询的相关程度输出分数。最后按分数排序取 Top-N 作为最终输出。3.2 召回阶段为什么不能只靠一路很多初学检索的人会把宝压在语义模型身上觉得有嵌入向量就够了。但只靠语义向量召回往往会遗漏低频、但判别性极强的临床术语。反过来只靠 BM25长尾表达又会被漏掉。两路召回相当于给系统上了双保险。实际操作中还要注意两路召回的 Top-K 值设置。如果 Top-K 太小融合后的候选集可能不够覆盖所有正例如果太大后续 LLM 精排的成本会明显上升。比较稳的做法是先分别取 20 到 50 条跑完精排再看评估指标回退调整。从工程经验看BM25 的 Top-K 可以比语义检索稍微小一点因为语义检索贡献的多样性往往更多。3.3 精排阶段用 LLM 做“人”的判断精排是整个系统中对结果质量贡献最大的一环。LLM 的优势在于它能读完整句子并理解上下文。ADHD 症状句子的判断难点往往不是关键词而是句子所隐含的状态描述。比如“我今天又啥都没干”这句话单独看很模糊结合上下文可能是在描述拖延和执行困难这种情况下 BERT 向量和 BM25 都很难给出高置信度LLM 却可以结合表达方式做推理。精排时 Prompt 设计非常关键。要把症状定义、判断标准、评分规则写清楚最好给一两个正例和负例作为 few-shot 参考。这里最容易犯的错误是让 LLM 直接输出“是/否”因为句子相关度本来就存在模糊地带硬二分类会丢失大量信息。给一个连续分数更合理。3.4 延迟与成本权衡如果每秒要处理大量查询把每一条候选都交给大模型打分显然不现实。一个被验证过的工程做法是分层级联先让嵌入式模型做一个粗排只把 Top-10 或 Top-20 交给 LLM 做最终重排。这样既控制了成本又保证核心结果质量。类似思路在搜索引擎里叫做级联排序在 RAG 里也是标配。DSGT-ARC 方案真正有价值的不是某个单点模型的先进而是把“高效召回 复杂精排”组合成了一个成本和质量都可控的系统。这提示我们在论文复现时永远要把工程约束放在第一优先级。4. 环境准备与前置条件下面是跑通本文代码所需的基础环境。以 Python 3.9 或 3.10 为主其他版本也大概率兼容。建议使用虚拟环境避免污染系统 Python。python -m venv adhd-rerank-env source adhd-rerank-env/bin/activate pip install --upgrade pip核心依赖库如下rank_bm25BM25 稀疏检索实现sentence-transformers语义嵌入模型加载与编码torch深度学习框架安装 CPU 或 GPU 版本按机器情况决定openai调用 OpenAI 兼容接口的客户端库tiktokentoken 计数用于控制提示词长度安装命令pip install rank_bm25 sentence-transformers torch openai tiktoken语义嵌入模型建议选择支持文本匹配任务的通用模型例如 BGE-small 或 BGE-base。具体的模型名称以实际项目可用版本为准本文代码里的模型名只是一个占位演示建议替换成你实际可选用的模型。如果你希望完全本地化运行 LLM 重排序也可以用 vLLM 或 llama.cpp 自托管一个开源模型然后暴露 OpenAI 兼容接口。这样代码无需变化只需要把 base_url 改成本地地址。这个思路好处是数据不出内网对心理健康文本场景尤其重要。数据集方面eRisk 任务基于真实的社交媒体语料由于隐私和合规限制很多原始数据并不公开但你可以用任何带“句子级标签”的文本构造自己的评测集。最简形式就是一个 JSON 文件包含查询、候选句子、相关性标签标签为 1 表示相关0 表示不相关。后续代码演示都以这种格式为基础。5. 核心代码实现5.1 BM25 稀疏召回先用 rank_bm25 构建一个最简单的稀疏检索器。这里的关键是分词方式。英文直接用空格切分即可中文场景则建议使用 jieba 分词否则 BM25 的效果会打折扣。# 文件路径sparse_retriever.py from rank_bm25 import BM25Okapi import jieba def tokenize(text: str, language: str en) - list[str]: 按语言选择分词策略。 text text.lower().strip() if language zh: return list(jieba.cut(text)) return text.split() class SparseRetriever: def __init__(self, corpus: list[str], language: str en): self.language language tokenized_corpus [tokenize(doc, language) for doc in corpus] self.bm25 BM25Okapi(tokenized_corpus) self.corpus corpus def retrieve(self, query: str, top_k: int 30) - list[tuple[str, float]]: tokenized_query tokenize(query, self.language) scores self.bm25.get_scores(tokenized_query) ranked sorted( enumerate(scores), keylambda x: x[1], reverseTrue ) return [(self.corpus[idx], scores[idx]) for idx, _ in ranked[:top_k]]这段代码里BM25Okapi 初始化时接收已经分好词的文档列表。之后每次查询只需要调用 get_scores 得到所有文档的得分再取 Top-K。实际工程中如果候选文档很多应该用 Elasticsearch 这一类倒排索引引擎来做而不是全量扫内存但原理一致。5.2 语义召回用 sentence-transformers 实现语义检索。先加载嵌入模型再对查询和文档做向量化。得到向量后可以用 sklearn 的 cosine_similarity或者直接对归一化向量做点积。# 文件路径semantic_retriever.py from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity import numpy as np class SemanticRetriever: def __init__(self, model_name: str BAAI/bge-small-en-v1.5): self.model SentenceTransformer(model_name) self.corpus_embeddings None self.corpus None def index(self, corpus: list[str]): self.corpus corpus self.corpus_embeddings self.model.encode( corpus, normalize_embeddingsTrue, show_progress_barTrue ) def retrieve(self, query: str, top_k: int 30) - list[tuple[str, float]]: query_embedding self.model.encode( [query], normalize_embeddingsTrue ) similarity cosine_similarity(query_embedding, self.corpus_embeddings)[0] ranked np.argsort(similarity)[::-1][:top_k] return [(self.corpus[idx], float(similarity[idx])) for idx in ranked]语义检索的关键参数是 normalize_embeddings。设为 True 后向量都做了 L2 归一化此时点积等价于余弦相似度计算更高效。如果数据量大应该引入 FAISS 或 Milvus 做 ANN 索引进一步压缩查询延迟。5.3 LLM 重排序LLM 重排序是整条管道里最灵活的一环。这里以 OpenAI 兼容接口为例用一个 Pointwise 打分函数让 LLM 输出 0 到 10 之间的相关度分数。# 文件路径llm_reranker.py from openai import OpenAI import json class LLMReranker: def __init__(self, api_key: str, base_url: str None, model: str gpt-4o-mini): self.client OpenAI(api_keyapi_key, base_urlbase_url) self.model model def _build_prompt(self, query: str, candidate: str) - str: return f 你正在辅助一个心理健康症状检测系统。 你的任务是判断【候选句子】是否表达或描述了查询中的 ADHD 相关症状。 查询症状{query} 候选句子{candidate} 请根据相关程度输出一个 0 到 10 之间的整数分数。 评分标准 - 10 分句子直接、明确地描述了该症状。 - 5 分句子与症状相关但表达比较模糊或间接。 - 0 分句子与症状完全无关。 只输出分数不要输出任何解释。 .strip() def rerank_sentence(self, query: str, candidate: str) - float: prompt self._build_prompt(query, candidate) response self.client.chat.completions.create( modelself.model, messages[{role: user, content: prompt}], temperature0, max_tokens10, ) raw response.choices[0].message.content.strip() try: return float(raw) except ValueError: return 0.0 def rerank(self, query: str, candidates: list[str]) - list[tuple[str, float]]: scored [] for candidate in candidates: score self.rerank_sentence(query, candidate) scored.append((candidate, score)) return sorted(scored, keylambda x: x[1], reverseTrue)这段代码有几个值得注意的地方。温度强制设为 0保证输出稳定可复现。max_tokens 设为 10 而不是默认的很大值一方面省钱另一方面也能引导模型尽量只输出分数而不是长篇解释。实际调试时如果模型经常输出非数字内容可以在提示词里追加“只能输出整数”这种强约束同时保留解析失败时返回 0 的兜底。如果本地部署开源模型可以把 base_url 指向 vLLM 或 llama.cpp 的服务地址API 部分不需要改动。这样既能保护心理健康文本的数据隐私也能显著降低单条打分成本。5.4 把三段管道串起来下面这个 Pipeline 类把稀疏召回、语义召回、LLM 精排整合成完整链路。# 文件路径pipeline.py from sparse_retriever import SparseRetriever from semantic_retriever import SemanticRetriever from llm_reranker import LLMReranker class RetrievalPipeline: def __init__(self, corpus: list[str], llm_reranker: LLMReranker, language: str en, sparse_top_k: int 30, semantic_top_k: int 30, final_top_k: int 10): self.sparse SparseRetriever(corpus, languagelanguage) self.semantic SemanticRetriever() self.semantic.index(corpus) self.reranker llm_reranker self.sparse_top_k sparse_top_k self.semantic_top_k semantic_top_k self.final_top_k final_top_k def retrieve(self, query: str) - list[tuple[str, float]]: sparse_results self.sparse.retrieve(query, top_kself.sparse_top_k) semantic_results self.semantic.retrieve(query, top_kself.semantic_top_k) merged {} for sentence, score in sparse_results semantic_results: if sentence not in merged: merged[sentence] score else: merged[sentence] max(merged[sentence], score) candidates sorted(merged, keymerged.get, reverseTrue) # 为控制成本先截断到 50 条再交给 LLM candidates candidates[:50] return self.reranker.rerank(query, candidates)[: self.final_top_k]管道里唯一需要解释的是候选融合策略。这里对重复句子直接取两路得分的最大值后续你完全可以根据实验把策略改成加权融合或者乘积融合。但第一版用 max 最简单也便于定位问题。6. 运行结果与效果验证6.1 运行方式准备好语料后用下面的方式运行完整管道corpus [ I keep procrastinating on every assignment until the last minute., I hyperfocus on random hobbies for hours and forget to eat., My brain feels like its full of fog and noise all the time., I bought a new notebook to organize my life but I never use it., The weather is nice today and I want to go for a walk., ] query executive dysfunction and procrastination pipeline RetrievalPipeline( corpuscorpus, llm_rerankerLLMReranker(api_keysk-xxx, modelgpt-4o-mini), languageen, sparse_top_k30, semantic_top_k30, final_top_k3, ) results pipeline.retrieve(query) for sentence, score in results: print(f{score:.1f}\t{sentence})预期输出中前两条应该明显与拖延和执行功能障碍相关而“The weather is nice”这类无关句子应该被排到最后。如果运行后出现 API 报错先检查网络连接、API Key 是否有效、模型名是否被当前服务商支持。6.2 评估指标对于检索排序任务只看最终输出个大概效果是不够的必须用定量指标衡量。推荐几个从竞赛到工业界都在用的指标指标含义用途RecallK前 K 条结果中命中的正例数占全部正例比例评估召回能力PrecisionK前 K 条结果中真正相关的比例评估精排准确性MRR第一个正例出现位置的倒数均值适合单目标查询nDCGK按相关等级加权的排名质量最常用于通用排序最稳妥的评测方式是先人工标注一小批测试集运行管道后计算上述指标。你不需要在初始阶段追求完美标注哪怕先标 50 个查询也能暴露不少问题。6.3 结果判读如果 RecallK 偏低问题大概率出在召回阶段需要扩大 Top-K、优化查询扩展或者更换语义嵌入模型。如果 RecallK 正常但 PrecisionK 偏低问题就在 LLM 重排序阶段需要检查提示词的评分标准是否模糊、候选集中是否混入大量相似但无关句子。这里最容易被忽视的是查全率和查准率在健康文本场景下同等重要。漏掉一个症状句可能意味着错过干预机会而误判太多又会降低系统可信度。所以指标分析不能只看单一指标。7. 常见问题与排查思路问题现象可能原因排查方式解决方案BM25 召回结果完全无效分词不合适中文场景误用空格切分打印 tokenized_corpus 观察分词结果引入 jieba 等专用分词器处理同义词语义召回结果和关键词几乎无关嵌入模型与领域不匹配随机挑几条句子做向量相似度可视化换领域特化模型或用领域数据微调LLM 总是输出非数字内容提示词约束不足、模型理解偏差查看模型完整输出内容增加输出格式约束使用 few-shot 示例候选集太大导致 API 成本过高召回 Top-K 设置过大统计每轮查询平均候选数先截断到 20-50 条再进 LLM同样查询多次结果不稳定温度参数未设为 0检查请求参数temperature0必要时固定随机种子数据包含个人信息原始社交语料未做脱敏检查语料来源和字段强制脱敏、匿名化最小权限访问如果你在本地跑通后发现某一类问题反复出现建议按“召回质量 - 融合策略 - 重排序提示词”的顺序逐步排查不要一上来就改模型那样只会引入更多变量。8. 最佳实践与工程建议结合心理健康文本挖掘和信息检索的长线工程经验这里给出几条值得写在项目文档里的实践原则。第一查询扩展要放在检索之前。原始症状名往往和用户表达存在巨大差异直接用原始词查询很容易漏检。可以准备一个症状同义词表也可以让 LLM 为每个症状生成 5 到 10 个用户视角的表达再把这些表达混入查询。这个步骤对 RecallK 的提升往往是最明显的。第二嵌入模型要领域特化。通用 Sentence-BERT 在新闻、百科语料上表现不错但面对 ADHD 社群里的黑话、梗和情绪化表达容易失效。三个可行路径收集一定数量的领域句子对做对比学习微调选择已经在 Reddit 等社交媒体语料上训练的开源嵌入模型至少做一次人工评估确认嵌入模型对典型症状表达的敏感性。第三重排序的提示词要写“判断标准”不要只写“判断”。告诉模型你关注的要素比如“句子是否描述了注意力缺陷、冲动控制或多动表现”“是否是第一人称的具体经历描述”“是否具有时间持续性”比笼统地要求“判断相关性”更有效。可以准备两个正例和两个负例放进提示词里做 few-shot。第四严格做好隐私合规。心理健康文本属于高敏感数据无论做比赛还是做产品原型都要遵循最小权限原则。数据脱敏、访问审计、模型服务内网部署是基本要求。不要为了效果把敏感文本发送到不受控的外部服务。如果必须使用外部 LLM API优先选择签署了数据保护协议的服务或者干脆用自托管模型。第五评估集要和真实分布对齐。用人工构造的干净句子做测试往往会让系统看起来比实际好用很多。更靠谱的做法是从真实语料里采样并标注多包含一些模糊句、否定句和隐喻句。这样模型上线后出现“实验室高分、真实场景崩盘”的概率会低很多。第六把管道设计成可插拔。不同任务的融合策略、Top-K 参数、提示词风格差异很大。建议从一开始就把 Sparse、Semantic、Reranker 封装成独立模块通过配置文件控制超参数方便后续做消融实验。9. 总结与后续学习方向从 DSGT-ARC 这个方案里最能直接复用的不是某一行代码而是“分层治理”的思路。召回层用 Sparse 和 Semantic 互相兜底精排层用 LLM 保证顶级质量中间用候选融合和截断控制成本。这套设计在信息检索领域沉淀了很多年在 RAG、搜索引擎、健康文本分析里反复出现。顺着这篇文章下一步你可以做三件事。先把自己手头的小规模语料跑通上面这套最小管道然后加一个离线评估模块用 RecallK 和 nDCGK 监控每次改动是变好还是变坏最后尝试把 Pointwise 重排序换成 Pairwise在 Top-10 范围内体验一下精度和成本的差异。心理健康领域的文本挖掘还有很多值得深入的方向针对 ADHD 症状的术语本体构建、多语言社交媒体语料的域适应、结合时间线做早期风险检测以及把 RAG 能力引入到症状解释和报告生成中。每一项都能和今天的检索管道拼在一起形成更大的系统。但无论怎么做第一原则不变一切效果优化都必须在可控成本、合规数据、可评估的闭环里进行这样你的系统才不是比赛里的花瓶而是能真正支撑业务的技术底座。