Files
QMDSearch/tests/test_e2e_integration.py
T
kplam 51dc8dc4f6 Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
2026-07-29 21:24:40 +08:00

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""端到端集成测试
真实 Ingester + Retriever + 内存 Qdrantlocation=":memory:")串联分层 RAG 全链路,
仅替换两个外部边界:
- FakeOllama:按 prompt 内容返回确定性的 L1/L2/L3 总结、分类 JSON、query 解析 JSON
- DeterministicEmbedding:稳定哈希生成固定维度向量(无语义,仅保证流程可跑)
本机无需 Docker / Ollama / RedisCI 可直接运行。
"""
import json
import random
import uuid
from dataclasses import dataclass
from hashlib import sha256
from typing import Any
import pytest
from fastapi.testclient import TestClient
from qdrant_client import AsyncQdrantClient
from app.api.v1 import document as document_module
from app.api.v1 import search as search_module
from app.config import Settings, settings
from app.core.chunker import Chunker
from app.core.classifier import Classifier
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
from app.core.ingestion import Ingester
from app.core.query_parser import QueryParser
from app.core.retriever import Retriever
from app.core.sparse import SparseEncoder
from app.core.summarizer import Summarizer
from app.main import app
from app.models.document import DocumentInput
from app.models.knowledge import load_taxonomy
from app.models.search import SearchRequest
from app.services.qdrant import (
ALL_COLLECTIONS,
COLLECTION_CHUNKS,
COLLECTION_L1,
COLLECTION_L2,
COLLECTION_L3,
QdrantService,
)
# FakeOllama 的确定性输出
FAKE_L1_SUMMARY = "本文介绍安装指南、环境准备与安装步骤,是一篇集成测试文档。"
FAKE_L3_OUTLINE = (
"## 安装指南\n安装指南的整体流程说明。\n"
"## 环境准备\n环境准备的依赖与注意事项。\n"
"## 安装步骤\n安装步骤的命令与验证方法。"
)
FAKE_L2_HALF = "要点一:短文档的核心通知内容。\n要点二:需要关注的事项。"
FAKE_CATEGORY = "技术文档"
FAKE_TAGS = ["安装", "运维"]
class FakeOllama:
"""按 prompt 内容返回确定性结果的假 OllamaClient
覆盖 Summarizer / Classifier / QueryParser 三类调用方;
分类与 query 解析的置信度可配置,用于构造路由兜底场景。
"""
def __init__(self, classify_confidence: float = 0.9, query_confidence: float = 0.9) -> None:
self.classify_confidence = classify_confidence
self.query_confidence = query_confidence
async def generate(self, prompt: str, json_mode: bool = False) -> str:
if "你是知识库分类助手" in prompt:
return json.dumps(
{"main_category": FAKE_CATEGORY, "tags": FAKE_TAGS, "confidence": self.classify_confidence},
ensure_ascii=False,
)
if "你是搜索查询分析助手" in prompt:
return json.dumps(
{
"categories": [{"name": FAKE_CATEGORY, "confidence": self.query_confidence}],
"rewrite": "安装指南 环境准备 安装步骤",
"keywords": ["安装", "环境准备"],
},
ensure_ascii=False,
)
if "请用一句话对以下文档内容进行高度概括" in prompt:
return FAKE_L1_SUMMARY
if "请提取以下文档的主要章节结构" in prompt:
return "1. 主题一\n2. 主题二"
if "请对以下文档的每个章节/主题进行详细的内容摘要" in prompt:
return FAKE_L3_OUTLINE
if "请对以下文档内容进行详细摘要" in prompt:
return FAKE_L2_HALF
raise AssertionError(f"FakeOllama 收到未识别的 prompt: {prompt[:100]}")
class DeterministicEmbedding:
"""稳定哈希伪向量:同文本恒同向量,维度等于 settings.embedding_dimension"""
async def embed(self, texts: list[str]) -> list[list[float]]:
vectors: list[list[float]] = []
for text in texts:
seed = int.from_bytes(sha256(text.encode("utf-8")).digest()[:8], "big")
rng = random.Random(seed)
vectors.append([rng.random() for _ in range(settings.embedding_dimension)])
return vectors
class FakeCache:
"""无缓存行为的假 RedisCache(get 恒未命中,set 恒成功)"""
async def get_json(self, key: str) -> dict[str, Any] | None:
return None
async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool:
return True
@dataclass
class _Env:
"""一套共享内存 Qdrant 的真实 Ingester + Retriever 环境"""
qdrant: QdrantService
ollama: FakeOllama
ingester: Ingester
retriever: Retriever
async def _make_env(classify_confidence: float = 0.9, query_confidence: float = 0.9) -> _Env:
"""构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量"""
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
await qdrant.ensure_collections()
ollama = FakeOllama(classify_confidence=classify_confidence, query_confidence=query_confidence)
taxonomy = load_taxonomy()
embedding = DeterministicEmbedding()
ingester = Ingester(
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
chunker=Chunker(),
embedding=embedding,
sparse=SparseEncoder(),
qdrant=qdrant,
)
retriever = Retriever(
qdrant=qdrant,
query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type]
embedding=embedding,
sparse_encoder=SparseEncoder(),
)
return _Env(qdrant=qdrant, ollama=ollama, ingester=ingester, retriever=retriever)
def _structured_doc() -> DocumentInput:
"""带 Markdown 标题结构的中文长文档(>500 字符,切出 3 个 section chunk"""
paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 20
text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}"
return DocumentInput(text=text, title="安装文档")
def _short_doc() -> DocumentInput:
"""短文档(< summary_min_text_length),触发 2.5 级回退"""
return DocumentInput(text="# 维护通知\n明天凌晨系统维护,请提前保存工作。", title="维护通知")
async def _counts(qdrant: QdrantService) -> dict[str, int]:
"""四个集合的点数"""
return {name: (await qdrant.client.count(collection_name=name)).count for name in ALL_COLLECTIONS}
class TestIngestIntegration:
"""入库链路:四层集合点数与 chunk payload 完整性"""
async def test_ingest_structured_document(self):
"""结构化长文档 → L1=1、L2=标题数、L3/chunks ≥1chunk payload 字段齐全"""
env = await _make_env()
doc = _structured_doc()
result = await env.ingester.ingest(doc)
# 结果透传
assert result.document_id
assert result.category == FAKE_CATEGORY
assert result.tags == FAKE_TAGS
assert result.category_confidence == 0.9
assert result.summary.l1_summary == FAKE_L1_SUMMARY
assert result.chunks_count >= 1
counts = await _counts(env.qdrant)
assert counts[COLLECTION_L1] == 1
assert counts[COLLECTION_L2] == 3 # 标题数:安装指南/环境准备/安装步骤
assert counts[COLLECTION_L3] >= 1
assert counts[COLLECTION_CHUNKS] == result.chunks_count >= 1
# chunk payloaddoc_summary/category/tags/section_path 齐全且与分类结果一致
points, _ = await env.qdrant.client.scroll(
collection_name=COLLECTION_CHUNKS, limit=100, with_payload=True
)
assert len(points) == result.chunks_count
expected_paths = {"安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"}
for point in points:
payload = point.payload or {}
assert payload["doc_id"] == result.document_id
assert payload["doc_summary"] == FAKE_L1_SUMMARY
assert payload["category"] == FAKE_CATEGORY
assert payload["tags"] == FAKE_TAGS
assert payload["section_path"] in expected_paths
assert payload["text"] in doc.text
async def test_ingest_short_document_l2_half(self):
"""短文档 → 2.5 级:doc_l2 无该 doc 节点,其余层正常写入"""
env = await _make_env()
result = await env.ingester.ingest(_short_doc())
assert result.summary.l2_outline is None
counts = await _counts(env.qdrant)
assert counts[COLLECTION_L1] == 1
assert counts[COLLECTION_L2] == 0 # 2.5 级文档无 L2 大纲节点
assert counts[COLLECTION_L3] >= 1
assert counts[COLLECTION_CHUNKS] >= 1
async def test_reingest_idempotent(self, monkeypatch: pytest.MonkeyPatch):
"""同 doc_id 重复入库:幂等覆盖不报错,各层点数不翻倍"""
env = await _make_env()
doc = _structured_doc()
# 固定 doc_id,两次入库写入同一组确定性 point id(uuid5 覆盖)
fixed_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
monkeypatch.setattr("app.core.ingestion.uuid.uuid4", lambda: fixed_uuid)
first = await env.ingester.ingest(doc)
counts_after_first = await _counts(env.qdrant)
second = await env.ingester.ingest(doc)
counts_after_second = await _counts(env.qdrant)
assert first.document_id == second.document_id == fixed_uuid.hex
assert counts_after_second == counts_after_first
assert counts_after_second[COLLECTION_L1] == 1
assert counts_after_second[COLLECTION_L2] == 3
class TestSearchIntegration:
"""检索链路:正常路由 / 路由兜底 / 空库"""
async def test_search_normal_query(self):
"""正常 queryhits 非空,text 为原文片段,doc_summary 非空,routed_categories 来自分类"""
env = await _make_env()
doc = _structured_doc()
await env.ingester.ingest(doc)
resp = await env.retriever.search(SearchRequest(query="安装步骤有哪些注意事项?"))
assert resp.fallback is False
assert resp.routed_categories == [FAKE_CATEGORY]
assert resp.hits
hit = resp.hits[0]
assert hit.text in doc.text
assert hit.doc_summary == FAKE_L1_SUMMARY
assert hit.score > 0
async def test_search_route_fallback(self):
"""query 解析低置信 → 路由兜底:fallback=True 且仍有 hits"""
env = await _make_env(query_confidence=0.3)
doc = _structured_doc()
await env.ingester.ingest(doc)
resp = await env.retriever.search(SearchRequest(query="随便问点什么"))
assert resp.fallback is True
assert resp.routed_categories == []
assert resp.hits
assert resp.hits[0].text in doc.text
async def test_search_empty_db(self):
"""空库 searchhits 为空、不报错、fallback=True"""
env = await _make_env()
resp = await env.retriever.search(SearchRequest(query="空库查询"))
assert resp.hits == []
assert resp.fallback is True
class TestApiIntegration:
"""API 层冒烟:documents → search → knowledge/categories,统一响应格式 code=0"""
async def test_api_smoke(self, monkeypatch: pytest.MonkeyPatch):
env = await _make_env()
doc = _structured_doc()
# 入库走异步任务:测试自建纯内存任务管理器(内存 Qdrant + FakeOllama 的 Ingester
manager = IngestTaskManager(env.ingester, None, Settings())
# lifespan 的 ensure_collections 替换为空操作(真实 Qdrant 不可达时也能启动)
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
# 模块级单例替换为集成环境实例
monkeypatch.setattr(document_module, "_task_manager", manager)
monkeypatch.setattr(search_module, "_retriever", env.retriever)
monkeypatch.setattr(search_module, "get_cache", lambda: FakeCache())
with TestClient(app) as client:
# 入库:202 拿 task_id,等待任务终态后断言入库结果
resp_doc = client.post("/api/v1/documents", json={"text": doc.text, "title": doc.title})
assert resp_doc.status_code == 202
body_doc = resp_doc.json()
assert body_doc["code"] == 0
assert body_doc["data"]["status"] == "pending"
task_id = body_doc["data"]["task_id"]
assert task_id
final = await manager.wait_done(task_id)
assert final["status"] == IngestTaskStatus.DONE
result = final["result"]
assert result["document_id"]
assert result["category"] == FAKE_CATEGORY
assert result["chunks_count"] >= 1
# 检索
resp_search = client.post("/api/v1/search", json={"query": "安装步骤有哪些注意事项?"})
body_search = resp_search.json()
assert body_search["code"] == 0
assert body_search["data"]["hits"]
assert body_search["data"]["routed_categories"] == [FAKE_CATEGORY]
# 知识分类
resp_categories = client.get("/api/v1/knowledge/categories")
body_categories = resp_categories.json()
assert body_categories["code"] == 0
assert body_categories["data"]["count"] > 0