Files
QMDSearch/scripts/smoke_live.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

166 lines
6.8 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.
"""真实 Ollama 现场冒烟脚本
用法:uv run python scripts/smoke_live.py
链路:内存 Qdrant + 真实本地 Ollamadeepseek-r1:8b+ 确定性哈希向量(无语义,仅打通流程)。
读取回归集第一篇文档走完整 ingest → search 流程,打印各阶段结果、原始 LLM 输出与耗时。
deepseek-r1 是推理模型,/api/generate 的 response 字段可能带 <think> 思考内容;
classifier / query_parser 内建 JSON 容错,若解析失败会走兜底路径——均为合理的现场观察。
"""
import asyncio
import json
import random
import sys
import time
from hashlib import sha256
from pathlib import Path
# 脚本直接运行时需要把项目根目录加入 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from qdrant_client import AsyncQdrantClient # noqa: E402
from app.config import settings # noqa: E402
from app.core.chunker import Chunker # noqa: E402
from app.core.classifier import Classifier # noqa: E402
from app.core.ingestion import Ingester # noqa: E402
from app.core.query_parser import QueryParser # noqa: E402
from app.core.retriever import Retriever # noqa: E402
from app.core.sparse import SparseEncoder # noqa: E402
from app.core.summarizer import Summarizer # noqa: E402
from app.models.document import DocumentInput # noqa: E402
from app.models.knowledge import load_taxonomy # noqa: E402
from app.models.search import SearchRequest # noqa: E402
from app.services.ollama import OllamaClient # noqa: E402
from app.services.qdrant import QdrantService # noqa: E402
OLLAMA_MODEL = "deepseek-r1:8b"
OLLAMA_TIMEOUT = 600.0
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 RecordingOllama:
"""包装真实 OllamaClient,记录每次调用的原始输出供现场观察"""
def __init__(self, inner: OllamaClient) -> None:
self.inner = inner
self.records: list[tuple[str, str]] = [] # (调用用途, 原始输出)
async def generate(self, prompt: str, json_mode: bool = False) -> str:
kind = _prompt_kind(prompt)
raw = await self.inner.generate(prompt, json_mode=json_mode)
self.records.append((kind, raw))
return raw
def _prompt_kind(prompt: str) -> str:
"""按 prompt 特征串标注调用用途"""
if "你是知识库分类助手" in prompt:
return "文档分类"
if "你是搜索查询分析助手" in prompt:
return "query 解析"
if "请用一句话对以下文档内容进行高度概括" in prompt:
return "L1 总结"
if "请提取以下文档的主要章节结构" in prompt:
return "L2 大纲"
if "请对以下文档的每个章节/主题进行详细的内容摘要" in prompt:
return "L3 内容大纲"
if "请对以下文档内容进行详细摘要" in prompt:
return "L2.5 摘要"
return "未知"
def _fmt(seconds: float) -> str:
return f"{seconds:.1f}s"
async def main() -> int:
started = time.perf_counter()
# 0. 环境检查
ollama_inner = OllamaClient(base_url="http://localhost:11434", model=OLLAMA_MODEL, timeout=OLLAMA_TIMEOUT)
if not await ollama_inner.is_available():
print("Ollama 不可用(http://localhost:11434),请先启动 Ollama 服务")
return 1
print(f"[环境] Ollama 可用,模型 {OLLAMA_MODEL}Qdrant 使用 :memory: 本地模式")
# 1. 读取回归集第一篇文档与一条相关 query
regression_path = Path(__file__).resolve().parent / "eval" / "regression_set.json"
regression = json.loads(regression_path.read_text(encoding="utf-8"))
doc_data = regression["documents"][0]
query = next(q["query"] for q in regression["queries"] if q.get("golden_doc_id") == doc_data["id"])
doc = DocumentInput(text=doc_data["text"], title=doc_data["title"])
print(f"[数据] 文档《{doc.title}》({len(doc.text)} 字符);query{query}")
# 2. 组装真实链路(仅 embedding 为确定性伪向量)
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
await qdrant.ensure_collections()
ollama = RecordingOllama(ollama_inner)
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), # type: ignore[arg-type]
embedding=embedding,
sparse_encoder=SparseEncoder(),
)
# 3. 入库
t0 = time.perf_counter()
result = await ingester.ingest(doc)
t_ingest = time.perf_counter() - t0
print(f"\n[入库] 耗时 {_fmt(t_ingest)}")
print(f" 总结层级: {result.summary.level.value}")
print(f" L1 总结: {result.summary.l1_summary[:100]}")
print(f" L2 大纲: {(result.summary.l2_outline or '(None2.5 级回退)')[:100]}")
print(f" 分类: {result.category} (置信度 {result.category_confidence:.2f})tags={result.tags}")
print(f" chunks 数: {result.chunks_count}")
# 4. 检索
t0 = time.perf_counter()
response = await retriever.search(SearchRequest(query=query))
t_search = time.perf_counter() - t0
print(f"\n[检索] 耗时 {_fmt(t_search)}")
print(f" routed_categories={response.routed_categories}fallback={response.fallback}")
print(f" hits 数: {len(response.hits)}")
if response.hits:
hit = response.hits[0]
print(f" 首条 hit: section_path={hit.section_path!r}score={hit.score:.4f}")
print(f" 首条 hit.doc_summary 前 50 字: {hit.doc_summary[:50]!r}")
print(f" 首条 hit.text 前 50 字: {hit.text[:50]!r}")
# 5. r1 模型原始输出观察(thinking / JSON 表现)
print("\n[Ollama 原始输出摘录](r1 推理模型的 JSON 表现现场观察)")
for kind, raw in ollama.records:
excerpt = raw.strip().replace("\n", " ")[:200]
has_think = "<think>" in raw
print(f" - {kind}: 长度 {len(raw)},含 <think>={has_think},输出摘录: {excerpt!r}")
print(f"\n[总耗时] {_fmt(time.perf_counter() - started)}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))