Commit 7ba212d1 7ba212d111e8d255f108a791b618391ba08a8ecc by 沈秋雨

bugfix

1 parent 3cd77d9d
...@@ -43,6 +43,16 @@ cp .env.example .env ...@@ -43,6 +43,16 @@ cp .env.example .env
43 - `RAGAS_HTTP_KEEPALIVE=false` 会让 RAGAS LLM 请求使用短连接,规避长流程中 async HTTP 连接清理问题 43 - `RAGAS_HTTP_KEEPALIVE=false` 会让 RAGAS LLM 请求使用短连接,规避长流程中 async HTTP 连接清理问题
44 - `RAGAS_TESTSET_TRANSFORMS=single_hop_entities` 会让 `prechunked` 只运行单跳 QA 所需的实体抽取 transform;设为 `default` 可回到 Ragas 默认 prechunked transforms 44 - `RAGAS_TESTSET_TRANSFORMS=single_hop_entities` 会让 `prechunked` 只运行单跳 QA 所需的实体抽取 transform;设为 `default` 可回到 Ragas 默认 prechunked transforms
45 45
46 ### 测试集生成模式
47
48 `TESTSET_RAGAS_MODE` 控制 `05_generate_testset.py` 如何用 Ragas 生成 QA:
49
50 - `direct`:工程兜底模式。脚本直接把 WeKnora chunks 构造成单跳场景,再调用 Ragas 的 single-hop sample generator 生成 QA。它跳过 Ragas 的文档 transform 和 scenario generation,最稳,但不是完整的 Ragas testset generation 流程。
51 - `prechunked`:Ragas 面向“已经切好 chunk”的标准入口,对应 `generate_with_chunks()`。这里的 pre-chunked 指输入已经是 WeKnora 导出的 chunk,不需要 Ragas 再从原始文档切分。当前配置默认只启用 `single_hop_entities` transform,并限制为 single-hop QA,避免不相关 chunk 被组合成 multi-hop 问题。
52 - `langchain_docs`:Ragas 面向 LangChain Document 的原始文档入口,对应 `generate_with_langchain_docs()`。它可能触发标题、摘要、拆分等默认文档预处理,不适合作为本项目主路径,只保留给对比实验。
53
54 当前推荐先用 `direct` 跑通评测闭环;需要验证 Ragas 标准 pre-chunked 生成链路时,再切换到 `prechunked`
55
46 ## 首轮 Pilot 56 ## 首轮 Pilot
47 57
48 把原始文件放到 `data/raw_docs/`,脚本会按扩展名自动识别 PDF 和 XLSX。也兼容旧目录: 58 把原始文件放到 `data/raw_docs/`,脚本会按扩展名自动识别 PDF 和 XLSX。也兼容旧目录:
......
...@@ -290,7 +290,13 @@ RAGAS_HTTP_KEEPALIVE=false ...@@ -290,7 +290,13 @@ RAGAS_HTTP_KEEPALIVE=false
290 RAGAS_TESTSET_TRANSFORMS=single_hop_entities 290 RAGAS_TESTSET_TRANSFORMS=single_hop_entities
291 ``` 291 ```
292 292
293 `direct` 模式会跳过 Ragas 默认的 `HeadlinesExtractor``SummaryExtractor``NERExtractor` 文档预处理链路,直接把 WeKnora chunks 组装成 Ragas KnowledgeGraph 并生成单跳 QA。`prechunked``langchain_docs` 仅用于对比实验,遇到本地 vLLM 结构化输出不稳定时不建议使用。 293 `TESTSET_RAGAS_MODE` 支持三种模式:
294
295 - `direct`:工程兜底模式。脚本直接把 WeKnora chunks 构造成单跳场景,再调用 Ragas 的 single-hop sample generator 生成 QA。它跳过 Ragas 默认的 `HeadlinesExtractor``SummaryExtractor``NERExtractor`、scenario generation 等中间步骤,最稳,但不是完整的 Ragas testset generation 流程。
296 - `prechunked`:Ragas 面向“已经切好 chunk”的标准入口,对应 `generate_with_chunks()`。这里的 pre-chunked 指输入已经是 WeKnora 导出的 chunk,不需要 Ragas 再从原始文档切分。当前建议配合 `RAGAS_TESTSET_TRANSFORMS=single_hop_entities`,只做单跳 QA 所需的实体抽取,并限制为 single-hop,避免多个不相关 chunk 被组合成 multi-hop 问题。
297 - `langchain_docs`:Ragas 面向 LangChain Document 的原始文档入口,对应 `generate_with_langchain_docs()`。它可能触发标题、摘要、拆分等默认文档预处理,不适合作为本项目主路径,只保留给对比实验。
298
299 当前推荐先用 `direct` 跑通评测闭环;需要验证 Ragas 标准 pre-chunked 生成链路时,再切换到 `prechunked`
294 300
295 如果使用 Qwen thinking 模型,`RAGAS_ENABLE_THINKING=false` 会只在 RAGAS 请求里附加 `chat_template_kwargs.enable_thinking=false`,避免 RAGAS 的 JSON/Pydantic 结构化输出被 `Thinking Process` 前缀破坏;WeKnora 本身的检索问答链路不经过这些脚本,不会受影响。 301 如果使用 Qwen thinking 模型,`RAGAS_ENABLE_THINKING=false` 会只在 RAGAS 请求里附加 `chat_template_kwargs.enable_thinking=false`,避免 RAGAS 的 JSON/Pydantic 结构化输出被 `Thinking Process` 前缀破坏;WeKnora 本身的检索问答链路不经过这些脚本,不会受影响。
296 302
......
...@@ -26,7 +26,12 @@ def retrieval_metrics( ...@@ -26,7 +26,12 @@ def retrieval_metrics(
26 for row in samples: 26 for row in samples:
27 gold = set(row.get("gold_chunk_ids") or []) 27 gold = set(row.get("gold_chunk_ids") or [])
28 refs = row.get("weknora_references") or [] 28 refs = row.get("weknora_references") or []
29 predicted = [str(ref.get("id")) for ref in refs if ref.get("id")] 29 predicted = [
30 chunk_id
31 for ref in refs
32 for chunk_id in [_reference_chunk_id(ref)]
33 if chunk_id
34 ]
30 for k in ks: 35 for k in ks:
31 top_k = predicted[:k] 36 top_k = predicted[:k]
32 hits = len(gold.intersection(top_k)) 37 hits = len(gold.intersection(top_k))
...@@ -53,10 +58,13 @@ def generate_summary_report( ...@@ -53,10 +58,13 @@ def generate_summary_report(
53 *, 58 *,
54 scores_csv_path: str = "data/reports/ragas_scores.csv", 59 scores_csv_path: str = "data/reports/ragas_scores.csv",
55 ragas_input_path: str = "data/runs/ragas_input.jsonl", 60 ragas_input_path: str = "data/runs/ragas_input.jsonl",
61 testset_path: str = "data/testsets/testset.reviewed.jsonl",
56 answers_path: str = "data/runs/weknora_answers.jsonl", 62 answers_path: str = "data/runs/weknora_answers.jsonl",
57 output_path: str = "data/reports/summary.md", 63 output_path: str = "data/reports/summary.md",
58 ) -> str: 64 ) -> str:
59 ragas_rows = read_jsonl(ragas_input_path, missing_ok=True) 65 ragas_rows = read_jsonl(ragas_input_path, missing_ok=True)
66 reviewed_rows = read_jsonl(testset_path, missing_ok=True)
67 ragas_rows = _backfill_gold_chunks(ragas_rows, reviewed_rows)
60 answer_rows = read_jsonl(answers_path, missing_ok=True) 68 answer_rows = read_jsonl(answers_path, missing_ok=True)
61 scores = pd.read_csv(scores_csv_path) if Path(scores_csv_path).exists() else pd.DataFrame() 69 scores = pd.read_csv(scores_csv_path) if Path(scores_csv_path).exists() else pd.DataFrame()
62 70
...@@ -155,6 +163,41 @@ def _worst_rows(scores: pd.DataFrame, column: str, *, limit: int = 10) -> list[d ...@@ -155,6 +163,41 @@ def _worst_rows(scores: pd.DataFrame, column: str, *, limit: int = 10) -> list[d
155 return scores.sort_values(metric_column, ascending=True).head(limit).to_dict(orient="records") 163 return scores.sort_values(metric_column, ascending=True).head(limit).to_dict(orient="records")
156 164
157 165
166 def _backfill_gold_chunks(
167 ragas_rows: list[dict[str, Any]],
168 reviewed_rows: list[dict[str, Any]],
169 ) -> list[dict[str, Any]]:
170 reviewed_by_id = {
171 row.get("sample_id"): row
172 for row in reviewed_rows
173 if row.get("sample_id")
174 }
175 result: list[dict[str, Any]] = []
176 for row in ragas_rows:
177 if row.get("gold_chunk_ids"):
178 result.append(row)
179 continue
180 reviewed = reviewed_by_id.get(row.get("sample_id")) or {}
181 if reviewed.get("gold_chunk_ids"):
182 row = {**row, "gold_chunk_ids": reviewed.get("gold_chunk_ids")}
183 result.append(row)
184 return result
185
186
187 def _reference_chunk_id(reference: dict[str, Any]) -> str | None:
188 for key in ("id", "chunk_id", "chunkId"):
189 value = reference.get(key)
190 if value:
191 return str(value)
192 raw = reference.get("raw")
193 if isinstance(raw, dict):
194 for key in ("id", "chunk_id", "chunkId"):
195 value = raw.get(key)
196 if value:
197 return str(value)
198 return None
199
200
158 def _metric_column(scores: pd.DataFrame, name: str) -> str | None: 201 def _metric_column(scores: pd.DataFrame, name: str) -> str | None:
159 if name in scores.columns: 202 if name in scores.columns:
160 return name 203 return name
......