在实际的自然语言处理应用中,ADHD 症状句子的检索与排序并不只是把 query 和文章做关键词匹配。eRisk 2026 Task 3 这类早期风险预测任务,目标是从用户生成内容里找到与注意缺陷多动障碍相关的症状描述。DS@GT-ARC 方案标题中的 Sparse、Semantic、LLM Reranking,实际上给出了一条非常典型的技术主线:先用稀疏检索保证覆盖面,再用语义检索解决同义改写,最后用大语言模型对候选句子做细粒度重排序。这条主线不只是竞赛能用,放到日常的垂直搜索、智能问答、文档召回场景里也值得复用。
本文会用一套可运行的最小实现把这条链路拆开讲清楚:每阶段解决什么问题、核心参数怎么理解、常见报错怎么排查、评估指标怎么落地。读者不需要有 eRisk 的背景,只要掌握 Python 基础,并且对信息检索或文本分类有基本概念,就能顺着代码把流程跑起来。示例代码用于说明思路,实际项目需要根据自己的包名、路径、模型版本和评估集做调整。
1. 问题定义:ADHD 症状句子为什么需要多级检索
1.1 eRisk Task 3 在做什么
eRisk 是 CLEF 体系下的早期风险预测评测,历年来更关注从社交媒体流中提前发现心理风险信号。Task 3 的具体设定会随年份变化,但围绕 ADHD 症状句子的识别、检索或排序是常见的核心目标。可以把它理解成一项“句子级检索”任务:给定一组用户表达,或者给定一个查询意图,系统需要从候选语料中返回最有可能描述 ADHD 症状的句子。
这类任务和普通文本分类的差别在于,它并不是在已经切好的句子上简单打标签,而是经常需要先面对海量未标注文本,把真正值得关注的句子捞出来。实际数据里大量句子是日常闲聊,真正描述“注意力难以集中”“做事拖延”“容易冲动”等表现的句子只占很小比例。如果先做全量分类,成本高且类别严重不均衡。更常见的做法是:先召回候选,再精排,最后才判断是否命中。
1.2 单一检索方法为什么不够
只靠关键词匹配,也就是稀疏检索,会把同义改写和口语化表达漏掉。比如“我总把作业拖到最后一天”和“我很难按时开始任务”都可能是执行功能受损的信号,但两者与“ADHD”这个关键词并没有直接重合。
只靠语义向量检索,能缓解同义改写问题,但也会引入噪声。语义相近不代表真正描述 ADHD 症状。比如“我最近很焦虑,什么都没做完”在向量空间里可能和“我无法集中注意力完成任务”距离很近,但前者更多描述情绪状态,并不等于是 ADHD 的症状证据。
只靠 LLM 直接对全部语料排序,从理论上可行,但成本和时间都难以接受。LLM 适合在候选数量可控时做精细化判断,不适合从几十万句子里全量挑选。多级检索的核心逻辑就是:用便宜的方法缩小范围,把贵的方法留给最后一小批候选。
1.3 多级检索与重排序的整体链路
这里采用的方案可以抽象成四个阶段:
- 稀疏检索从全量语料中取出 top N 候选,保证关键词覆盖。
- 语义检索从全量语料中取出 top N 候选,补充同义改写和语义相关文本。
- 对两路候选做融合去重,得到混合候选集合。
- 将混合候选交给 LLM 重排序,输出最终 top K。
每一级的目标都不同。稀疏检索负责“不漏词”,语义检索负责“不漏意”,LLM 重排负责“在候选里找最像答案的那几条”。这个分离设计的好处是每个阶段都可以独立验证、独立调优、独立替换。后续每部分都会围绕这条主线展开。
2. 环境准备:先把依赖、目录和数据格式对齐
2.1 技术栈与依赖清单
为了把示例代码跑起来,建议使用 Python 3.10 或更高版本。核心依赖如下:
| 依赖 | 用途 |
|---|---|
| rank-bm25 | 实现 BM25 稀疏检索 |
| sentence-transformers | 加载句子向量模型并生成 embedding |
| faiss-cpu / faiss-gpu | 构建向量索引并执行近邻检索 |
| numpy | 向量数组处理和索引写入 |
| pandas | 读取表格或做一些结果分析 |
| openai | 调用兼容 OpenAI 协议的本地或远程 LLM 服务 |
安装命令如下:
python -m pip install rank-bm25 sentence-transformers faiss-cpu numpy pandas openai注意,faiss 的 CPU 版本适合学习和中小规模数据;如果语料达到百万级别,建议使用 GPU 版本或专门的向量数据库。LLM 部分可以使用本地 vLLM 服务,也可以使用内部 API 服务,但不管用哪种,都要先确认 base_url 和 api_key 配置正确。
2.2 项目目录与 JSONL 数据格式
建议先创建一个干净的项目目录:
adhd_symptom_search/ ├── data/ │ ├── corpus.jsonl │ ├── queries.jsonl │ └── qrels.json ├── src/ │ ├── sparse.py │ ├── dense.py │ ├── fuse.py │ └── rerank.py ├── scripts/ │ └── run_pipeline.py └── outputs/corpus.jsonl 的每一行是一个候选句子,字段可以包含 id、text 和 label。label 在训练阶段可以用来做评估,但实际预测时不一定需要。
{"id": "s1", "text": "I keep putting off my assignments until the last minute.", "label": 1} {"id": "s2", "text": "The weather was really nice today.", "label": 0}queries.jsonl 保存检索查询或需要判断的待匹配文本,格式可以保持一致:
{"id": "q1", "text": "difficulty starting tasks"}qrels.json 保存人工标注的相关性,用于评估排序质量:
{ "q1": ["s1", "s5", "s12"] }如果原始语料没有标注,可以先用少量样本人工标注,再逐步扩大评估集。没有评估集的检索系统很难判断参数改对了还是改错了。
2.3 环境检查清单
在写正式代码前,先做一遍环境检查,避免后面把所有问题都混在一起。
- 确认 Python 版本,建议使用
python --version检查。 - 确认依赖安装成功,至少
python -c "import rank_bm25, faiss, sentence_transformers"不报错。 - 确认 sentence-transformers 能加载目标模型。如果网络环境不允许首次下载模型,要提前把模型下载到本地缓存目录。
- 构造一个包含 3 个句子的最小测试集,跑通向量编码,确保输出维度一致。
- 如果使用 FAISS,先写入一个小索引再读取,确认索引序列化路径没问题。
- 如果使用 LLM,先测一个单条请求,确认响应格式和解析函数能正常工作。
这一套检查通常不会超过 15 分钟,但能节省不少排查时间。
3. 稀疏检索:用 BM25 先保证关键词覆盖
3.1 BM25 原理与两个重要参数
BM25 是经典稀疏检索算法,核心思想是:一个词在文档中出现次数越多,文档和查询越相关;但文档中常见词会被词频饱和度削掉;长文档需要对词频做归一化,避免因为篇幅长就更容易命中。
其得分公式可以简化为:
score(D, Q) = sum over query term q: IDF(q) * f(q, D) * (k1 + 1) / (f(q, D) + k1 * (1 - b + b * |D| / avgdl))参数 k1 控制词频饱和度。k1 越大,词频增加对分数的影响越不敏感。参数 b 控制文档长度归一化强度,b 越接近 1,长文档惩罚越强;b 越接近 0,文档长度影响越小。
在 rank_bm25 中,BM25Okapi 默认使用 k1=1.5,b=0.75,是一组比较通用的起点值。实际使用时需要根据语料调整。比如候选句子之间长度差异很大,b 可以适当调大;如果所有文档长度都比较接近,b 的影响就相对小。
3.2 rank_bm25 实现最小索引
先定义一个统一的分词函数,保证查询和文档使用同一种预处理逻辑。英文场景可以直接用小写加正则提取字母数字:
import re def tokenize(text: str) -> list[str]: return re.findall(r"[a-z0-9]+", text.lower())构建索引并查询:
from rank_bm25 import BM25Okapi # corpus_items 是读取 corpus.jsonl 后得到的字典列表 corpus = [item["text"] for item in corpus_items] tokenized_corpus = [tokenize(doc) for doc in corpus] bm25 = BM25Okapi(tokenized_corpus, k1=1.2, b=0.75)对单个查询召回候选:
query = "difficulty starting tasks" tokens = tokenize(query) scores = bm25.get_scores(tokens) top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:50] sparse_results = [corpus_items[i]["id"] for i in top_indices]这段代码的要点是:BM25 只依赖词粒度,不依赖语义模型,因此速度快,适合在第一阶段做粗召回。必须保证 tokenize 在查询和文档两侧一致,否则查出来的结果会严重失真。
3.3 稀疏检索常见坑
第一个坑是查询词全部被预处理成空列表。比如只过滤掉所有词,或者正则表达式对中文不友好。如果发现 BM25 返回全 0,先打印 tokenize 后的结果看是否为空。
第二个坑是停用词处理。对于 ADHD 症状检索,类似 not、difficult、trouble 这样的词往往承载关键信号,不能盲目删除。建议先不做停用词过滤,等观察结果后再说。
第三个坑是长短句差异。有些句子只有几个词,有些句子是一整段。默认参数下,短句更容易获得高权重,但实际语料中长句可能包含更多真实信息。这时不要急着调 k1 和 b,先看一版结果,通过人工检查确认方向,再小范围调参。
4. 语义检索:用向量召回补上同义改写
4.1 句子嵌入与 FAISS 索引
语义检索把句子映射成固定维度向量,再通过向量距离衡量语义相关度。只需要加载一个预训练句子向量模型,比如常见场景里可以使用 MiniLM、bge、gte 等系列模型,但具体选哪个版本要根据语料语言、运行资源和评测结果定。这里以 sentence-transformers 通用写法为例:
from sentence_transformers import SentenceTransformer model_name = "sentence-transformers/all-MiniLM-L6-v2" model = SentenceTransformer(model_name) corpus_embeddings = model.encode( corpus, normalize_embeddings=True, batch_size=64, show_progress_bar=True, )normalize_embeddings=True表示输出向量已经做了 L2 归一化。归一化之后,用内积计算相似度等价于余弦相似度,而且 FAISS 的 IndexFlatIP 可以直接使用。
构建向量索引:
import faiss import numpy as np dim = corpus_embeddings.shape[1] index = faiss.IndexFlatIP(dim) index.add(corpus_embeddings.astype("float32"))查询时把查询文本编码成同样维度的向量:
query_vec = model.encode([query], normalize_embeddings=True) scores, indices = index.search(query_vec.astype("float32"), k=50) dense_results = [corpus_items[i]["id"] for i in indices[0]]注意,FAISS 的索引返回顺序是相似度从高到低,但对 IndexFlatIP 来说,分数越大表示越相似。后续计算排序指标时,要确认你使用的库返回的是 score 降序还是升序。
4.2 向量检索的归一化与维度问题
向量检索最容易出现的错误是维度不一致。换了一个 embedding 模型或换了一个版本后,向量维度可能发生变化,但 FAISS 索引是按旧维度创建的,会直接报 shape 不匹配。解决方式很简单:索引文件名里带上模型名和维度,或者每次重建索引前显式校验。
另一个问题是向量范围不稳定。不同模型输出的分数分布差异很大,有些模型在 0.5 以上才算相关,有些模型可能 0.7 才是相关。融合多路检索时,最好先把分数归一化或直接使用排名而不是原始分数。
批次编码时还要注意内存。假设 10 万条句子,每条 384 维,用 float32 存储,大约需要 10 万乘 384 乘 4 字节,约 153 MB,这是可以接受的。但如果是千万级语料,建议使用向量数据库或分片索引。
4.3 混合召回:用 RRF 融合两路结果
稀疏检索和语义检索各有短板,最直接的做法不是拼分数,而是拼排名。RRF 是常用且稳定的融合方式,公式如下:
RRF score(d) = sum over ranking r in R of 1 / (k + rank_r(d))其中 rank_r(d) 从 1 开始,k 是平滑参数,常见取 60。RRF 不关心两路分数是否在同一量纲,只关心每个文档在各自列表里的排名,因此稳定性更好。
融合实现:
def rrf_fuse(rankings: list[list[int]], k: int = 60) -> list[int]: fusion_scores = {} for ranking in rankings: for rank, doc_idx in enumerate(ranking): fusion_scores[doc_idx] = fusion_scores.get(doc_idx, 0.0) + 1.0 / (k + rank + 1) return [ doc_idx for doc_idx, _ in sorted( fusion_scores.items(), key=lambda x: x[1], reverse=True ) ]使用方式:
sparse_ids = [corpus_items[i]["id"] for i in top_indices] dense_ids = [corpus_items[i]["id"] for i in indices[0]] fused_ids = rrf_fuse([sparse_ids, dense_ids])[:40]融合后的候选集通常比任何单一路更完整。实际项目里可以先把两路各自的 Recall@50 都算出来,如果某一路有明显问题,再考虑调整这一路的检索参数,而不是盲目改融合权重。
5. LLM 重排序:从语义相关到症状命中
5.1 为什么召回之后还要重排序
混合召回的候选仍然可能存在两种典型问题。第一种是语义相近但并未命中 ADHD 症状,比如一些描述焦虑、情绪低落或一般性生活压力的句子。第二种是句子确实相关,但症状强度有差异,有的句子是“偶尔拖延”,有的句子是“长期严重无法启动任务”,这两种句子在排序时应该有区别。
LLM 重排序的作用,就是在一个小范围内做更精细的语义判断。它不再只看词频或向量距离,而是结合指令、上下文和常识,判断一个句子是不是真正描述 ADHD 症状。代价是速度慢、成本高,所以必须放在召回之后。
5.2 用 LLM 做二分类与 pair-wise 排序
最简单的方式是 pointwise 分档:对每个候选句子,让 LLM 输出 0 到 3 的分数,然后按分数排序。这个方式实现简单,扩展性也不错。
下面示例使用兼容 OpenAI 协议的客户端,可以指向本地 vLLM 服务,也可以指向内部部署模型。关键是要把 base_url 和 api_key 配置成你自己的服务。
import json from openai import OpenAI client = OpenAI( base_url="http://localhost:8000/v1", api_key="EMPTY", ) def build_prompt(query: str, candidate: str) -> str: return f"""You are an information retrieval assistant. We are looking for sentences that describe ADHD symptoms. Given a query and a candidate sentence, output JSON with a single field "score". score is an integer from 0 to 3: 0 means unrelated 1 means weak related 2 means related 3 means strongly related Query: {query} Candidate: {candidate} Output JSON only.""" def rerank_sentence(query: str, candidate: str) -> int: prompt = build_prompt(query, candidate) response = client.chat.completions.create( model="your-model-name", messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=16, ) text = response.choices[0].message.content try: payload = json.loads(text) return int(payload["score"]) except Exception: return 0调用的时候,对融合后的候选逐条打分,然后按分数倒序排序:
scored = [] for cand_id in fused_ids: candidate_text = id_to_text[cand_id] score = rerank_sentence(query, candidate_text) scored.append((score, cand_id)) scored.sort(reverse=True, key=lambda x: x[0]) reranked_ids = [cand_id for _, cand_id in scored]这个示例只展示思路。实际项目中要考虑批量请求、并发控制、失败重试和缓存,不能直接对大量候选反复调用。
5.3 输出解析、缓存与失败处理
LLM 输出经常不是严格的 JSON。模型可能输出 markdown 代码块、额外说明文字、或者把 score 写成“2分”。建议在解析时做两层处理:
def parse_score(text: str) -> int: if not text: return 0 try: return int(text.strip()) except ValueError: pass match = re.search(r"score[\"']?\s*[:=]\s*(\d+)", text, re.IGNORECASE) if match: return int(match.group(1)) return 0如果模型支持约束解码,优先使用约束解码,直接从接口层限制输出格式,这样可以少写很多解析逻辑。
缓存也很重要。对同一个 query 和 candidate 组合,LLM 结果可以保存到磁盘或内存中。如果后续调参数,重复请求会浪费时间和费用。缓存键可以直接用query + "||" + candidate的哈希值。
网络请求失败或超时是常态。必须设置超时时间、重试次数、退避策略,并且在重试三次后降级为原融合排序顺序,避免因为单条请求失败导致整个候选列表丢失。
6. 完整 Pipeline 与评估闭环
6.1 串联三个阶段的流程
可以把整个流程封装成一个函数,方便测试和线上复用:
def run_pipeline(query: str): sparse_ids = search_sparse(query, top_k=50) dense_ids = search_dense(query, top_k=50) fused_ids = rrf_fuse([sparse_ids, dense_ids])[:40] reranked_ids = rerank_ids(query, fused_ids) return reranked_ids[:10]在 scripts/run_pipeline.py 里,按顺序读取语料、构建索引、循环处理查询,并把结果写入 outputs/predictions.jsonl:
results = [] for query_item in query_items: query_id = query_item["id"] query_text = query_item["text"] predicted_ids = run_pipeline(query_text) results.append({"query_id": query_id, "predicted_ids": predicted_ids}) with open("outputs/predictions.jsonl", "w", encoding="utf-8") as f: for r in results: f.write(json.dumps(r, ensure_ascii=False) + "\n")这样每个阶段解耦,单条查询出问题以后可以单独复现。
6.2 评估指标:P@k、Recall@k 与 nDCG@k
因为这是排序任务,不能用简单准确率评估。最常用的三个指标是:
| 指标 | 含义 | 适用场景 |
|---|---|---|
| P@k | 前 k 个结果中相关比例 | 关注精排前几位是否可靠 |
| Recall@k | 前 k 个结果覆盖了多少相关句子 | 关注召回能力 |
| nDCG@k | 结合排序位置的折损收益 | 关注相关句子是否排在前列 |
P@k 计算示例:
def precision_at_k(predicted_ids: list[str], relevant_ids: set[str], k: int) -> float: if k == 0: return 0.0 hit = 0 for pred_id in predicted_ids[:k]: if pred_id in relevant_ids: hit += 1 return hit / knDCG 的核心是 DCG,再除以理想排序的 IDCG。公式如下:
DCG = sum over position i of rel_i / log2(i + 2) IDCG = DCG of the ideal ordering nDCG = DCG / IDCG其中 rel_i 是第 i 个结果的标注相关度,比如相关为 1,不相关为 0。nDCG 比 recall 更严格,因为即使相关句子被召回到第 20 位,对用户体验的影响也远小于第 2 位。
6.3 错误分析:看假阳性与假阴性
评估指标只能告诉你哪里好、哪里差,不能告诉你模型为什么错。需要做错误分析。
第一类错误是假阳性:被 LLM 排到前面,但人工判断并不相关。可能原因是 prompt 对“ADHD 症状”定义太宽泛。解决方式是给出更具体的症状维度,比如注意力不集中、多动、冲动、执行功能受损等,要求模型按维度判断。
第二类错误是假阴性:相关句子在融合阶段就被排除,LLM 根本看不到。这种情况不是重排的问题,而是召回不足。需要回到稀疏检索和语义检索分别看 Recall@50,确认是哪一路漏掉。
建议从测试集里随机抽 20 条失败样本,人工标注错误类型,并维护成小列表。每次调整参数后重新跑一遍,看这些样本是否被修复。这个回归测试集比单纯看一个总指标更能指导开发。
7. 常见问题与排查路径
7.1 高频问题对照表
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| BM25 召回结果为空 | 分词后查询为空,或文档与查询预处理不一致 | 打印 tokenize(query) 和 tokenize(doc) | 统一分词函数,确认语言适配 |
| BM25 召回结果全是短句 | b 参数过大或文档长度分布差异大 | 计算文档长度分布 | 调低 b 到 0.3 或 0.5,观察效果 |
| FAISS 报 shape mismatch | embedding 模型更换后维度变化 | 打印 embeddings.shape 和 index.d | 重建索引,索引文件名包含维度 |
| 两路融合后效果变差 | 某一路召回质量太差或 RRF k 参数不当 | 分别计算每路 Recall@50 | 先修单路,再调 k 在 40 到 80 之间 |
| LLM 返回结果无法解析 | 模型输出 markdown 或附加文字 | 打印原始响应文本 | 用约束解码或增强正则解析 |
| LLM 重排后 nDCG 反而下降 | 候选集太小、prompt 不具体、温度过高 | 检查融合后候选数量和 prompt 样例 | 增大候选到 40 或 60,温度设为 0 |
| 长句子被截断 | tokenizer max length 限制 | 检查 token 数日志 | 先句子切分,再选择长文本模型 |
| 在线时延过高 | 没有缓存、候选太多、串行请求 | 统计每阶段耗时 | 增加缓存、减小重排数量、并行批处理 |
7.2 按链路排查的顺序
当某个查询结果明显不合理时,按下面的顺序排查:
- 确认查询文本没有清洗错误,比如缺失、编码异常或整句为空。
- 确认语料 id 和文本映射没有错位。很多线上问题来自索引版本和当前语料版本不一致。
- 确认分词和向量编码阶段没有报错,打印中间结果。
- 分别跑稀疏检索和语义检索,看各自返回前几条是否合理。
- 确认融合函数没有把 id 和 index 混淆。
- 确认 LLM 重排的候选确实来自融合结果,而不是遗漏了某一路。
- 最后才怀疑 LLM 本身判断能力。不要一上来就换大模型。
这个顺序的核心是:先排除输入和索引问题,再排查检索链路,最后才是模型能力问题。很多时候慢和不准的根因都在前面几层。
8. 最佳实践与扩展方向
8.1 工程落地清单
在把一个多级检索系统从实验脚本变成可维护服务之前,建议先过一遍下面的清单:
- 稀疏索引和向量索引要能持久化,并且记录版本号。
- 每条语料必须有稳定 id,id 不能随排序位置变化。
- 代码里不要出现硬编码文件路径,统一用配置项管理。
- LLM 调用必须有超时、重试、缓存和降级策略。
- 每次调完参数,把指标、配置、模型版本一起记录,方便回滚。
- 对输出结果保留原始句子和检索来源,便于审计和错误分析。
- 数据涉及心理健康信息时,必须脱敏、限制访问权限并记录日志。
特别要注意,LLM 重排只是辅助判断工具,不能直接替代医学诊断。在真实产品里,需要明确展示系统定位是信息检索或研究辅助,而不是诊断结论。
8.2 生产环境需要补的保障
实验脚本里可以直接把整个语料加载进内存,但生产环境要额外考虑:
- 向量索引可以放到专门的向量数据库,支持增量更新。
- 稀疏索引可以使用 Elasticsearch 或 OpenSearch,便于运维。
- LLM 服务需要监控请求量、延迟、token 消耗和错误率。
- 对外接口要做权限控制,避免任意用户消耗大量计算资源。
- 查询日志要保存,但要去掉可直接识别个人身份的信息。
- 模型更新时要先影子评估,再切流量。
越早期的信息检索系统越容易忽略可观测性。实际上,没有日志和监控,就很难判断一次效果下降是数据问题、模型问题还是服务资源配置问题。
8.3 下一阶段可以尝试的方向
如果基础 pipeline 已经跑通,可以考虑从三个方向继续迭代。
第一个方向是建模粒度升级。当前处理的是单句,但 ADHD 症状经常需要上下文才能判断。可以尝试把一个帖子或一段短对话作为输入,让模型判断“这段话是否包含症状证据”,再回传到句子级排序。
第二个方向是融合更强监督信号。如果评估集里有症状类型标签,可以把任务变成多分类或分层排序,让模型分别判断“注意力不集中”“多动冲动”“执行功能受损”等维度,这样排序解释性更强。
第三个方向是成本优化。LLM 重排很昂贵,可以用它生成弱标注数据,训练一个较小的排序模型。在线推理先过小模型,再把不确定样本交给 LLM,既能控制成本,又能保留 LLM 的细粒度判断能力。
如果让我只保留一条经验,我会保留“先定义清楚每一级检索的目标”。稀疏负责覆盖率,语义负责表达变化,LLM 负责精细化判断。三者的顺序和成本控制,比单独调任何一个模型的参数都更能影响最终效果。做 eRisk 任务如此,做其他垂直检索系统也同样如此。