Initial commit: QMDSearch 分层信息检索服务

- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
This commit is contained in:
2026-07-29 21:24:40 +08:00
commit 51dc8dc4f6
83 changed files with 10794 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
"""知识统计 API 测试(GET /api/v1/knowledge/stats
两层覆盖:
1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径
2. 真实内存 Qdrantlocation=":memory:")集成:stats 数值与实际写入一致
"""
from collections.abc import Iterator
from typing import Any
import pytest
from fastapi.testclient import TestClient
from qdrant_client import AsyncQdrantClient
from app.api.v1 import knowledge as knowledge_module
from app.config import settings
from app.main import app
from app.services.qdrant import (
COLLECTION_CHUNKS,
COLLECTION_L1,
COLLECTION_L2,
COLLECTION_L3,
QdrantService,
)
DIM = settings.embedding_dimension
def _dense(seed: float) -> list[float]:
"""构造确定性 dense 向量"""
vec = [0.0] * DIM
vec[0] = seed
vec[1] = 1.0 - seed
vec[2] = seed * 0.5
vec[3] = 0.1
return vec
@pytest.fixture
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
"""TestClientlifespan 中的 Qdrant 集合初始化替换为空操作"""
async def _noop_ensure_collections(self: QdrantService) -> None:
return None
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
with TestClient(app) as test_client:
yield test_client
class _FakeQdrantService:
"""假 Qdrant 服务:固定集合计数,scroll_l1 分 2 页返回类目"""
COUNTS = {COLLECTION_L1: 3, COLLECTION_L2: 6, COLLECTION_L3: 4, COLLECTION_CHUNKS: 9}
PAGES = [
([{"category": "技术文档"}, {"category": "技术文档"}], "cursor-1"),
([{"category": "uncategorized"}], None),
]
async def count(self, collection: str) -> int:
return self.COUNTS[collection]
async def scroll_l1(
self, limit: int = 100, offset: str | None = None
) -> tuple[list[dict[str, Any]], str | None]:
assert limit == 100
return self.PAGES[0] if offset is None else self.PAGES[1]
class _FailingQdrantService:
"""count 即抛异常的假服务,用于验证错误包装"""
async def count(self, collection: str) -> int:
raise RuntimeError("qdrant 连接失败")
async def scroll_l1(
self, limit: int = 100, offset: str | None = None
) -> tuple[list[dict[str, Any]], str | None]:
raise RuntimeError("qdrant 连接失败")
def test_stats_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""正常返回:四字段结构完整,数值来自假服务,类目跨页聚合"""
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FakeQdrantService())
resp = client.get("/api/v1/knowledge/stats")
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
data = body["data"]
assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"}
assert data["collections"] == {"doc_l1": 3, "doc_l2": 6, "doc_l3": 4, "chunks": 9}
assert data["categories"] == {"技术文档": 2, "uncategorized": 1}
assert data["uncategorized_count"] == 1
assert data["documents_total"] == 3
def test_stats_qdrant_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""Qdrant 异常 → code 2000 统一错误响应"""
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FailingQdrantService())
resp = client.get("/api/v1/knowledge/stats")
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 2000
assert body["data"] is None
assert "获取知识库统计失败" in body["message"]
@pytest.fixture
async def memory_service() -> QdrantService:
"""真实内存 Qdrant 服务,集合并写入 3 篇文档与若干 chunk"""
service = QdrantService(client=AsyncQdrantClient(location=":memory:"))
await service.ensure_collections()
# 3 篇 L12 篇同类目 + 1 篇 uncategorized
await service.upsert_l1(
doc_id="doc-a",
title="标题A",
summary="总结A",
category="技术文档",
tags=["api"],
dense_vector=_dense(0.9),
)
await service.upsert_l1(
doc_id="doc-b",
title="标题B",
summary="总结B",
category="技术文档",
tags=["sdk"],
dense_vector=_dense(0.8),
)
await service.upsert_l1(
doc_id="doc-c",
title="标题C",
summary="总结C",
category="uncategorized",
tags=[],
dense_vector=_dense(0.7),
)
# 5 个 chunkdoc-a 3 个、doc-c 2 个)
chunks = [
{
"doc_id": doc_id,
"chunk_index": i,
"text": f"chunk{i}-{doc_id}",
"section_path": "1",
"title": f"标题-{doc_id}",
"category": category,
"tags": [],
"dense_vector": _dense(0.5 + i * 0.1),
}
for doc_id, category, n in (("doc-a", "技术文档", 3), ("doc-c", "uncategorized", 2))
for i in range(n)
]
await service.upsert_chunks(chunks)
return service
async def test_stats_with_real_memory_qdrant(
memory_service: QdrantService, monkeypatch: pytest.MonkeyPatch
) -> None:
"""stats 数值与真实内存 Qdrant 写入一致(直接调路由处理函数)"""
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: memory_service)
body = await knowledge_module.knowledge_stats()
assert body["code"] == 0
data = body["data"]
assert data["collections"] == {"doc_l1": 3, "doc_l2": 0, "doc_l3": 0, "chunks": 5}
assert data["documents_total"] == 3
assert data["categories"] == {"技术文档": 2, "uncategorized": 1}
assert data["uncategorized_count"] == 1