414 lines
13 KiB
Python
414 lines
13 KiB
Python
import asyncio
|
|
import os
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("OPENAI_API_KEY", "test-key")
|
|
|
|
from yuxi.knowledge.eval import benchmark_generation
|
|
from yuxi.knowledge.eval.benchmark_generation import (
|
|
build_benchmark_generation_prompt,
|
|
clamp_neighbors_count,
|
|
collect_kb_chunks,
|
|
iter_generated_benchmark_items,
|
|
normalize_generation_concurrency_count,
|
|
select_graph_enhanced_chunks,
|
|
select_neighbor_chunks_by_kb_query,
|
|
)
|
|
|
|
|
|
class FakeKnowledgeBase:
|
|
pass
|
|
|
|
|
|
class FakeGenerationKnowledgeBase:
|
|
def __init__(self, query_results=None):
|
|
self.query_results = query_results or []
|
|
self.query_calls = []
|
|
|
|
async def aquery(self, query_text, kb_id, **kwargs):
|
|
self.query_calls.append({"query_text": query_text, "kb_id": kb_id, **kwargs})
|
|
return self.query_results
|
|
|
|
|
|
class FakeLlm:
|
|
def __init__(self, gold_chunk_id="anchor_chunk"):
|
|
self.gold_chunk_id = gold_chunk_id
|
|
self.prompts = []
|
|
|
|
async def call(self, prompt, stream):
|
|
self.prompts.append(prompt)
|
|
return SimpleNamespace(
|
|
content=('{"query":"问题","gold_answer":"答案","gold_chunk_ids":["' + self.gold_chunk_id + '"]}')
|
|
)
|
|
|
|
|
|
class NoQueryKnowledgeBase(FakeGenerationKnowledgeBase):
|
|
async def aquery(self, query_text, kb_id, **kwargs):
|
|
raise AssertionError("neighbors_count=1 时不应调用 aquery")
|
|
|
|
|
|
class TrackingLlm:
|
|
def __init__(self, content=None, delay=0):
|
|
self.content = content or '{"query":"问题","gold_answer":"答案","gold_chunk_ids":["anchor_chunk"]}'
|
|
self.delay = delay
|
|
self.active_calls = 0
|
|
self.max_active_calls = 0
|
|
self.calls = 0
|
|
|
|
async def call(self, prompt, stream):
|
|
self.calls += 1
|
|
self.active_calls += 1
|
|
self.max_active_calls = max(self.max_active_calls, self.active_calls)
|
|
try:
|
|
if self.delay:
|
|
await asyncio.sleep(self.delay)
|
|
return SimpleNamespace(content=self.content)
|
|
finally:
|
|
self.active_calls -= 1
|
|
|
|
|
|
class FakeGraphGenerationKnowledgeBase(FakeGenerationKnowledgeBase):
|
|
pass
|
|
|
|
|
|
def make_chunk(
|
|
chunk_id: str,
|
|
*,
|
|
kb_id: str = "db_1",
|
|
file_id: str = "file_a",
|
|
content: str = "anchor content",
|
|
chunk_index: int = 0,
|
|
graph_indexed: bool = False,
|
|
ent_ids: list[str] | None = None,
|
|
):
|
|
return SimpleNamespace(
|
|
chunk_id=chunk_id,
|
|
kb_id=kb_id,
|
|
file_id=file_id,
|
|
content=content,
|
|
chunk_index=chunk_index,
|
|
graph_indexed=graph_indexed,
|
|
ent_ids=ent_ids,
|
|
tags=None,
|
|
extraction_result=None,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fake_chunk_repository(monkeypatch):
|
|
class FakeChunkRepository:
|
|
chunks = [make_chunk("anchor_chunk")]
|
|
|
|
async def list_by_kb_id(self, kb_id):
|
|
return [chunk for chunk in self.chunks if chunk.kb_id == kb_id]
|
|
|
|
monkeypatch.setattr(
|
|
"yuxi.repositories.knowledge_chunk_repository.KnowledgeChunkRepository",
|
|
FakeChunkRepository,
|
|
)
|
|
return FakeChunkRepository
|
|
|
|
|
|
def test_clamp_neighbors_count():
|
|
assert clamp_neighbors_count(-1) == 0
|
|
assert clamp_neighbors_count(3) == 3
|
|
assert clamp_neighbors_count(11) == 10
|
|
|
|
|
|
def test_normalize_generation_concurrency_count():
|
|
assert normalize_generation_concurrency_count(None) == 10
|
|
assert normalize_generation_concurrency_count("") == 10
|
|
assert normalize_generation_concurrency_count(0) == 1
|
|
assert normalize_generation_concurrency_count(-5) == 1
|
|
assert normalize_generation_concurrency_count(10000) == 20
|
|
|
|
|
|
def test_build_benchmark_generation_prompt_contains_required_schema():
|
|
prompt = build_benchmark_generation_prompt([("chunk_1", "片段内容")])
|
|
|
|
assert "片段ID=chunk_1" in prompt
|
|
assert "query、gold_answer、gold_chunk_ids" in prompt
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_collect_kb_chunks_filters_kb_id(fake_chunk_repository):
|
|
fake_chunk_repository.chunks = [
|
|
make_chunk("file_a_chunk", content="内容"),
|
|
make_chunk("file_b_chunk", kb_id="db_2", file_id="file_b", content="其他"),
|
|
]
|
|
|
|
chunks = await collect_kb_chunks(FakeKnowledgeBase(), "db_1")
|
|
|
|
assert chunks == [
|
|
{
|
|
"id": "file_a_chunk",
|
|
"content": "内容",
|
|
"file_id": "file_a",
|
|
"chunk_index": 0,
|
|
"graph_indexed": False,
|
|
"ent_ids": [],
|
|
"tags": [],
|
|
"extraction_result": None,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_with_one_chunk_does_not_query(monkeypatch):
|
|
fake_llm = FakeLlm()
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=NoQueryKnowledgeBase(),
|
|
kb_id="db_1",
|
|
count=1,
|
|
neighbors_count=1,
|
|
llm_model_spec="test-provider:test-model",
|
|
)
|
|
]
|
|
|
|
assert items == [{"query": "问题", "gold_chunk_ids": ["anchor_chunk"], "gold_answer": "答案"}]
|
|
assert "片段ID=anchor_chunk" in fake_llm.prompts[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_neighbor_chunks_by_kb_query_filters_anchor():
|
|
kb = FakeGenerationKnowledgeBase(
|
|
query_results=[
|
|
{
|
|
"content": "anchor content",
|
|
"metadata": {"chunk_id": "anchor_chunk", "file_id": "file_a", "chunk_index": 0},
|
|
},
|
|
{
|
|
"content": "neighbor content",
|
|
"metadata": {"chunk_id": "neighbor_chunk", "file_id": "file_a", "chunk_index": 1},
|
|
},
|
|
]
|
|
)
|
|
|
|
chunks = await select_neighbor_chunks_by_kb_query(
|
|
kb_instance=kb,
|
|
kb_id="db_1",
|
|
anchor_chunk={"id": "anchor_chunk", "content": "anchor content", "file_id": "file_a", "chunk_index": 0},
|
|
neighbors_count=1,
|
|
)
|
|
|
|
assert chunks == [{"id": "neighbor_chunk", "content": "neighbor content", "file_id": "file_a", "chunk_index": 1}]
|
|
assert kb.query_calls == [
|
|
{
|
|
"query_text": "anchor content",
|
|
"kb_id": "db_1",
|
|
"search_mode": "vector",
|
|
"final_top_k": 4,
|
|
"use_reranker": False,
|
|
"similarity_threshold": 0.0,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_graph_enhanced_chunks_expands_by_ppr_with_anchor_bias(monkeypatch):
|
|
calls = []
|
|
|
|
async def fake_rank(self, kb_id, seed_weights, *, max_nodes, top_k, damping):
|
|
calls.append(dict(seed_weights))
|
|
if len(calls) == 1:
|
|
return [("anchor", 0.9), ("neighbor_1", 0.8)]
|
|
return [("anchor", 0.9), ("neighbor_1", 0.8), ("neighbor_2", 0.7)]
|
|
|
|
monkeypatch.setattr(
|
|
"yuxi.knowledge.graphs.milvus_graph_service.MilvusGraphService.query_and_rank_chunks_by_ppr",
|
|
fake_rank,
|
|
)
|
|
chunks_by_id = {
|
|
"anchor": {"id": "anchor", "content": "anchor", "ent_ids": ["anchor_entity"]},
|
|
"neighbor_1": {"id": "neighbor_1", "content": "neighbor 1", "ent_ids": ["entity_1"]},
|
|
"neighbor_2": {"id": "neighbor_2", "content": "neighbor 2", "ent_ids": ["entity_2"]},
|
|
}
|
|
|
|
chunks = await select_graph_enhanced_chunks(
|
|
kb_id="db_1",
|
|
anchor_chunk=chunks_by_id["anchor"],
|
|
chunks_by_id=chunks_by_id,
|
|
context_count=3,
|
|
graph_expand_top_k=1,
|
|
)
|
|
|
|
assert [chunk["id"] for chunk in chunks] == ["anchor", "neighbor_1", "neighbor_2"]
|
|
assert calls[0] == {"anchor_entity": 1.0}
|
|
assert calls[1]["anchor_entity"] == 1.0
|
|
assert calls[1]["entity_1"] == 0.9
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_graph_mode_uses_graph_indexed_anchor(monkeypatch, fake_chunk_repository):
|
|
fake_chunk_repository.chunks = [
|
|
make_chunk(
|
|
"vector_anchor",
|
|
content="vector content",
|
|
chunk_index=0,
|
|
graph_indexed=False,
|
|
ent_ids=["vector_entity"],
|
|
),
|
|
make_chunk(
|
|
"graph_anchor",
|
|
content="graph anchor content",
|
|
chunk_index=1,
|
|
graph_indexed=True,
|
|
ent_ids=["anchor_entity"],
|
|
),
|
|
make_chunk(
|
|
"graph_neighbor",
|
|
content="graph neighbor content",
|
|
chunk_index=2,
|
|
graph_indexed=False,
|
|
ent_ids=["neighbor_entity"],
|
|
),
|
|
]
|
|
|
|
async def fake_rank(self, kb_id, seed_weights, *, max_nodes, top_k, damping):
|
|
assert seed_weights["anchor_entity"] == 1.0
|
|
return [("graph_anchor", 0.9), ("graph_neighbor", 0.8)]
|
|
|
|
fake_llm = FakeLlm(gold_chunk_id="graph_neighbor")
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
monkeypatch.setattr(
|
|
"yuxi.knowledge.graphs.milvus_graph_service.MilvusGraphService.query_and_rank_chunks_by_ppr",
|
|
fake_rank,
|
|
)
|
|
kb = FakeGraphGenerationKnowledgeBase()
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=kb,
|
|
kb_id="db_1",
|
|
count=1,
|
|
neighbors_count=2,
|
|
llm_model_spec="test-provider:test-model",
|
|
generation_mode="graph_enhanced",
|
|
)
|
|
]
|
|
|
|
assert items == [{"query": "问题", "gold_chunk_ids": ["graph_neighbor"], "gold_answer": "答案"}]
|
|
assert kb.query_calls == []
|
|
assert "片段ID=graph_anchor" in fake_llm.prompts[0]
|
|
assert "片段ID=graph_neighbor" in fake_llm.prompts[0]
|
|
assert "片段ID=vector_anchor" not in fake_llm.prompts[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_uses_query_neighbor(monkeypatch):
|
|
fake_llm = FakeLlm(gold_chunk_id="neighbor_chunk")
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
kb = FakeGenerationKnowledgeBase(
|
|
query_results=[
|
|
{
|
|
"content": "neighbor content",
|
|
"metadata": {"chunk_id": "neighbor_chunk", "file_id": "file_a", "chunk_index": 1},
|
|
}
|
|
]
|
|
)
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=kb,
|
|
kb_id="db_1",
|
|
count=1,
|
|
neighbors_count=2,
|
|
llm_model_spec="test-provider:test-model",
|
|
)
|
|
]
|
|
|
|
assert items == [{"query": "问题", "gold_chunk_ids": ["neighbor_chunk"], "gold_answer": "答案"}]
|
|
assert kb.query_calls[0]["query_text"] == "anchor content"
|
|
assert kb.query_calls[0]["search_mode"] == "vector"
|
|
assert "片段ID=neighbor_chunk" in fake_llm.prompts[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_falls_back_to_anchor_when_query_empty(monkeypatch):
|
|
fake_llm = FakeLlm()
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=FakeGenerationKnowledgeBase(query_results=[]),
|
|
kb_id="db_1",
|
|
count=1,
|
|
neighbors_count=2,
|
|
llm_model_spec="test-provider:test-model",
|
|
)
|
|
]
|
|
|
|
assert items == [{"query": "问题", "gold_chunk_ids": ["anchor_chunk"], "gold_answer": "答案"}]
|
|
assert "片段ID=anchor_chunk" in fake_llm.prompts[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_respects_concurrency_count(monkeypatch):
|
|
fake_llm = TrackingLlm(delay=0.01)
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=NoQueryKnowledgeBase(),
|
|
kb_id="db_1",
|
|
count=4,
|
|
neighbors_count=1,
|
|
concurrency_count=2,
|
|
llm_model_spec="test-provider:test-model",
|
|
)
|
|
]
|
|
|
|
assert len(items) == 4
|
|
assert fake_llm.max_active_calls == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_returns_at_most_count(monkeypatch):
|
|
fake_llm = TrackingLlm(delay=0.01)
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=NoQueryKnowledgeBase(),
|
|
kb_id="db_1",
|
|
count=3,
|
|
neighbors_count=1,
|
|
concurrency_count=10,
|
|
llm_model_spec="test-provider:test-model",
|
|
)
|
|
]
|
|
|
|
assert len(items) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_generated_benchmark_items_stops_at_max_attempts(monkeypatch):
|
|
fake_llm = TrackingLlm(content='{"query":"","gold_answer":"答案","gold_chunk_ids":["anchor_chunk"]}')
|
|
monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: fake_llm)
|
|
|
|
items = [
|
|
item
|
|
async for item in iter_generated_benchmark_items(
|
|
kb_instance=NoQueryKnowledgeBase(),
|
|
kb_id="db_1",
|
|
count=2,
|
|
neighbors_count=1,
|
|
concurrency_count=10,
|
|
llm_model_spec="test-provider:test-model",
|
|
)
|
|
]
|
|
|
|
assert items == []
|
|
assert fake_llm.calls == 50
|