做心理健康领域的 NLP 系统,最难的不是模型选型,而是怎么定义“相关”。在 eRisk 2026 Task 3 这种任务里,给定一条 ADHD 症状描述,比如“难以开始一项任务”,系统要从用户的社交文本中找出真正体现这个症状的句子。用户不会老老实实写“我有注意缺陷多动障碍”,他们更可能写“我又拖到凌晨三点才开始写作业”“开会的时候我刷了十分钟手机”。这种表达差异,决定了纯关键词匹配一定会漏,纯向量检索又说不清为什么相关,直接让大模型全库扫一遍又贵到不现实。
从 DS@GT-ARC at eRisk 2026 这个参赛系统所代表的技术路线来看,最务实的做法是把稀疏检索(Sparse)、语义检索(Semantic)和 LLM 重排序(LLM Reranking)串成一个三级漏斗:先用 BM25 这类 Sparse 方法保证基础召回和可解释性,再用向量检索补充同义改写和上下文语义,最后用 LLM 对候选句子做精排。这套组合不是炫技,而是工程上的必然选择。
这篇文章不打算复述论文,而是把这条路线拆成可落地的代码流水线。读完你会理解每一级检索解决什么问题、在什么时候引入 LLM 重排序最划算、以及评估一个 ADHD 症状句子检索系统该看哪些指标。
1. eRisk 2026 Task 3 在解决什么问题
1.1 从 eRisk 说起
eRisk 是 CLEF 实验室长期举办的共享任务,全称是 Early Risk Prediction on the Internet,核心目标是从互联网用户公开产生的文本中,尽早识别抑郁、自伤、饮食失调、注意力缺陷多动障碍等心理风险。它和传统情感分析最大的区别在于“早期”:不是等用户已经明确表达了求助信号,而是在信号还比较微弱、表达还比较隐晦的时候,就尝试发现风险。
Task 3 聚焦 ADHD 症状句子的检索与重排序。从任务名称看,系统输入是一个或多个症状描述,输出是候选句子的相关度排序。为什么要做句子级而不是用户级?因为风险判断需要证据。一个模型如果只说“这个用户可能有 ADHD”,在真实场景里是很难被信任的;如果它能指出“这句话描述的是难以维持注意力”“这句话反映了组织计划能力下降”,那么后续专业人员就能根据这些证据做进一步判断。
1.2 句子级检索的定义与难点
如果把 Task 3 抽象成信息检索问题,它就是一个 query-document 相关度排序任务:
- query:一条 ADHD 症状描述,比如 difficulty concentrating、difficulty initiating tasks、often loses things。
- document:从用户发帖中切分出来的句子。
- 目标:对所有候选句子按与 query 的相关度从高到低排序,并返回 TopK。
这个任务有三个难点:
第一,表达极度口语化。ADHD 症状在真实文本里往往不是医学用语,而是生活场景描述。比如“我常常同时开十几个浏览器标签页”可能是注意力分散的表现,“买了三本同款笔记本然后又找不到了”可能是丢三落四的表现。关键词匹配很难覆盖这些改写。
第二,正样本稀缺。症状相关的句子在全部用户文本中占比极低,直接训练一个二分类器很容易因为类别不平衡而失效。评测任务通常更关注排序指标,比如 nDCG@10、Recall@100,而不是简单准确率。
第三,隐私敏感。这里处理的是心理健康相关文本,不能把用户真实 ID、社交关系链、地理位置等作为特征随意拼接,更不能把原始语料直接丢给外部 API 不做脱敏。
1.3 为什么需要 Reranking 流水线
全库文本直接让 LLM 打分,理论上最灵活,但实际不可行。假设一个用户有一万条分句,每条都给 LLM 生成一个分数,成本和延迟都会爆炸。更合理的做法是“先粗糙召回,再精细重排”。
召回阶段要尽可能不遗漏相关句子,排序阶段要尽可能把最相关的句子放到前面。DS@GT-ARC 这个系统名里的 Sparse、Semantic、LLM Reranking,本质上描述了完整的漏斗结构:Sparse 和 Semantic 并行做候选生成,LLM 做最终精排。这也是目前信息检索领域非常主流的生产架构。
2. Sparse、Semantic、LLM 重排序:能力与边界
2.1 Sparse Retrieval:关键词匹配依然不可替代
Sparse Retrieval 的核心是词汇匹配。最经典的算法是 BM25,它基于词频和逆文档频率,给每个 query 词在文档中的出现计算加权分数。
在 ADHD 症状句子检索里,BM25 的价值在于:
- 可解释性强。模型说这句话相关,至少是因为它命中了某个关键词,比如 attention、focus、procrastinate。
- 零训练成本。不需要标注数据,直接对分词后的文本建索引。
- 速度快。普通 CPU 上也能轻松处理上万条句子。
- 适合术语和专用词。医学名词、药物名称、特定行为词,往往在向量空间里反而不够突出。
但 BM25 也有明显短板:词汇鸿沟。query 是 difficulty organizing tasks,句子是 “I always forget what I’m supposed to do next”,两者没有共同关键词,但语义上是相关的。只靠 BM25,这种句子永远不会进候选集。
2.2 Semantic Retrieval:用向量补上同义改写
Semantic Retrieval 通常使用双塔编码器(bi-encoder)把 query 和 document 分别编码成稠密向量,再用余弦相似度计算相关度。常见的模型有 bge、all-MiniLM、E5 系列。
它的优势是:
- 能匹配同义词和近义表达。比如 query 里的 “lose focus”,能被 “my mind drifts away” 这种句子命中。
- 对上下文有一定理解能力。
- 可以预计算句子向量,线上只需要计算 query 向量再和向量库做相似度检索。
但语义检索也有代价:
- 训练数据如果不匹配领域,模型容易把“相关”理解成“话题相似”。
- 可解释性弱。模型给高分,但说不出是哪个词起的作用。
- 只做双塔编码时,query 和 document 没有深度交互,复杂推理能力有限。
- 资源开销比 BM25 高,需要向量索引,比如 faiss、milvus。
2.3 LLM Reranking:把精排交给大模型
Reranking 阶段的任务是对召回的 Top 候选进行更精细的打分。LLM 在这里可以做三种范式:
- Pointwise:让 LLM 对每个 query-document 对输出一个分数,比如 0 到 5 分。
- Pairwise:让 LLM 比较两个文档,输出哪个更相关,然后通过排序算法组合成全局排序。
- Listwise:把整个候选列表给 LLM,让它生成一个重排后的列表。
LLM 重排序的最大优势是复杂语义理解。它可以识别反讽、否定、隐含表达,甚至可以结合 prompt 里给出的 ADHD 症状定义来做推理。但这个优势伴随着三个问题:
- 成本高。每调用一次都是 token 消耗。
- 延迟高。不能对全库做。
- 输出不稳定。LLM 可能给出不一致分数,需要设计 prompt 和解析逻辑。
2.4 三种方法对比
| 维度 | Sparse(BM25) | Semantic(向量检索) | LLM Reranking |
|---|---|---|---|
| 核心原理 | 词频与倒排索引 | 语义向量相似度 | 大模型上下文推理 |
| 可解释性 | 高 | 低 | 中 |
| 计算成本 | 低 | 中 | 高 |
| 延迟 | 低 | 中 | 高 |
| 同义词处理 | 差 | 好 | 很好 |
| 复杂语义理解 | 差 | 中 | 好 |
| 是否适合全库扫描 | 是 | 是 | 否 |
| 典型使用位置 | 召回 | 召回 | 精排 |
从工程角度看,这三种方法不是竞争关系,而是互补关系。正确的姿势是让它们各司其职。
3. 整体系统架构:从候选生成到精排
3.1 三级漏斗设计
一个稳定的 ADHD 症状句子检索系统,可以设计成这样一个漏斗:
第一级,Sparse 和 Semantic 并行召回。
- Sparse 召回:用 BM25 对全部分句打分,取 Top 200。
- Semantic 召回:用双塔模型计算句子向量,与 query 向量做相似度检索,取 Top 200。
第二级,融合与去重。
- 用 RRF(Reciprocal Rank Fusion)或加权线性融合合并两路结果,得到 Top 100。
- 去掉重复句子和过短句子。
第三级,LLM 精排。
- 对 Top 50 或 Top 100 的 query-sentence 对,用 LLM 逐条打分。
- 按 LLM 打分重新排序,取 TopK 输出。
这个设计背后的原因很直接:如果只用 BM25,召回到不了位;如果只用向量检索,偶尔会漏掉有明确关键词的强相关句子;如果一开始就让 LLM 介入,成本不可控。三级漏斗的核心是“把预算花在最值得精排的候选点上”。
3.2 数据流全景
整个流程可以描述为:
- 读取原始文本。
- 分句,切分成长度合适的句子。
- 对句子做基本预处理,比如去 URL、去多余空白。
- 建立 BM25 索引,同时生成句子向量。
- 输入 query,分别得到 BM25 候选和向量候选。
- 融合候选,得到待精排列表。
- 调用 LLM 打分。
- 输出最终排序结果。
- 用标注好的评测集计算 nDCG、Recall 等指标。
这个数据流有一个很容易被忽略的点:分句质量直接决定检索上限。如果一句话被切得太碎,比如把“我经常忘记钥匙放在哪里,但是我又懒得找”切成“我经常忘记钥匙放在哪里”和“但是我又懒得找”,两个片段的语义都不完整。实际项目中应该优先使用基于句法或标点的分句器,而不是简单按句号切。
3.3 评估协议的重要性
eRisk 这类任务通常看重排序质量。常用指标包括:
- nDCG@K:衡量 TopK 排序质量。
- Recall@K:衡量 TopK 是否覆盖了所有相关句子。
- MRR:衡量第一个相关结果的位置。
- Precision@K:衡量 TopK 中的相关比例。
没有人能保证模型一次就做到完美,所以评估协议必须固定下来。建议在实验开始前先确定 K 值、指标计算方式、是否对长文本做截断,否则后面所有对比都是无效的。
4. 环境准备与前置条件
4.1 运行环境
建议使用 Linux 或 macOS 环境,Python 3.9 及以上版本。核心依赖如下:
pip install rank-bm25 sentence-transformers torch transformers如果使用 OpenAI 兼容 API 做 LLM reranking,再安装 openai 包:
pip install openai如果文本是中文,建议安装 jieba 用于分词:
pip install jieba如果句子数量很大,做 semantic retrieval 时推荐安装 faiss-cpu 或 faiss-gpu:
pip install faiss-cpu注意:版本号请以实际安装环境为准,这里只演示通用思路。不要盲目升级到最新版本,尤其是 torch 和 CUDA 的组合,容易出现依赖冲突。
4.2 模型选择建议
不同阶段的模型可以分开选择:
- Sparse 检索:直接用 BM25,不需要模型。
- Semantic 检索:可以用 all-MiniLM-L6-v2 作为起点,这个模型体积小、速度快,适合快速验证;如果效果不够,再换 bge-base-en-v1.5 或 bge-large-en-v1.5。
- LLM Reranking:如果预算允许,可以调用商业 API 的轻量级模型;如果希望本地部署,可以用 7B 或 8B 级别的开源模型,配合 vLLM 起一个 OpenAI 兼容服务。
在心理健康文本场景中,需要额外注意模型的领域适配性。通用语义模型可能在“医学表达”上得分高,但对“日常化表达”不敏感。可以先人工看一批检索结果,判断模型是真正理解症状,还是只是在做话题匹配。
4.3 数据准备
你需要准备两类数据:
一类是用户文本分句后的句子集合,用于检索。另一类是评测集,每条包含一个 query、一个句子以及相关度标签,通常是相关或不相关。
如果没有现成数据,可以先用公开数据集或人工构造小规模样本验证流程。以 ADHD 症状为例,可以构造这样的样本:
| query | sentence | label |
|---|---|---|
| difficulty concentrating | I can never finish a movie without checking my phone. | 1 |
| difficulty concentrating | There’s a new coffee shop near my house. | 0 |
真实项目中,标签最好由具备心理学背景的人员审核,避免模型学习到表面语言特征。
5. 完整代码实现
5.1 数据准备与分句预处理
我们先从原始文本构造句子集合。假设输入是一个 JSON 文件,每个元素包含用户 ID 和文本内容。
# 文件路径:prepare_corpus.py import json import re from typing import List, Dict def split_sentences(text: str) -> List[str]: # 先按常见句子结束符切分,再清洗 parts = re.split(r'(?<=[.!?。!?])\s+', text.strip()) sentences = [] for part in parts: part = part.replace('\n', ' ').strip() if len(part) < 3: continue sentences.append(part) return sentences def build_corpus(raw_file: str, output_file: str) -> List[Dict]: with open(raw_file, 'r', encoding='utf-8') as f: records = json.load(f) corpus = [] for rec in records: user_id = rec.get('user_id', '') text = rec.get('text', '') for sent in split_sentences(text): corpus.append({ 'user_id': user_id, 'text': sent }) with open(output_file, 'w', encoding='utf-8') as f: json.dump(corpus, f, ensure_ascii=False, indent=2) return corpus if __name__ == '__main__': # 示例用法 corpus = build_corpus('raw_posts.json', 'corpus.json') print(f'build {len(corpus)} sentences')这段代码有两个关键设计:
- 用正则做分句,而不是简单 split,能保留句子的相对完整性。
- 过滤过短句子,避免把单个单词或孤立标点送进索引。
5.2 Sparse 检索:BM25 候选生成
下面用 rank_bm25 实现稀疏检索。英文可以直接按空格分词,中文需要额外用 jieba。
# 文件路径:sparse_search.py import json from rank_bm25 import BM25Okapi # 如果处理中文,先加载 jieba # import jieba def tokenize(text: str, language: str = 'en') -> list: if language == 'zh': # 中文分词 return list(jieba.cut(text)) return text.lower().split() def build_bm25(corpus_file: str, language: str = 'en'): with open(corpus_file, 'r', encoding='utf-8') as f: corpus = json.load(f) tokenized_corpus = [tokenize(item['text'], language) for item in corpus] bm25 = BM25Okapi(tokenized_corpus) return bm25, corpus def sparse_search(query: str, bm25, corpus, top_k: int = 200, language: str = 'en'): tokenized_query = tokenize(query, language) scores = bm25.get_scores(tokenized_query) ranked_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k] return [corpus[i] for i in ranked_idx], [scores[i] for i in ranked_idx] if __name__ == '__main__': bm25, corpus = build_bm25('corpus.json') results, scores = sparse_search('difficulty concentrating', bm25, corpus, top_k=10) for sent, score in zip(results, scores): print(score, sent['text'])这个实现非常轻量,适合快速跑通流程。缺点是每次搜索都重新计算全库分数,如果句子量在百万级,需要把 BM25 索引固化到磁盘,或者使用 Elasticsearch。eRisk 这类任务的文本量通常可控,先用内存版验证完全足够。
5.3 Semantic 检索:向量召回
接下来用 sentence-transformers 实现语义检索。这里采用先对全部句子编码,再用 numpy 做余弦相似度计算的方式,便于理解原理。句子量更大时建议换 faiss。
# 文件路径:semantic_search.py import json import numpy as np from sentence_transformers import SentenceTransformer def build_semantic_index(corpus_file: str, model_name: str = 'all-MiniLM-L6-v2'): with open(corpus_file, 'r', encoding='utf-8') as f: corpus = json.load(f) model = SentenceTransformer(model_name) texts = [item['text'] for item in corpus] embeddings = model.encode(texts, normalize_embeddings=True, show_progress_bar=True) return model, corpus, embeddings def semantic_search(query: str, model, embeddings, corpus, top_k: int = 200): query_vec = model.encode([query], normalize_embeddings=True)[0] scores = embeddings @ query_vec ranked_idx = np.argsort(scores)[::-1][:top_k] return [corpus[i] for i in ranked_idx], [float(scores[i]) for i in ranked_idx] if __name__ == '__main__': model, corpus, embeddings = build_semantic_index('corpus.json') results, scores = semantic_search('difficulty concentrating', model, embeddings, corpus, top_k=10) for sent, score in zip(results, scores): print(round(score, 4), sent['text'])这里有两个细节值得注意:
- normalize_embeddings=True 确保所有向量是单位向量,之后可以直接用矩阵乘法代替余弦相似度计算。
- 语义检索输出的相似度分数可能是负值,这是正常的。后续融合时不要直接拿原始分数相加,最好使用排名位置信息。
5.4 融合召回结果:RRF
Sparse 和 Semantic 的分数尺度完全不同,不能直接相加。这里用 RRF(Reciprocal Rank Fusion)融合两个排序列表。RRF 的核心思想是:一个文档在两个列表中排名越靠前,融合分越高。
# 文件路径:fusion.py from typing import List, Dict def rrf_fusion(ranked_lists: List[List[Dict]], k: int = 60, top_k: int = 100) -> List[Dict]: scores = {} for ranked in ranked_lists: for rank, doc in enumerate(ranked): doc_id = doc.get('id') or doc['text'] if doc_id not in scores: scores[doc_id] = 0.0 scores[doc_id] = 0.0 scores[doc_id] += 1.0 / (k + rank + 1) ranked_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k] return [doc for doc, _ in ranked_docs]这里为了简单,直接用句子文本作为文档 ID。真实系统里建议给每个句子分配一个唯一 ID,避免相同文本被误合并。
融合之后,就可以得到待精排的候选列表。这个列表通常控制在 100 条以内,后续 LLM 精排只处理这个列表,能大幅降低成本。
5.5 LLM Reranking:用大模型精排候选句子
LLM 重排序的代码实现取决于你用的是商业 API 还是本地模型。这里以 OpenAI 兼容接口为例,因为很多开源模型部署工具也支持这个协议。
# 文件路径:llm_rerank.py import json import os from openai import OpenAI SYSTEM_PROMPT = """ 你是一个信息检索专家。给定一个查询和一个句子,判断该句子是否与查询描述的ADHD症状相关。 请只输出一个JSON对象,格式为: {"score": 0.0, "reason": "简短理由"} 评分标准: 5分:句子直接描述了查询中的症状。 3分:句子明显相关,但表达比较间接。 1分:句子主题相关,但不构成症状表现。 0分:句子与查询无关。 """.strip() def build_user_prompt(query: str, sentence: str) -> str: return f""" 查询:{query} 句子:{sentence} 请判断句子与查询的相关程度,输出JSON。 """.strip() def parse_llm_response(content: str): # 兼容可能带 markdown 代码块的输出 content = content.strip() if content.startswith("```"): content = content.strip("`") if content.startswith("json"): content = content[4:] return json.loads(content) def llm_rerank(query: str, candidates: list, top_k: int = 10): client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL", None) ) scored = [] for doc in candidates: response = client.chat.completions.create( model=os.getenv("RERANK_MODEL", "gpt-4o-mini"), response_format={"type": "json_object"}, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": build_user_prompt(query, doc['text'])} ], temperature=0, ) content = response.choices[0].message.content try: parsed = parse_llm_response(content) score = float(parsed.get("score", 0.0)) reason = parsed.get("reason", "") except Exception as e: print(f"parse error: {e}, content={content}") score = 0.0 reason = "parse_failed" scored.append({ "text": doc["text"], "llm_score": score, "reason": reason }) scored.sort(key=lambda x: x["llm_score"], reverse=True) return scored[:top_k] if __name__ == '__main__': candidates = [ {"text": "I can never finish a movie without checking my phone."}, {"text": "There’s a new coffee shop near my house."}, {"text": "I lost my keys again and found them in the fridge."}, ] results = llm_rerank("difficulty concentrating", candidates, top_k=2) for item in results: print(item["llm_score"], item["text"], item["reason"])需要特别提醒三点:
- temperature 必须设置为 0,否则同一个句子不同轮次可能得到不同分数。
- response_format 和模型相关,不是所有开源模型都支持 json_object 模式。如果服务不支持,可以把响应格式要求写进 prompt,并做好异常解析。
- 不要把 API Key 写在代码里,用环境变量管理。
5.6 串联完整流水线
把上面的模块串起来,就得到一个完整的检索重排序系统。
# 文件路径:pipeline.py import json from sparse_search import build_bm25, sparse_search from semantic_search import build_semantic_index, semantic_search from fusion import rrf_fusion from llm_rerank import llm_rerank class SymptomSentencePipeline: def __init__(self, corpus_file: str, semantic_model: str = "all-MiniLM-L6-v2"): self.corpus_file = corpus_file self.bm25, self.corpus = build_bm25(corpus_file) self.sem_model, self.sem_corpus, self.embeddings = build_semantic_index(corpus_file, semantic_model) def search(self, query: str, candidate_pool: int = 200, fusion_topk: int = 100, llm_topk: int = 10): sparse_results, _ = sparse_search(query, self.bm25, self.corpus, top_k=candidate_pool) semantic_results, _ = semantic_search(query, self.sem_model, self.embeddings, self.corpus, top_k=candidate_pool) fused = rrf_fusion([sparse_results, semantic_results], top_k=fusion_topk) return llm_rerank(query, fused, top_k=llm_topk) if __name__ == '__main__': pipe = SymptomSentencePipeline("corpus.json") results = pipe.search("difficulty organizing tasks") for item in results: print(item["llm_score"], item["text"])这个 pipeline 类可以是后续所有实验的基础。你只需要替换 corpus 文件和 query,就能跑新的任务。
6. 运行结果与效果验证
6.1 运行命令
假设你已经准备好raw_posts.json,可以按顺序执行:
python prepare_corpus.py python pipeline.py输出应该是每个候选句子的 LLM 分数、文本和理由。
6.2 预期输出示例
以 query = "difficulty concentrating" 为例,可能看到这样的输出:
5.0 I can never finish a movie without checking my phone. 4.0 I read the same paragraph three times and still don't know what it says. 1.0 I like to watch movies on weekends. 0.0 There's a new coffee shop near my house.这里不需要和某个具体团队的结果完全一致,重点观察两点:
- 排在最前面的是不是真正描述注意力困难的句子。
- LLM 给出的 reason 是否合理,能不能看出模型判断依据。
6.3 判断成功与否的标准
在本地小样本上,系统跑通只是第一步。判断是否有效,需要看评测指标。
可以写一个简单的评估脚本,计算 nDCG@10。这里给出一个最小实现:
# 文件路径:evaluate.py import numpy as np def dcg_at_k(relevance: list, k: int) -> float: relevance = relevance[:k] if not relevance: return 0.0 return sum(rel / np.log2(idx + 2) for idx, rel in enumerate(relevance)) def ndcg_at_k(relevance: list, k: int) -> float: dcg = dcg_at_k(relevance, k) ideal = sorted(relevance, reverse=True) idcg = dcg_at_k(ideal, k) if idcg == 0: return 0.0 return dcg / idcg # 示例:三位标注人员对 Top10 的相关性打分 relevance = [1, 1, 0, 1, 0, 0, 1, 0, 0, 0] print(ndcg_at_k(relevance, 10))如果 nDCG@10 只有 0.2 或更低,通常说明 LLM 重排序没有发挥预期作用。这时优先检查候选生成阶段是否漏掉了真正相关的句子,而不是急着换更大的模型。
6.4 失败时先查哪里
整个流水线有多个环节,一个问题可能来自任何一层。建议按以下顺序排查:
- 看 BM25 结果。如果 BM25 都没召回相关句子,说明 query 和句子之间没有关键词重叠,需要依赖语义检索。
- 看语义检索结果。如果向量排名靠前的句子都不相关,说明模型领域适应差,考虑换模型或微调。
- 看融合结果。如果融合后相关句子的排名比单个检索还低,检查 RRF 参数和候选集大小。
- 看 LLM 输出。如果 LLM 打分和人工判断明显不一致,检查 prompt 是否说清了任务,以及 temperature 是否为 0。
7. 常见问题与排查方法
下面表格总结了最容易遇到的问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| BM25 召回为空 | 分词粒度不合适,query 关键词太抽象 | 打印 tokenize 后的 query 和 corpus | 中文用 jieba,英文统一小写;考虑人工扩展同义词 |
| 语义检索结果全是无关句子 | 通用 embedding 模型不匹配领域 | 随机抽样 50 条结果人工评估 | 换用领域数据微调模型,或换多语言/医学领域模型 |
| LLM 输出不是合法 JSON | 模型不支持 response_format,prompt 被截断 | 打印原始 content,查看完整输出 | 增加解析回退逻辑,或改用正则提取 score |
| LLM 打分波动大 | temperature 不为 0,或模型版本不稳定 | 固定 temperature=0,多次运行对比 | 使用确定性解码参数,必要时设置随机种子 |
| 融合后排序反而下降 | 原始分数直接相加,或候选池太小 | 打印融合前后的相关句子排名 | 改用 RRF,增加候选池到 100 条以上 |
| 评测指标很低 | 标签定义不统一 | 检查评测集相关性标注一致性 | 重新定义标签标准,多人标注并计算 Cohen’s Kappa |
这些坑在真实项目中很常见,尤其是 LLM 输出解析。不要假设大模型会严格按格式输出,工程上必须对解析失败做兜底。
8. 最佳实践与工程建议
8.1 从最简单的基线开始
如果你第一次做这种系统,强烈建议先只跑 BM25 基线。哪怕它效果不理想,也能让你理解数据形态。然后再逐步加入 Semantic、LLM Reranking。每一步都对比指标,才能知道某个模块到底带来了多少提升。
如果一上来就上全套 pipeline,出现问题根本定位不到哪一层。
8.2 中间结果落盘
Sparse 候选、Semantic 候选、RRF 融合结果、LLM 打分,这四个阶段的中间结果都应该保存成 JSONL 文件。这样做的价值在于:
- 出问题时可以回看。
- 评估时不需要重新调用 LLM,节省成本。
- 多轮实验可以横向对比。
# 保存中间结果示例 import json def save_jsonl(items, path): with open(path, 'w', encoding='utf-8') as f: for item in items: f.write(json.dumps(item, ensure_ascii=False) + '\n')8.3 为 LLM Reranking 加缓存
LLM API 调用既贵又慢。同一个 query 和同一个句子组合,不应该重复调用。可以维护一个 query + sentence 的哈希缓存:
import hashlib def cache_key(query: str, sentence: str) -> str: key = query + " || " + sentence return hashlib.md5(key.encode("utf-8")).hexdigest()在调用 API 前先查缓存,命中的直接用缓存分数。这个优化能让实验成本下降一个数量级。
8.4 隐私与最小化原则
心理健康文本是高度敏感数据。在工程实现中要注意:
- 只使用任务必要字段,不保留社交关系、设备信息、精确时间戳。
- 对外部 API 发送文本前,先做脱敏,比如移除用户名、URL、电话号码。
- 如果数据不能出域,就用本地模型替代外部 API。
- 不要为了提升效果而采集额外个人信息。
8.5 输出定位为研究辅助,而非医学诊断
ADHD 检索系统的输出应该被定位为“研究辅助工具”,不能直接作为诊断依据。建议在系统界面和 API 文档里明确声明:该结果是文本分析提示,需由专业人员复核。这不仅是对用户负责,也是降低法律和伦理风险的必要手段。
8.6 对长句做截断
LLM 输入长度有限。过长的句子不仅浪费 token,还可能让模型忽略关键信息。建议在进入 LLM 前,把句子长度限制在合理范围内,比如 200 到 300 个字符。超长句子可以按语义断点切分,或者只保留核心子句。
def truncate_sentence(text: str, max_len: int = 300) -> str: if len(text) <= max_len: return text return text[:max_len].rsplit(' ', 1)[0] + '...'8.7 多个模型做集成
如果评测集不大,可以尝试用多个语义模型生成多路候选。例如一个通用模型加一个心理学领域模型,再做 RRF 融合。多路召回能显著提升 Recall@100,尤其适合正样本稀疏的 eRisk 任务。
9. 总结与后续学习方向
Sparse、Semantic、LLM Reranking 的组合,解决的是心理健康文本检索中最核心的一个矛盾:如何在成本可控的前提下,兼顾召回率、排序质量和可解释性。BM25 保证基线,向量检索补足语义空间,LLM 最后处理复杂表达。这种漏斗在 eRisk 2026 Task 3 这种句子级症状检索任务里,是性价比最高的工程路线。
做完这套系统之后,如果你还想继续深入,可以从三个方向扩展:
第一,训练自己的重排序模型。用 LLM 对训练集生成标注,再用 cross-encoder 蒸馏出一个小模型,这样可以降低成本并提高稳定性。
第二,把症状检索与用户级风险预测打通。句子级证据最终要汇总成用户级判断,这里可以尝试用分数聚合、证据抽取、多任务学习等方式。
第三,引入可解释性输出。不只是给分数,还要给出模型判定相关的原因。比如“该句出现了‘丢三落四’‘找不到钥匙’两个行为描述”,这样系统才更容易被心理专业人员信任。
如果你打算参加下一届 eRisk,最稳妥的建议是:先提交一个纯 BM25 基线,确保流程和数据管道没有问题,再在第二个版本加入 semantics,最终版本再上 LLM reranking。这样做不会因为实验节奏太快而错过真正的问题,也能让每次迭代的效果可量化。