10 KiB
产品需求文档:题目解析与结构化服务 (QPES)
Question Parsing & Extraction Service - Backend
| 文档版本 | V1.0 |
|---|---|
| 依赖模块 | UDC-M (通用文档转换服务 V2.0) |
| 最后更新 | 2025-12-09 |
| 状态 | 待开发 |
| 涉及端 | 后端 API, LLM Agent, 数据库 |
1. 项目背景与目标
1.1 背景
模块一(UDC-M)已经将 PDF/Word/Excel 变成了带有 MinIO 图片链接的标准 Markdown 流。但此时的数据仍然是非结构化的“一坨文本”。 我们需要将这些文本中的每一道试题(题干、选项、答案、解析)精准切割出来,变成数据库中的一行行记录,以便后续进行知识点打标和组卷搜索。
1.2 目标
构建一个智能分块与语义提取服务。
- 语义切分:利用 LLM 识别题目边界,解决“跨页中断”、“题目粘连”问题。
- 结构化输出:将非结构化文本转化为标准 JSON 格式(
content,answer,analysis)。 - 多模态保留:在切分过程中,绝对保留 Markdown 中的图片链接(公式截图、几何图)。
- 无缝衔接:通过
document_id与 UDC-M 的资产表深度关联。
2. 系统架构设计
2.1 核心流程
- Input: 接收
document_id-> 从 UDC-M 获取 Markdown 文本。 - Chunking Strategy (切片引擎):
- 由于 LLM 上下文限制(即使 DeepSeek 支持 128k,过长也会导致注意力分散),需采用 “滑动窗口 (Sliding Window)” 策略。
- 策略: 设定窗口大小(如 3000 Tokens 或 5 页),重叠大小(如 500 Tokens 或 1 页)。
- LLM Processor (提取引擎):
- 构造 Prompt,要求 LLM 输出特定 Schema 的 JSON。
- 处理重叠区域的题目去重 (Deduplication)。
- Validation: 使用 Pydantic 校验 LLM 返回的 JSON 格式,失败则重试。
- Storage: 写入
questions表。
3. 功能需求详细说明
3.1 提取任务管理
- 异步处理: 提取过程耗时较长(LLM 生成),必须异步。
- 状态机:
queued->processing->deduplicating(去重中) ->success/failed。
3.2 智能分块策略 (Sliding Window)
- 场景: 一道大题可能跨越第 5 页和第 6 页。如果硬切,题目会断裂。
- 逻辑:
- Chunk N: 读取第 1-1000 行。Prompt: "提取所有完整题目,如果末尾题目被截断,请不要提取,并在 JSON 中标记
truncated: true"。 - Chunk N+1: 读取第 800-1800 行(重叠 200 行)。Prompt: "提取所有完整题目。注意:如果是开头被截断的题目(即上一块的末尾),请尝试修复并提取"。
- Chunk N: 读取第 1-1000 行。Prompt: "提取所有完整题目,如果末尾题目被截断,请不要提取,并在 JSON 中标记
- 去重逻辑:
- 对提取出的每一道题计算
MD5(content_text)。 - 如果后一个 Chunk 提取出的题目 Hash 与前一个 Chunk 已存的题目 Hash 一致,则丢弃,防止重复。
- 对提取出的每一道题计算
3.3 Prompt Engineering (核心资产)
- 输入: Markdown 片段。
- 要求:
- 识别选择题、填空题、解答题。
- 关键: 保持文本中的
图片链接不被 LLM 吞掉或篡改。 - 关键: 数学公式保持 LaTeX 格式(
$包裹)。 - 输出 list of objects。
3.4 结构化存储
- 将提取结果存入
questions表。 - 预留字段:
knowledges和methods字段初始化为NULL,等待模块三(知识增强)处理。
4. 数据库设计 (PostgreSQL)
基于 SQLModel 设计,与 UDC-M 的 documents 表关联。
4.1 Table: questions (题库核心表)
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BIGINT (PK) | 题目唯一主键 |
document_id |
UUID (FK) | 关联 UDC-M 的 documents 表 |
batch_id |
UUID | 关联本次提取任务 ID |
question_seq |
INT | 在原文档中的顺序号 (1, 2, 3...) |
question_type |
VARCHAR | choice (选择), fill (填空), essay (解答) |
content_md |
TEXT | 题干内容 (含 Markdown 图片和 Latex) |
options_json |
JSONB | 选择题选项 [{"label":"A","text":"..."}, ...] |
answer_md |
TEXT | 参考答案 |
analysis_md |
TEXT | 解析 (如果有) |
image_urls |
JSONB | 提取出的图片链接列表 ["http...", "http..."] (冗余字段,方便索引) |
content_hash |
VARCHAR(64) | 用于去重 |
enrich_status |
VARCHAR | pending (待打标), done (已打标) |
created_at |
TIMESTAMP |
4.2 Table: extraction_tasks (提取任务记录)
用于记录每次 LLM 调用的消耗和状态。
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
UUID (PK) | 任务 ID |
document_id |
UUID | 关联文档 |
model_name |
VARCHAR | 使用的模型 (e.g., deepseek-v3) |
token_usage |
INT | 本次任务消耗 Token 数 (用于成本核算) |
status |
VARCHAR | 状态 |
error_log |
TEXT | 错误日志 |
5. API 接口定义 (RESTful)
Base URL: /api/v1/extractions
5.1 发起提取任务
- Endpoint:
POST / - Description: 对指定文档进行题目提取。
- Request Body:
{ "document_id": "uuid-of-document", "chunk_size": 3000, // 可选,Token数 "overlap": 500 // 可选,重叠Token数 } - Logic:
- 检查
documents表中该文档是否存在且parse_result_url(Markdown) 是否就绪。 - 创建
extraction_task。 - 推送到 Redis 队列
llm-extraction-queue。
- 检查
- Response:
{"task_id": "...", "status": "queued"}
5.2 查询提取状态
- Endpoint:
GET /{task_id} - Response:
{ "status": "processing", "progress": "3/10 chunks", "extracted_count": 45 }
5.3 获取提取结果 (核心)
- Endpoint:
GET /documents/{document_id}/questions - Description: 获取某文档下的所有题目(支持分页)。
- Response:
{ "total": 12, "items": [ { "id": 1001, "question_seq": 1, "content_md": "已知函数 $f(x)=x^2$ ... ", "enrich_status": "pending" }, ... ] }
5.4 人工修正接口 (可选)
- Endpoint:
PUT /questions/{question_id} - Description: 人工校对 OCR 错误或切分错误。
6. 技术选型 (Vibe Coding Stack)
除了沿用模块一的 FastAPI + Celery + PostgreSQL 外,本模块的核心在于 LLM 交互。
| 组件 | 选型 | 理由 |
|---|---|---|
| LLM Model | DeepSeek-V3 | API 成本极低,Context 窗口大,逻辑推理能力强,适合处理长文档。 |
| LLM SDK | OpenAI SDK | DeepSeek 完美兼容 OpenAI 接口协议。不要引入 LangChain (太重),直接用原生 SDK 配合 Pydantic 即可。 |
| Schema Validation | Pydantic (Instructor) | 强烈推荐使用 instructor 库(基于 Pydantic),它可以强制 LLM 输出完全符合 Pydantic 定义的 JSON 结构,极大降低解析错误率。 |
| Markdown Splitter | LangChain TextSplitter | 虽然不用 LangChain 的 Chain,但它的 MarkdownHeaderTextSplitter 工具类非常好用,用于预处理 Chunk。 |
7. 开发难点与 Vibe Coding 提示
在开发此模块时,请注意以下 Prompt 技巧,通过 Cursor/Windsurf 编写代码时由于重要:
7.1 "Instructor" 模式示例
不要让 LLM 返回纯文本然后自己 json.loads(),这很容易崩。请使用 Schema 驱动模式:
from pydantic import BaseModel, Field
from typing import List, Optional
# 1. 定义题目结构
class QuestionItem(BaseModel):
content: str = Field(..., description="The main text of the question, including LaTeX and image links.")
options: Optional[List[str]] = Field(None, description="List of options if it is a multiple choice question.")
answer: Optional[str] = Field(None, description="The answer key.")
type: str = Field(..., description="Type: choice, fill, or essay")
class ExtractionResult(BaseModel):
questions: List[QuestionItem]
# 2. Vibe Coding 调用 (伪代码)
# client = instructor.patch(OpenAI(...))
# resp = client.chat.completions.create(
# model="deepseek-chat",
# response_model=ExtractionResult, <-- 关键!强制返回对象
# messages=[...]
# )
7.2 图片链接保护 Prompt
在 System Prompt 中必须加入:
"You are a strictly structural parser. You must NOT simplify, summarize, or remove any content from the original text. CRITICAL RULE: Do NOT remove or modify any image links formatted as
. They must be preserved exactly as they appear in the text."
7.3 去重逻辑实现
建议在 Python 代码中实现去重,而不是让 LLM 去重。
- 方法: 维护一个
Set,存储hash(content)。 - 流程: Worker 处理完 Chunk 1,将题目入库并存 Hash 到 Set。处理 Chunk 2 时,先计算 Hash,如果在 Set 里,直接跳过。
8. 与后续模块的接口
该模块完成后,数据库中的 questions 表已经有了干净的题目数据。
- 字段状态:
knowledges为 NULL,enrich_status为pending。 - 下一步: 模块三(知识增强服务)只需要
SELECT * FROM questions WHERE enrich_status = 'pending',然后逐条调用 Summary API 进行填空即可。
这套设计保证了模块的高内聚、低耦合。即使模块三挂了,模块二依然可以照常解析入库,不会阻塞业务。