尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

混合检索实战:稀疏检索、语义检索与LLM重排在ADHD症状识别中的应用

混合检索实战:稀疏检索、语义检索与LLM重排在ADHD症状识别中的应用 随着心理健康文本挖掘逐渐成为 NLP 和 IR 领域的热门方向类似 eRisk 这种早期风险预测任务也越来越多地出现在研究者和工程师的视野中。eRisk 2026 Task 3 围绕 ADHD 症状句子的识别与筛选展开本质上是一个很典型的“先召回再排序”的信息检索问题。本文将从我的实战经验出发完整拆解基于稀疏检索Sparse、语义检索Semantic和 LLM Reranking 的混合检索方案覆盖思路、核心代码、融合策略与工程化注意事项。无论你是准备参加评测任务还是想在自己的项目里落地检索增强生成RAG架构这套方法都有很强的参考价值。1. 背景与问题定义1.1 eRisk Task 3 到底在做什么eRiskEarly Risk Prediction on the Internet是 CLEF 评测体系中专门面向心理健康风险早期识别的一项任务历年都涉及抑郁、自伤、饮食障碍、注意力缺陷多动障碍等话题。Task 3 在 2026 年的目标是让系统从大量用户文本片段中找出与 ADHD 症状相关的句子并给出合理的相关性判断或排序结果。这类任务的难点在于ADHD 症状的表达往往不是直白的医学词汇而是隐藏在日常生活叙述中。比如 “我总是在截止日期前最后一刻才动手” 这句话可能映射的是注意力管理问题“我开会的时候总是控制不住打断别人” 可能映射冲动控制问题。传统的关键词匹配很容易漏掉这类语义改写而单纯靠大模型直接判断在句子数量非常大的时候又存在成本和效率问题。所以一个合理的系统设计分为两段先用低成本手段快速召回候选句子再用更强的模型做精排。这一步拆得好后面无论是准确率还是响应速度都会很可观。1.2 Sparse、Semantic、LLM Reranking 分别是什么这三个词讲的其实是三种不同抽象层级的“相关性判断方式”。稀疏检索Sparse Retrieval 是最经典的关键词匹配流派。BM25 是这里的代表算法它通过词频、逆文档频率、文档长度归一化等因素计算文档和查询的得分。优点是速度快、可解释性强、不需要训练缺点是处理同义词和语义改写时能力有限。语义检索Semantic Retrieval 是最近十年信息检索领域最大的变量。它会用预训练语言模型把句子编码成稠密向量然后在向量空间里做相似度计算。因为向量空间里语义相近的句子会靠得很近所以即使查询和候选句子没有共同关键词也能被召回。LLM Reranking 则是把相关性判断交给大语言模型做二次裁决。LLM 可以结合任务说明、few-shot 示例甚至用户画像信息对第一轮召回的结果进行精细排序或过滤。它像一个“阅卷老师”虽然速度慢但判断维度更丰富。这三者不是替代关系而是互补关系。在实际项目中我的经验是稀疏保证“不漏”语义保证“能泛化”LLM 保证“排得准”。1.3 为什么这类任务适合混合检索ADHD 症状句子检索任务有一个很典型的特点正样本非常稀疏而且标签噪声比较大。一个句子是否属于 ADHD 症状不仅取决于句子本身还可能取决于上下文语境。混合检索带来的好处有三个第一稀疏检索可以稳定兜底。即使训练数据有限BM25 这类无监督方法也不会出现“完全跑不动”的情况同时它能为后续重排阶段提供高质量的候选集。第二语义检索弥补词汇鸿沟。用户表达 ADHD 症状时经常使用隐喻、口语和间接表述embedding 模型能捕捉这种潜在语义关系。第三LLM Reranking 接近任务终点。前端召回阶段可能返回几百条候选但最终只需要其中十几条。LLM 重排器可以结合任务定义把真正符合“ADHD 症状”定义的句子排到前面去。如果你的任务是参加 eRisk 这类评测混合方案几乎是必须的。下面我从环境准备开始带你把这条链路完整跑通。2. 环境准备与数据集理解2.1 运行环境与依赖本文示例使用 Python 3.10 环境重点依赖如下pip install rank-bm25 pip install sentence-transformers pip install faiss-cpu pip install transformers pip install openai如果你需要使用本地大模型做重排可以安装 vLLM 或 llama.cpp 的 Python 绑定。如果使用向量数据库管理候选句子还可以考虑安装 qdrant-client 或 chromadb。下面是一个 mypy 风格的环境说明表格组件本文示例版本说明Python3.10建议使用虚拟环境rank-bm250.2.2稀疏检索 BM25 实现sentence-transformers3.x语义向量编码faiss-cpu1.8.x稠密向量检索openai1.xLLM API 调用示例torch2.xsentence-transformers 底层依赖版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.2 数据形式与预处理在 eRisk 类任务里输入通常是一个用户 ID 对应的多篇文本或者是一批带标签的句子对。以 Task 3 为例我们需要判断“某句是否属于 ADHD 相关症状描述”这既可以建模成二分类也可以建模成检索排序。我建议先把原始数据统一整理成下面的结构{ id: 0001, user_id: u_10086, text: I always put things off until the last minute, even when I know it will cause me stress., label: 1 }无论评测数据最终给什么格式进入模型前统一转成这种结构化形式会省很多事。预处理阶段我一般做这些操作去除 HTML 标签和 URL。保留原始大小写因为 LLM 对大小写比较敏感后续重排阶段不建议直接小写化。对超长句子按句号、问号、感叹号做切分。过滤掉长度低于 10 个字符的句段。这些操作不是为了炫技而是为了后续检索和重排时减少噪声。比如一个句子混在很长的帖子文本里直接编码会导致语义被稀释切分之后反而更利于精准召回。2.3 查询构造策略在“句子检索”任务中查询query的构造是一个容易被忽略但非常关键的环节。我们不能简单把“ADHD symptoms”作为唯一查询因为不同用户描述同一个症状的方式差异很大。更合理的做法是构造一组“查询主题”例如ADHD symptomattention problemhyperactivityimpulsivityexecutive dysfunctiontime management difficultyemotional dysregulation然后对每个候选句子分别计算多组查询的得分最终取最大值或加权和。这种多查询策略能显著提升稀疏检索的召回率因为 BM25 对查询词覆盖很敏感单一查询很难覆盖全部相关表达。如果你的任务提供了训练集标签还可以从标签为 positive 的句子中提取高频词构建一个“症状词表”进一步扩充查询表达。3. 稀疏检索模块实现3.1 BM25 工具封装稀疏检索我推荐直接用 rank-bm25 库它实现简单效果稳定。核心思路是对所有候选句子建立 BM25 索引然后对每个查询返回得分最高的 Top-K。# 文件路径src/sparse_retriever.py import json import jieba # 如果处理英文文本则不需要 from rank_bm25 import BM25Okapi def load_sentences(file_path): with open(file_path, r, encodingutf-8) as f: data [json.loads(line) for line in f] return data def tokenize_en(text): # 示例英文文本按空白切分保留小写 return text.lower().split() def build_bm25_index(sentences): tokenized_sentences [tokenize_en(s[text]) for s in sentences] bm25 BM25Okapi(tokenized_sentences) return bm25 def sparse_search(bm25, sentences, query, top_k50): tokenized_query tokenize_en(query) scores bm25.get_scores(tokenized_query) ranked_idx sorted(range(len(scores)), keylambda x: scores[x], reverseTrue)[:top_k] results [] for idx in ranked_idx: if scores[idx] 0: continue results.append({ id: sentences[idx][id], text: sentences[idx][text], score: float(scores[idx]), retriever: bm25 }) return results这段代码的核心是build_bm25_index和sparse_search。前者负责把句子列表变成可检索的索引后者负责根据查询返回 Top-K 结果。注意这里tokenize_en只做了最简单的按空格切分如果你要处理中文数据需要引入 jieba 或类似分词器。3.2 多查询聚合如前面所说单一查询在稀疏检索中很容易漏召。我们可以构造一个查询列表然后对每个查询单独执行搜索最后合并结果。def multi_query_sparse_search(bm25, sentences, queries, top_k50): merged {} for q in queries: results sparse_search(bm25, sentences, q, top_ktop_k) for r in results: rid r[id] if rid not in merged or r[score] merged[rid][score]: merged[rid] r ranked sorted(merged.values(), keylambda x: x[score], reverseTrue) return ranked[:top_k]这里我用了简单的“取每个 ID 的最高分”策略。如果希望更平滑可以取多个查询得分的均值但要注意均值方式容易稀释掉只被某个查询命中的句子。在实际实验中“最大值”策略通常召回更稳。3.3 稀疏检索的局限性BM25 虽然是基线中的战斗机但它有一个明显短板查询和句子如果完全没有任何共享词分数就是 0。比如查询是 “impulsivity”句子是 “I always speak out before thinking carefully”这两个表达语义相关但没有词汇重叠BM25 很难召回。这就是为什么需要语义检索来补位。接下来的语义检索模块会重点解决这类同义改写问题。4. 语义检索模块实现4.1 选择编码模型语义检索的核心是编码器。目前社区里常见的方案有两种一种是使用 sentence-transformers 生态中的通用句向量模型比如all-MiniLM-L6-v2速度快、占用小适合原型验证。另一种是使用针对“心理健康文本”微调过的模型或者效果更好的大规模 embedding 模型比如BAAI/bge-large-zh-v1.5中文或BAAI/bge-large-en-v1.5英文。这类模型在语义匹配和检索任务上往往表现更好。本文示例使用BAAI/bge-large-en-v1.5因为它对英文长句的支持相对较好输出维度是 1024。pip install sentence-transformers4.2 向量索引与检索我们先把所有候选句子编码成向量然后使用 FAISS 建立索引。# 文件路径src/semantic_retriever.py from sentence_transformers import SentenceTransformer import faiss import numpy as np class SemanticRetriever: def __init__(self, model_nameBAAI/bge-large-en-v1.5): self.model SentenceTransformer(model_name) self.index None self.sentences [] def encode_texts(self, texts, batch_size64): return self.model.encode( texts, batch_sizebatch_size, normalize_embeddingsTrue, show_progress_barFalse ) def build_index(self, sentences): self.sentences sentences texts [s[text] for s in sentences] embeddings self.encode_texts(texts) dim embeddings.shape[1] self.index faiss.IndexFlatIP(dim) self.index.add(embeddings.astype(np.float32)) def search(self, query, top_k50): query_vec self.encode_texts([query])[0].astype(np.float32) scores, indices self.index.search(query_vec.reshape(1, -1), top_k) results [] for score, idx in zip(scores[0], indices[0]): if idx 0: continue results.append({ id: self.sentences[idx][id], text: self.sentences[idx][text], score: float(score), retriever: semantic }) return results这里使用了IndexFlatIP也就是内积索引配合归一化向量时内积等价于余弦相似度效果直观。FAISS 还提供了 IVF 和 HNSW 等近似最近邻索引当句子数量达到百万级时可以考虑用IndexHNSWFlat来减少内存占用代价是召回精度有微小下降。4.3 查询扩写优化语义召回纯靠原始查询做语义编码有时仍然不够。比较好的做法是结合 LLM 对原始查询做上下文扩写。例如把查询 “ADHD symptom” 扩写成更细致的描述Which sentences describe ADHD-related symptoms such as inattention, hyperactivity, impulsivity, executive dysfunction, or emotional dysregulation?这一步看似简单但对提升 embedding 模型的区分度很有帮助。因为 embedding 模型对“完整句子”式的查询比对“短语”式查询更敏感。如果你已经在流程中接入了 LLM这个扩写步骤几乎不增加额外成本。5. LLM Reranking 实现5.1 为什么需要 LLM Rerank经过 BM25 和语义检索后我们已经拿到了几百条候选句子但候选集里依然混入了不少“语义相近但不属于 ADHD 症状”的句子。这时候需要一个更强的裁决器。LLM 重排的优势在于能理解任务描述和判断标准。能结合 few-shot 示例学习标注风格。能对候选句子之间的细微差异做区分。相比传统交叉编码器cross-encoderLLM 的优势是更容易适应新任务并且可以直接输出带有解释的排序结果方便人工审查。5.2 基于 API 的重排器实现下面是一个基于 OpenAI API 的重排器示例。为了避免把 API Key 硬编码在代码里这里统一从环境变量读取。# 文件路径src/llm_reranker.py import os import json from openai import OpenAI class LLMReranker: def __init__(self, modelgpt-4o-mini, max_context20): self.client OpenAI(api_keyos.environ.get(OPENAI_API_KEY)) self.model model self.max_context max_context def rerank(self, query, candidates, task_instructionNone): # 控制进入 LLM 的候选数量 candidates candidates[: self.max_context] mini_pool [ {id: c[id], text: c[text]} for c in candidates ] prompt self._build_prompt(query, mini_pool, task_instruction) resp self.client.chat.completions.create( modelself.model, messages[ {role: system, content: You are an expert in mental health text analysis and information retrieval.}, {role: user, content: prompt} ], temperature0.0 ) content resp.choices[0].message.content return self._parse_response(content, candidates) def _build_prompt(self, query, mini_pool, task_instruction): task_desc task_instruction or ( You are given a query and a list of sentences. Rank the sentences by their relevance to the query. Output the IDs in descending order of relevance, one per line. ) pool_text \n.join( [f{item[id]}\t{item[text]} for item in mini_pool] ) prompt f Task: {task_desc} Query: {query} Candidate sentences: {pool_text} Please output the ranked IDs only, one per line, from most relevant to least relevant. return prompt def _parse_response(self, content, candidates): ranked_order [] for line in content.strip().splitlines(): line line.strip() if not line: continue rid line.split()[0] if rid not in ranked_order: ranked_order.append(rid) id2score {rid: 1.0 / (idx 1) for idx, rid in enumerate(ranked_order)} results [] for c in candidates: if c[id] in id2score: results.append({ id: c[id], text: c[text], score: id2score[c[id]], retriever: llm_rerank }) results.sort(keylambda x: x[score], reverseTrue) return results这个重排器核心思想很简单把候选句子构建成 prompt让 LLM 输出排好序的 ID 列表再转换成可用于融合的分数。注意temperature0.0很重要重排任务应该尽量可复现。5.3 本地模型重排方案如果你的数据涉及隐私或需要离线部署使用 API 可能不合适。一个可选的替代方案是本地部署 Qwen、LLaMA 等模型通过 vLLM 暴露 OpenAI 兼容接口然后仍然使用上述客户端调用逻辑。示例启动命令vllm serve Qwen/Qwen2.5-7B-Instruct --port 8000然后只需要把客户端 base_url 指向本地地址self.client OpenAI( api_keyEMPTY, base_urlhttp://localhost:8000/v1 )这样既能保留 LLM 重排的效果又不需要把数据传到外部接口更适合实际工程项目。6. 检索融合策略6.1 为什么要融合BM25 和语义检索的得分体系完全不同前者是概率得分后者是余弦相似度不能直接相加。所以需要一个标准化策略把多路召回结果对齐到同一尺度上。常见的融合思路有分数归一化后加权求和。使用 Borda Count 基于排序位置投票。使用 Reciprocal Rank FusionRRF加权排名。我在实际项目中最常用的是 RRF因为它不需要调分数分布参数鲁棒性很好。6.2 RRF 融合代码下面是一个简单的 RRF 实现它把每路检索结果的排序位置映射成一个分数。# 文件路径src/rrf_fusion.py def reciprocal_rank_fusion(result_lists, k60): fused_scores {} for results in result_lists: for rank, item in enumerate(results): rid item[id] fused_scores[rid] fused_scores.get(rid, 0.0) 1.0 / (k rank 1) if rid not in fused_scores: fused_scores[rid] 0.0 fused_results [] for rid, score in fused_scores.items(): fused_results.append({id: rid, score: score}) fused_results.sort(keylambda x: x[score], reverseTrue) return fused_resultsk是一个平滑常数通常取 60。这个融合方法最大的优点是不需要每个检索器给出概率分数只需要排序结果。即使某一路检索器分数分布很奇怪也不影响整体融合效果。6.3 带权重的融合策略虽然 RRF 很方便但如果某一路检索器明显更可靠我们希望能给它更高权重。可以把 RRF 分数按权重叠加def weighted_rrf(result_lists, weights, k60): fused_scores {} for results, weight in zip(result_lists, weights): for rank, item in enumerate(results): rid item[id] fused_scores[rid] fused_scores.get(rid, 0.0) weight / (k rank 1) fused_results [{id: rid, score: score} for rid, score in fused_scores.items()] fused_results.sort(keylambda x: x[score], reverseTrue) return fused_results如果 BM25 的精度较高权重可以给大一些如果语义检索泛化能力更强则语义权重高一些。这个比例建议在验证集上做一次简单扫描选出最优组合。6.4 融合后再重排的完整链路在实际流程中我建议分两轮先融合 BM25 和语义检索的结果形成候选集然后再用 LLM 重排。这样比直接让 LLM 处理上千条候选要省很多成本。完整链路如下原始句子库 ↓ BM25 召回 Top-200 ↓ 语义检索召回 Top-200 ↓ RRF 融合取 Top-50 ↓ LLM Reranking 输出 Top-10 ↓ 结果保存和评估这个流水线在效果和成本之间比较平衡。如果评测指标是 RecallK可以适当扩大前两轮召回数量如果注重 Precision可以额外用规则过滤明显无关的句子。7. 评估指标与实验设计7.1 常用评估指标eRisk 类任务通常关注 PK、RecallK、F1 和 NDCG 等指标。对于“症状句子识别”任务我建议重点看Recall100如果前 100 条候选都没覆盖到真实相关句子后续重排再强也救不回来。P10作为最终输出的精度指标因为通常用户只会关注最靠前的结果。NDCG10如果评测要求对排序质量敏感NDCG 更合适。7.2 消融实验不要一上来就把 BM25、语义、LLM 全接上建议做三组消融实验只用 BM25。只用语义检索。BM25 语义检索 RRF 融合。在融合基础上加入 LLM Reranking。这样你能清楚知道每一路方法到底带来了多少增益。我在类似任务中的经验是BM25 和语义检索的融合通常能提升 10% 到 20% 的 Recall加入 LLM Reranking 后Precision 会有明显提升但 Recall 的提升幅度不大因为重排阶段只是对已有候选集重新排序并不会产生新的候选。7.3 错误分析实验做完之后不要只看指标建议抽样检查错误案例分三类统计稀疏检索漏召原因往往是词汇完全不一致需要扩充同义词查询。语义检索排错原因往往是 embedding 模型对“心理健康文本”理解不够考虑换用领域模型。LLM 判断错误原因往往是 prompt 指令不明确可以考虑增加 few-shot 示例。把这三类错误占比统计出来下一步优化方向就非常清晰了。8. 常见问题与排查思路8.1 候选句子数量过大导致 LLM 重排慢这是最常遇到的问题。LLM 重排是串行慢操作候选越多延迟越不可控。解决思路严格限制进入 LLM 的候选数比如最多 20 条。使用批处理或者异步并发请求。优先使用更小的模型例如 7B 左右的本地模型或 API 的轻量版本。8.2 BM25 结果出现大量 0 分如果构建索引时使用了全小写分词而查询里包含特殊符号很容易导致得分偏低。解决办法是统一预处理逻辑查询和文档使用同一套分词函数且移除特殊符号。8.3 FAISS 索引无法加载FAISS 的索引文件与库版本强相关换机器或换环境后经常出现Invalid argument之类的报错。解决办法只在完全相同的 Python 和 FAISS 版本下保存/加载索引。或者不保存索引文件每次重新构建。如果需要保存可以使用faiss.write_index和faiss.read_index并记录版本号。8.4 LLM 输出格式不稳定LLM 重排时模型可能输出额外解释导致解析失败。解决思路Prompt 中明确要求“不输出任何其他内容”。解析时只取每行第一个 token。如果模型输出 JSON建议用response_format{type: json_object}。下面是一个常见的排查表格问题现象常见原因解决思路召回结果相关句很少查询构造单一使用多查询扩展语义检索结果不稳定embedding 模型不匹配领域换用领域模型或增加查询扩写LLM 重排结果与预期不符prompt 指令不清晰增加任务描述和 few-shot 示例融合后总分被某一路带偏分数尺度不一致改用 RRF 排序融合在线推理耗时过高候选数量太大分层过滤减少进入 LLM 的数量9. 最佳实践与工程建议9.1 数据与基线先行不要在没有基线的情况下直接上大模型。建议先做完 BM25 和语义检索两条线拿到至少一个可复现的基线结果再往上面叠加 LLM 重排。这样出了问题你可以确定是哪一层引入的。9.2 配置管理把模型名称、Top-K、RRF 的 k、LLM 温度、prompt 模板等参数统一写入配置文件避免散落在代码里。# 文件路径configs/retrieval.yaml retrieval: sparse_top_k: 200 semantic_top_k: 200 fusion_top_k: 50 rrf_k: 60 semantic: model_name: BAAI/bge-large-en-v1.5 batch_size: 64 reranker: type: api # 可选 local / api model: gpt-4o-mini max_context: 20 temperature: 0.0使用 yaml 或者 JSON 配置的好处是实验管理和版本控制都会方便很多。当你要尝试不同的模型组合时只需要修改配置文件不需要改动代码。9.3 安全与隐私边界心理健康文本属于高度敏感数据。在工程化部署时必须注意以下几点如果使用外部 API必须确认数据脱敏审批流程。尽量优先使用本地模型推理。日志中不要记录完整句子可以记录句子 ID 或脱敏后的摘要。涉及用户真实数据时严格遵循最小权限原则只有授权人员才能访问原始文本。这不仅是合规问题更是对用户负责的底线。9.4 缓存与性能优化检索链路中向量编码和 BM25 索引构建都是可以离线完成的。在线阶段只需要做查询编码、向量检索和 LLM 重排。建议对查询结果做缓存比如在 Redis 中以查询哈希为 key 缓存 Top-K 结果。相同或相似查询再次请求时可以直接返回避免重复调用 LLM。9.5 可观测性在工程落地时建议为每一路检索器都输出日志记录查询 ID、候选数、耗时和得分分布。这样一旦线上效果波动你能快速定位是 BM25 索引更新问题、embedding 模型版本问题还是 LLM prompt 调整导致的回归。10. 总结与后续方向本文围绕 eRisk 2026 Task 3 的 ADHD 症状句子检索问题完整实现了一条“稀疏检索 语义检索 LLM Reranking”的混合检索链路。核心思路可以推广到绝大多数文本召回与排序场景先用低成本方法扩大召回面再用强模型做精细化排序最后通过 RRF 等策略把多路排序结果融合起来。从工程角度看真正值得重点关注的不是某一个模型有多强而是整个链路的稳定性、可复现性和成本控制。建议你在自己的数据集上先跑通最小闭环再逐步替换模型、调整参数、加入领域知识。后续可以继续尝试的方向包括使用 SPLADE 这类可学习的稀疏检索模型替代传统 BM25引入基于交叉编码器的 Reranker 作为 LLM 重排前的第二道精排以及在重排阶段加入用户级上下文信息让模型不只看到单句还能看到句子所在的整篇文本。如果你正在准备类似评测任务或者准备把检索增强生成RAG应用到心理健康领域可以先从本文的代码骨架入手把它跑通再针对自己的业务场景做优化。
返回列表