Files
QMDSearch/app/core/ingestion.py
T
kplam e2e8e6829d feat: add document file download and admin page file display
1. 新增将文档元数据存入L1向量库的功能
2. 新增文档文件下载API接口,支持路径安全校验
3. 后端文档详情接口新增文件信息返回字段
4. 管理后台页面新增原始文件信息展示与下载链接
2026-07-31 22:46:13 +08:00

308 lines
12 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) → 分类判定(L1总结) → 切分 chunk
→ 构建 L2/L3 大纲节点 → 批量向量化(dense + sparse)→ 写入 Qdrant 四层集合
L2/L3 大纲节点的构建策略见 _build_l2_nodes / _build_l3_nodes。
"""
import uuid
from collections.abc import Callable
from typing import Any
import structlog
from app.config import settings
from app.core.chunker import Chunker
from app.core.classifier import Classifier
from app.core.embeddings import EmbeddingService, create_embedding_service
from app.core.headings import Heading, parse_headings
from app.core.sparse import SparseEncoder
from app.core.summarizer import Summarizer
from app.models.document import ChunkModel, DocumentInput, DocumentSummary, IngestionResult, SummaryLevel
from app.models.knowledge import CategoryResult
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3, QdrantService, SparseVectorTuple
logger = structlog.get_logger()
# IngestionError 阶段标识
STAGE_SUMMARIZE = "summarize"
STAGE_CLASSIFY = "classify"
STAGE_EMBED = "embed"
STAGE_QDRANT = "qdrant"
class IngestionError(Exception):
"""入库失败异常
携带失败阶段(stage)与已产出的总结(summary,如有),
Qdrant 写入失败时总结不丢,上层可按阶段重试。
"""
def __init__(self, stage: str, message: str, summary: DocumentSummary | None = None) -> None:
super().__init__(message)
self.stage = stage
self.summary = summary
def _heading_paths(headings: list[Heading]) -> list[tuple[str, str]]:
"""按文档顺序计算每个标题的 (标题文本, 祖先标题链含自身),链用 " / " 连接"""
paths: list[tuple[str, str]] = []
stack: list[Heading] = []
for heading in headings:
# 遇到同级或更高级标题时弹栈,维护当前祖先链
while stack and stack[-1].level >= heading.level:
stack.pop()
stack.append(heading)
paths.append((heading.title, " / ".join(h.title for h in stack)))
return paths
def _split_l3_blocks(outline: str) -> list[tuple[str, str]]:
"""将内容大纲按 "## " 行分块,返回 (块标题, 块文本) 列表
"## " 行时整块作为一个节点(块标题为空);
首个 "## " 之前的引导内容直接忽略。
"""
stripped = outline.strip()
if not stripped:
return []
blocks: list[tuple[str, list[str]]] = []
for line in stripped.splitlines():
if line.startswith("## "):
blocks.append((line[3:].strip(), [line]))
elif blocks:
blocks[-1][1].append(line)
if not blocks:
return [("", stripped)]
return [(title, "\n".join(lines).strip()) for title, lines in blocks]
class Ingester:
"""文档入库器:编排总结、分类、切分、向量化与 Qdrant 写入全链路"""
def __init__(
self,
summarizer: Summarizer | None = None,
classifier: Classifier | None = None,
chunker: Chunker | None = None,
embedding: EmbeddingService | None = None,
sparse: SparseEncoder | None = None,
qdrant: QdrantService | None = None,
) -> None:
self.summarizer = summarizer or Summarizer()
self.classifier = classifier or Classifier()
self.chunker = chunker or Chunker()
self.embedding = embedding or create_embedding_service()
self.sparse = sparse or SparseEncoder()
self.qdrant = qdrant or QdrantService()
async def ingest(self, doc: DocumentInput, progress_cb: Callable[[str], None] | None = None) -> IngestionResult:
"""执行文档入库
Args:
doc: 文档输入(文本内容 + 元数据)
progress_cb: 可选的阶段进度回调(同步函数),在各阶段边界以
"summarizing" / "classifying" / "embedding" / "writing" 调用
Returns:
IngestionResult: 入库结果
Raises:
IngestionError: 任一阶段失败时抛出,携带 stage 与已产出总结
"""
def _report(stage: str) -> None:
if progress_cb is not None:
progress_cb(stage)
logger.info("开始文档入库", title=doc.title, text_length=len(doc.text))
doc_id = uuid.uuid4().hex
# 1. 三级总结
_report("summarizing")
try:
summary = await self.summarizer.summarize(doc.text, title=doc.title)
except Exception as exc:
logger.error("入库失败:三级总结", stage=STAGE_SUMMARIZE, error=str(exc))
raise IngestionError(STAGE_SUMMARIZE, f"三级总结失败: {exc}") from exc
logger.info("三级总结完成", doc_id=doc_id, level=summary.level.value)
# 2. 分类判定(基于 L1 总结)
_report("classifying")
try:
category = await self.classifier.classify(summary.l1_summary, title=doc.title)
except Exception as exc:
logger.error("入库失败:分类判定", stage=STAGE_CLASSIFY, error=str(exc))
raise IngestionError(STAGE_CLASSIFY, f"分类判定失败: {exc}", summary=summary) from exc
logger.info("分类判定完成", doc_id=doc_id, category=category.main_category, confidence=category.confidence)
# 3. 切分 chunk 并构建 L2/L3 大纲节点((text, section_path) 列表)
chunks = self.chunker.chunk(doc.text, doc_id)
l2_nodes = self._build_l2_nodes(doc.text, summary)
l3_nodes = self._build_l3_nodes(doc.text, summary)
# 4. 批量 embeddingL1 + L2 + L3 + chunks 一次调用,按序切片取向量
texts = [
summary.l1_summary,
*(node_text for node_text, _ in l2_nodes),
*(node_text for node_text, _ in l3_nodes),
*(c.text for c in chunks),
]
try:
_report("embedding")
vectors = await self.embedding.embed(texts)
except Exception as exc:
logger.error("入库失败:向量化", stage=STAGE_EMBED, error=str(exc))
raise IngestionError(STAGE_EMBED, f"向量化失败: {exc}", summary=summary) from exc
l1_vector = vectors[0]
l2_vectors = vectors[1 : 1 + len(l2_nodes)]
l3_vectors = vectors[1 + len(l2_nodes) : 1 + len(l2_nodes) + len(l3_nodes)]
chunk_vectors = vectors[1 + len(l2_nodes) + len(l3_nodes) :]
# 5. sparse 向量(仅 L1 与 chunks 需要)
l1_sparse: SparseVectorTuple | None = None
chunk_sparses: list[SparseVectorTuple | None] = [None] * len(chunks)
if settings.sparse_enabled:
l1_sparse = self.sparse.encode(summary.l1_summary)
chunk_sparses = [self.sparse.encode(c.text) for c in chunks]
# 6. 写入 Qdrant 四层集合
_report("writing")
try:
await self._write_qdrant(
doc_id,
doc,
summary,
category,
chunks,
l2_nodes,
l3_nodes,
l1_vector,
l2_vectors,
l3_vectors,
chunk_vectors,
l1_sparse,
chunk_sparses,
)
except Exception as exc:
logger.error("入库失败:Qdrant 写入", stage=STAGE_QDRANT, doc_id=doc_id, error=str(exc))
raise IngestionError(STAGE_QDRANT, f"Qdrant 写入失败: {exc}", summary=summary) from exc
logger.info(
"文档入库完成",
doc_id=doc_id,
chunks_count=len(chunks),
l2_nodes=len(l2_nodes),
l3_nodes=len(l3_nodes),
category=category.main_category,
)
return IngestionResult(
document_id=doc_id,
summary=summary,
category=category.main_category,
tags=category.tags,
category_confidence=category.confidence,
collection="四层集合",
chunks_count=len(chunks),
)
def _build_l2_nodes(self, text: str, summary: DocumentSummary) -> list[tuple[str, str]]:
"""构建 L2 大纲节点,返回 (text, section_path) 列表
- 有标题结构(L3 级且标题数 >= 2):每个标题一个节点,
text 与 section_path 均为该节点的祖先标题链
- 否则若 l2_outline 非空(LLM 生成的大纲):按非空行拆节点,section_path 为空
- 2.5 级文档(l2_outline 为 None):无 L2 节点
"""
headings = parse_headings(text)
if summary.level == SummaryLevel.L3 and len(headings) >= 2:
return [(path, path) for _, path in _heading_paths(headings)]
if summary.l2_outline:
return [(line.strip(), "") for line in summary.l2_outline.splitlines() if line.strip()]
return []
def _build_l3_nodes(self, text: str, summary: DocumentSummary) -> list[tuple[str, str]]:
"""构建 L3 内容大纲节点,返回 (text, section_path) 列表
"## " 分块(无 "## " 则整块一个节点);
section_path 尽力匹配文档标题链(块标题与文档标题文本精确匹配),匹配不到用 ""
"""
path_by_title: dict[str, str] = {}
for title, path in _heading_paths(parse_headings(text)):
path_by_title.setdefault(title, path)
return [
(block_text, path_by_title.get(block_title, ""))
for block_title, block_text in _split_l3_blocks(summary.l3_content_outline)
]
async def _write_qdrant(
self,
doc_id: str,
doc: DocumentInput,
summary: DocumentSummary,
category: CategoryResult,
chunks: list[ChunkModel],
l2_nodes: list[tuple[str, str]],
l3_nodes: list[tuple[str, str]],
l1_vector: list[float],
l2_vectors: list[list[float]],
l3_vectors: list[list[float]],
chunk_vectors: list[list[float]],
l1_sparse: SparseVectorTuple | None,
chunk_sparses: list[SparseVectorTuple | None],
) -> None:
"""将 L1/L2/L3/chunks 四层数据写入 Qdrant(任一失败向上抛出)"""
await self.qdrant.upsert_l1(
doc_id=doc_id,
title=doc.title,
summary=summary.l1_summary,
category=category.main_category,
tags=category.tags,
dense_vector=l1_vector,
sparse_vector=l1_sparse,
metadata=doc.metadata,
)
# L2/L3 大纲节点(为空时跳过对应集合的 upsert)
for collection, nodes, vectors in (
(COLLECTION_L2, l2_nodes, l2_vectors),
(COLLECTION_L3, l3_nodes, l3_vectors),
):
if not nodes:
continue
await self.qdrant.upsert_nodes(
collection,
[
{
"doc_id": doc_id,
"section_path": section_path,
"text": node_text,
"category": category.main_category,
"tags": category.tags,
"dense_vector": vector,
}
for (node_text, section_path), vector in zip(nodes, vectors, strict=True)
],
)
if chunks:
# chunk dict 额外携带 doc_summary(= L1 总结),检索侧直接取用,不用回查 L1
chunk_dicts: list[dict[str, Any]] = [
{
"doc_id": doc_id,
"chunk_index": chunk.chunk_index,
"text": chunk.text,
"section_path": chunk.section_path,
"title": doc.title,
"category": category.main_category,
"tags": category.tags,
"dense_vector": vector,
"sparse_vector": sparse,
"doc_summary": summary.l1_summary,
}
for chunk, vector, sparse in zip(chunks, chunk_vectors, chunk_sparses, strict=True)
]
await self.qdrant.upsert_chunks(chunk_dicts)