多级检索与LLM重排序:ADHD症状句子检索完整实践

发布时间:2026/8/28 10:33:17
多级检索与LLM重排序:ADHD症状句子检索完整实践 在实际的自然语言处理应用中ADHD 症状句子的检索与排序并不只是把 query 和文章做关键词匹配。eRisk 2026 Task 3 这类早期风险预测任务目标是从用户生成内容里找到与注意缺陷多动障碍相关的症状描述。DSGT-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加载句子向量模型并生成 embeddingfaiss-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 默认使用 k11.5b0.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, k11.2, b0.75)对单个查询召回候选query difficulty starting tasks tokens tokenize(query) scores bm25.get_scores(tokens) top_indices sorted(range(len(scores)), keylambda i: scores[i], reverseTrue)[: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_embeddingsTrue, batch_size64, show_progress_barTrue, )normalize_embeddingsTrue表示输出向量已经做了 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_embeddingsTrue) scores, indices index.search(query_vec.astype(float32), k50) 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(), keylambda x: x[1], reverseTrue ) ]使用方式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]融合后的候选集通常比任何单一路更完整。实际项目里可以先把两路各自的 Recall50 都算出来如果某一路有明显问题再考虑调整这一路的检索参数而不是盲目改融合权重。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_urlhttp://localhost:8000/v1, api_keyEMPTY, ) def build_prompt(query: str, candidate: str) - str: return fYou 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( modelyour-model-name, messages[{role: user, content: prompt}], temperature0.0, max_tokens16, ) 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(reverseTrue, keylambda 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(rscore[\]?\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_k50) dense_ids search_dense(query, top_k50) 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.jsonlresults [] 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, encodingutf-8) as f: for r in results: f.write(json.dumps(r, ensure_asciiFalse) \n)这样每个阶段解耦单条查询出问题以后可以单独复现。6.2 评估指标Pk、Recallk 与 nDCGk因为这是排序任务不能用简单准确率评估。最常用的三个指标是指标含义适用场景Pk前 k 个结果中相关比例关注精排前几位是否可靠Recallk前 k 个结果覆盖了多少相关句子关注召回能力nDCGk结合排序位置的折损收益关注相关句子是否排在前列Pk 计算示例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 根本看不到。这种情况不是重排的问题而是召回不足。需要回到稀疏检索和语义检索分别看 Recall50确认是哪一路漏掉。建议从测试集里随机抽 20 条失败样本人工标注错误类型并维护成小列表。每次调整参数后重新跑一遍看这些样本是否被修复。这个回归测试集比单纯看一个总指标更能指导开发。7. 常见问题与排查路径7.1 高频问题对照表问题现象可能原因检查方式处理建议BM25 召回结果为空分词后查询为空或文档与查询预处理不一致打印 tokenize(query) 和 tokenize(doc)统一分词函数确认语言适配BM25 召回结果全是短句b 参数过大或文档长度分布差异大计算文档长度分布调低 b 到 0.3 或 0.5观察效果FAISS 报 shape mismatchembedding 模型更换后维度变化打印 embeddings.shape 和 index.d重建索引索引文件名包含维度两路融合后效果变差某一路召回质量太差或 RRF k 参数不当分别计算每路 Recall50先修单路再调 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 工程落地清单在把一个多级检索系统从实验脚本变成可维护服务之前建议先过一遍下面的清单稀疏索引和向量索引要能持久化并且记录版本号。每条语料必须有稳定 idid 不能随排序位置变化。代码里不要出现硬编码文件路径统一用配置项管理。LLM 调用必须有超时、重试、缓存和降级策略。每次调完参数把指标、配置、模型版本一起记录方便回滚。对输出结果保留原始句子和检索来源便于审计和错误分析。数据涉及心理健康信息时必须脱敏、限制访问权限并记录日志。特别要注意LLM 重排只是辅助判断工具不能直接替代医学诊断。在真实产品里需要明确展示系统定位是信息检索或研究辅助而不是诊断结论。8.2 生产环境需要补的保障实验脚本里可以直接把整个语料加载进内存但生产环境要额外考虑向量索引可以放到专门的向量数据库支持增量更新。稀疏索引可以使用 Elasticsearch 或 OpenSearch便于运维。LLM 服务需要监控请求量、延迟、token 消耗和错误率。对外接口要做权限控制避免任意用户消耗大量计算资源。查询日志要保存但要去掉可直接识别个人身份的信息。模型更新时要先影子评估再切流量。越早期的信息检索系统越容易忽略可观测性。实际上没有日志和监控就很难判断一次效果下降是数据问题、模型问题还是服务资源配置问题。8.3 下一阶段可以尝试的方向如果基础 pipeline 已经跑通可以考虑从三个方向继续迭代。第一个方向是建模粒度升级。当前处理的是单句但 ADHD 症状经常需要上下文才能判断。可以尝试把一个帖子或一段短对话作为输入让模型判断“这段话是否包含症状证据”再回传到句子级排序。第二个方向是融合更强监督信号。如果评估集里有症状类型标签可以把任务变成多分类或分层排序让模型分别判断“注意力不集中”“多动冲动”“执行功能受损”等维度这样排序解释性更强。第三个方向是成本优化。LLM 重排很昂贵可以用它生成弱标注数据训练一个较小的排序模型。在线推理先过小模型再把不确定样本交给 LLM既能控制成本又能保留 LLM 的细粒度判断能力。如果让我只保留一条经验我会保留“先定义清楚每一级检索的目标”。稀疏负责覆盖率语义负责表达变化LLM 负责精细化判断。三者的顺序和成本控制比单独调任何一个模型的参数都更能影响最终效果。做 eRisk 任务如此做其他垂直检索系统也同样如此。