Ragas 测试集生成 — 文档变知识图谱,沿图造问答对
本章讲 Ragas 信号最强的特色功能:没有测试集时,怎么把一堆文档自动变成「问题 + 标准答案 + 该用哪段资料」的评测数据。核心妙处是先把文档建成知识图谱,再沿图上的连 通路径造出需要跨多个文档才能回答的多跳问题。
1. 为什么不直接「给文档让 LLM 出题」
最朴素的做法:把每段文档丢给 LLM「出个问题」。问题是这样只能出单跳问题(答案在单段文字里),测不出 RAG 系统综合多段资料的能力——而那恰恰是 RAG 最难、最该测的地方。
Ragas 的解法:先把文档之间的关系(主题相近、提到同一实体)显式建成一张图,再沿着图上「A 连 B 连 C」这样的路径出题,逼着一个问题的答案散落在多个文档里。
2. 主线全景:文档 → 图 → 问答对
文档 docs
│ ① 转成 Node(type=DOCUMENT/CHUNK)
▼
KnowledgeGraph(只有节点,没有边)
│ ② apply_transforms: 抽标题/摘要/实体 + 切块 + 按相似度/共指连边
▼
KnowledgeGraph(节点 + 关系边)
│ ③ generate_personas_from_kg: 从图里推几个「提问者人设」
│ ④ 按 query_distribution 把 testset_size 拆给各合成器
▼
各 Synthesizer:
single-hop ── 取单个节点 ── 造单跳 scenario
multi-hop ── find_n_indirect_clusters 取路径簇 ── 造多跳 scenario
│ ⑤ 每个 scenario → generate_sample: LLM 据节点内容造(问题,答案)
▼
Testset(问答对 + 标准答案 + 用了哪些节点 + 由哪个合成器造)
总指挥是 TestsetGenerator。最常用的入口 generate_with_langchain_docs:把文档转成 DOCUMENT 节点 → 建空图 → apply_transforms 补齐边 → 调 generate()(src/ragas/testset/synthesizers/generate.py:114-220)。还有 generate_with_chunks(你已切好块,直接当 CHUNK 节点,src/ragas/testset/synthesizers/generate.py:302-412)和 from_langchain / from_llama_index 适配不同生态。
3. 第一步:文档变知识图谱(transforms)
图的两个基本元素都是 Pydantic 模型:
Node= 一个文档或块,带properties字典(page_content、抽出的headlines、summary、entities等),type是DOCUMENT/CHUNK(src/ragas/testset/graph.py:35-89)。add_property故意禁止重复 key(src/ragas/testset/graph.py:60-71)。Relationship= 两个节点间的有向(可双向)边,带type和properties(src/ragas/testset/graph.py:92-142)。序列化时只存节点 id 不存整个节点,避免膨胀(src/ragas/testset/graph.py:140-142,serialize_node)。
transforms 是一串图变换。 apply_transforms 递归地把变换作用到图上(就地修改),Parallel(...) 把多个变换的协程合并并发跑(src/ragas/testset/transforms/engine.py:54-101、23-43)。src/ragas/testset/transforms/ 下分三类:extractors(抽标题/摘要/实体)、splitters(切块)、relationship_builders(按嵌 入相似度或共享实体连边)。default_transforms 据文档自动组一套默认管线。
4. 核心算法:find_n_indirect_clusters(沿图采样路径)
这是测试集生成里工程含量最高的一段。多跳问题需要「一组互相连通的节点」,这个方法就负责从图里高效采出 n 个这样的簇(cluster = 一条连通路径上的节点集合,如 A→B→C→D 得到 {A,B,C,D})。
为什么不能暴力枚举所有路径。 大图上简单路径数量爆炸。所以 Ragas 用了几个工程手段(src/ragas/testset/graph.py:471-645):
怎么读:目标是高效拿到 n 个「多样、不冗余」的连通节点簇
1. 按 relationship_condition 过滤边,建邻接表 adjacency_list
2. 估算需要多少个起点(sample_size),只从这些起点出发
3. 随机但可复现地打乱起点(用所有节点 id 的 sha256 当种子)
4. 从每个起点 DFS,到达 depth_limit / 叶子 / 全访问过 就记一个簇
—— DFS 内置「该起点已攒够簇就提前返回」防止复杂度失控
5. 轮转(round-robin)从各起点的簇里取,凑够 n 个唯一簇
6. 去重 + 子集消除:若新簇是已有簇的超集,删掉那些子集(偏好超集)
几个值得学的细节:
- 可复现的随机。 用所有起点节点 id 拼起来的 SHA-256 前 8 位 hex 当随机种子(
src/ragas/testset/graph.py:592-597),既随机又对同一张图稳定可复现——评测最看重复现性。 - DFS 提前剪枝。
dfs一开始就检查「这个起点攒的簇是否已超过 sample_size」,是就直接返回(src/ragas/testset/graph.py:559-562),避免在稠密图上把路径走爆。还显式if neighbor not in current_path防环(src/ragas/testset/graph.py:582-584)。 - 超集优先去冗余。 凑簇时若新簇
issuperset已有簇,把那些子集踢掉再加新簇(src/ragas/testset/graph.py:625-636)。{A,B,C,D}比{A,B,C}信息更全,所以留大的。
另一个老版算法 find_indirect_clusters 走的是社区发现路线:用 sknetwork 的 Leiden 算法(图聚类)先分社区,再在社区内找路径,小社区暴力枚举、大社区随机游走采样(src/ragas/testset/graph.py:276-469)。方法内的 docstring 还专门澄清:这里「indirect cluster」(共享间接关系的节点组)和 Leiden 的「cluster」(连接紧密的邻域)不是一回事(src/ragas/testset/graph.py:285-291)——诚实的命名警告。
单跳的对应物 是 find_two_nodes_single_rel:直接收集所有 (节点A, 边, 节点B) 三元组,并把较小 id 的节点规范化到前面去重(src/ragas/testset/graph.py:694-738)。
5. 第三步:从簇造问答对(scenario → sample)
拿到节点簇后,要先造场景(scenario)再造样本(sample),两段都是 LLM 调用,中间隔开是为了先批量定好「问什么风格、给谁问」。
Scenario 带四个维度(src/ragas/testset/synthesizers/base.py:55-74,BaseScenario):涉及哪些 nodes、查询 style(完美语法 / 拼写错误 / 口语 / 像搜索词,QueryStyle)、length(长/中/短,QueryLength)、以及一个 persona(提问者人设)。风格和长度的变化让生成的测试集更贴近真实用户的五花八门问法,而不是清一色规整的问题。
Persona 从图里自动推。 没给 persona_list 时,generate_personas_from_kg 据图内容生成几个人设(src/ragas/testset/synthesizers/generate.py:516-522)——比如一份技术文档可能推出「初学者」「资深工程师」两种提问者。
两段式生成(在 generate() 里)。 先用一个 Executor 跑所有合成器的 generate_scenarios(批量造场景),再用第二个 Executor 跑 generate_sample(每个场景造一条问答对)(src/ragas/testset/synthesizers/generate.py:536-605)。BaseSynthesizer 把这两步各包一层回调 group 便于追踪(src/ragas/testset/synthesizers/base.py:96-145)。
query_distribution 控制题型配比。 它是 [(合成器, 概率), ...],calculate_split_values 把 testset_size 按概率拆给各合成器(src/ragas/testset/synthesizers/generate.py:526-528)。不指定就用 default_query_distribution(据图自动配)。
6. 边界与坑
- 依赖额外重型库。 Leiden 聚类要
scikit-network,路径采样用networkx;find_n_indirect_clusters的纯 DFS 路线不依赖 sknetwork,更轻。 - 默认会偷偷用 gpt-4o-mini。 合成器的
_default_llm_factory在没给 LLM 时默认 new 一个 OpenAIgpt-4o-mini(src/ragas/testset/synthesizers/base.py:23-31);但TestsetGenerator的几个generate_with_*入口会强制你显式提供 LLM/embedding,否则ValueError(src/ragas/testset/synthesizers/generate.py:175-184)——防止你不知不觉烧钱。 - rollback 没实现。
rollback_transforms直接NotImplementedError(src/ragas/testset/transforms/engine.py:104-113),变换是单向的。 depth_limit至少为 2。 否则成不了簇,会ValueError(src/ragas/testset/graph.py:517-518)。- 作用域: 本章详读了图结构与采样算法、生成总流程、scenario/synthesizer 基类;具体的单跳/多跳合成器子类(
synthesizers/single_hop、multi_hop)的 prompt 细节未逐一展开。
7. 代码地图
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 图数据结构 | src/ragas/testset/graph.py | KnowledgeGraph, Node, NodeType, Relationship |
| 多跳簇采样(主) | src/ragas/testset/graph.py | find_n_indirect_clusters, dfs |
| 多跳簇采样(Leiden) | src/ragas/testset/graph.py | find_indirect_clusters, get_node_clusters |
| 单跳三元组 | src/ragas/testset/graph.py | find_two_nodes_single_rel |
| 生成总指挥 | src/ragas/testset/synthesizers/generate.py | TestsetGenerator, generate, generate_with_langchain_docs, generate_with_chunks |
| 场景/合成器基类 | src/ragas/testset/synthesizers/base.py | BaseSynthesizer, BaseScenario, QueryStyle, QueryLength |
| 图变换引擎 | src/ragas/testset/transforms/engine.py | apply_transforms, Parallel, rollback_transforms |
| 人设生成 | src/ragas/testset/persona.py | generate_personas_from_kg, Persona |