从BM25到LLM重排序:构建ADHD症状句子检索系统
2026/8/28 20:01:58 网站建设 项目流程

做心理健康领域的 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 数据流全景

整个流程可以描述为:

  1. 读取原始文本。
  2. 分句,切分成长度合适的句子。
  3. 对句子做基本预处理,比如去 URL、去多余空白。
  4. 建立 BM25 索引,同时生成句子向量。
  5. 输入 query,分别得到 BM25 候选和向量候选。
  6. 融合候选,得到待精排列表。
  7. 调用 LLM 打分。
  8. 输出最终排序结果。
  9. 用标注好的评测集计算 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 症状为例,可以构造这样的样本:

querysentencelabel
difficulty concentratingI can never finish a movie without checking my phone.1
difficulty concentratingThere’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 失败时先查哪里

整个流水线有多个环节,一个问题可能来自任何一层。建议按以下顺序排查:

  1. 看 BM25 结果。如果 BM25 都没召回相关句子,说明 query 和句子之间没有关键词重叠,需要依赖语义检索。
  2. 看语义检索结果。如果向量排名靠前的句子都不相关,说明模型领域适应差,考虑换模型或微调。
  3. 看融合结果。如果融合后相关句子的排名比单个检索还低,检查 RRF 参数和候选集大小。
  4. 看 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。这样做不会因为实验节奏太快而错过真正的问题,也能让每次迭代的效果可量化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询