diff --git a/Dockerfile b/Dockerfile index 5df03af..a163525 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.11-slim # ── 系统依赖(PaddleOCR 需要的图形库) ── RUN apt-get update && apt-get install -y --no-install-recommends \ - libgl1-mesa-glx \ + libgl1 \ libglib2.0-0 \ libsm6 \ libxext6 \ diff --git a/README.md b/README.md index 79787c1..ba17bb8 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,129 @@ # HuiBrain - 中文直播教育知识库系统 -基于 FastAPI 的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、AI 语义搜索、企业微信机器人等功能。 +基于 FastAPI 的中文直播教育知识库系统,支持直播文字稿导入、OCR 截图识别、AI 语义搜索、企业微信机器人、MCP 协议等功能。 ## 功能特性 ### 知识库管理 -- **多格式导入**: Markdown / 纯文本 / Word 文档(.docx),自动按标题分页 -- **批量导入**: 支持多文件批量导入,实时进度显示 -- **智能分块**: 按段落边界切分文本,保留重叠区域 +- **多格式导入**: Markdown(解析 frontmatter + 按 `##` 标题分页)/ 纯文本 / Word 文档(.docx,按 Heading 分页) +- **批量导入**: 支持多文件批量导入、整个目录导入,实时进度显示 +- **智能分块**: 按段落边界切分文本,保留重叠区域(默认 chunk_size=300, overlap=50) - **向量嵌入**: 支持 MiniMax / OpenAI / 智谱 / DashScope / 本地 BGE 五种嵌入模型 +- **知识导出**: 支持 JSON 和 Markdown 两种格式导出 +- **索引重建**: 支持单页面重新生成嵌入向量 ### 图片 OCR -- **OCR 识别**: DeepSeek Vision 模型识别图片文字 -- **关键词提取**: MiniMax M2.7 自动提取内容标签 -- **批量处理**: 支持拖拽上传、选择文件、选择文件夹 +- **多 Provider**: DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云,支持 `auto` 自动 fallback(PaddleOCR → DeepSeek → 阿里云 → 腾讯云) +- **批量处理**: 支持拖拽上传、选择文件、选择文件夹、服务器路径批量导入 - **上传去重**: OCR 识别后自动检查内容是否已存在,避免重复录入 - **图片管理**: 网页端管理所有图片,支持一键去重、手动删除 -- **BM25 索引**: 内置倒排索引搜索引擎,jieba 中文分词 +- **并发控制**: 通过 asyncio.Semaphore 控制 OCR 并发数量(可配置 1-10) ### AI 搜索 -- **知识库搜索**: 关键词搜索 +- **知识库搜索**: LIKE 关键词搜索 + LLM 查询扩展(生成语义相近的替代表述) - **图片搜索**: MiniMax M2.7 语义匹配,SSE 流式实时返回结果 - **并发池**: 最多 10 个 LLM 请求并发,每批数量可配置 -- **可配置参数**: 搜索返回数量、LLM 批量判断数量、OCR 并发数量均可在网页端设置 +- **实体检测**: 自动识别知识点、技巧、人物、概念等实体类型 +- **可配置参数**: 搜索返回数量、LLM 批量判断数量、OCR 并发数量均可在网页端动态修改 ### 企业微信机器人 -- **WebSocket 长连接**: 基于官方 SDK,无需公网 IP -- **关键词搜索**: 用户发文字 → 搜索图片 → 返回匹配结果 -- **图片自动入库**: 用户发图片 → 自动 OCR 识别 → 录入知识库(自动去重) -- **集成启动**: 后端启动时自动检测配置并拉起机器人 +- **WebSocket 长连接**: 基于 wecom-aibot-python-sdk,无需公网 IP +- **关键词搜索**: 用户发文字 → 清理口语化前缀 → 搜索图片 → 流式回复文本 + 逐张发送图片 +- **图片自动入库**: 用户发图片 → 下载解密 → OCR 识别 → 自动去重入库 → 回复结果 +- **分片上传**: 支持大图片的三步分片上传(init → chunk → finish) +- **欢迎语**: 进入会话时自动发送欢迎语 +- **集成启动**: 后端启动时自动检测配置并拉起机器人(BotManager 后台线程) - **网页端管理**: 在设置页面配置 Bot ID / Secret,支持在线重启 +### MCP 协议 +- **MCP Server**: 支持 stdio 和 sse 两种传输协议,可被外部 AI Agent 直接调用 +- **5 个工具**: + - `search_knowledge`: 语义搜索知识库 + - `get_page`: 获取知识页面详情 + - `list_courses`: 列出所有课程 + - `get_page_chunks`: 获取页面的分块内容 + - `search_images`: 根据关键字搜索已 OCR 识别的聊天截图 +- **命令行入口**: `huibrain-mcp`(通过 pyproject.toml 定义) + ### 其他 -- **MCP 协议**: 支持 Model Context Protocol,可被 AI Agent 调用 +- **运行时配置**: 所有参数均可通过网页端设置 API 动态修改,无需重启服务 +- **服务测试**: 网页端支持在线测试嵌入模型、LLM、OCR 服务连通性 - **Docker 部署**: 单容器部署,轻量级 ## 技术栈 -| 层级 | 技术 | -|------|------| -| 后端框架 | FastAPI + Uvicorn | -| ORM | SQLAlchemy 2.0 (async) | -| 数据库 | SQLite | -| 前端 | 纯 HTML + CSS + JS(苹果风格 SPA) | -| OCR | DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云 | -| LLM | MiniMax M2.7 / DeepSeek / Qwen3-8B | -| 搜索 | BM25 倒排索引 + 关键词搜索 | -| 企业微信 | wecom-aibot-python-sdk (WebSocket 长连接) | -| 部署 | Docker | +| 层级 | 技术 | 说明 | +|------|------|------| +| 后端框架 | FastAPI + Uvicorn | 异步 Python Web 框架 | +| ORM | SQLAlchemy 2.0 (async) | 异步 ORM | +| 数据库 | SQLite (aiosqlite) | 轻量级,WAL 模式 | +| 配置管理 | pydantic-settings | 环境变量 / .env 文件加载 | +| 前端 | 纯 HTML + CSS + JS | 苹果风格 SPA 单页应用 | +| OCR | DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云 | 4 种 Provider + auto fallback | +| LLM | MiniMax M2.7 / DeepSeek / OpenAI | 搜索匹配、查询扩展、批量判断 | +| 嵌入模型 | MiniMax / OpenAI / 智谱 / DashScope / 本地 BGE | 5 种嵌入 Provider | +| 搜索 | LIKE 关键词 + LLM 查询扩展 | 知识库搜索;图片搜索走 LLM 语义匹配 | +| 企业微信 | wecom-aibot-python-sdk | WebSocket 长连接模式 | +| MCP | mcp[cli] >= 1.2.0 | Model Context Protocol | +| 部署 | Docker + docker-compose | 单容器部署 | ## 项目结构 ``` huibrain/ ├── app/ -│ ├── main.py # FastAPI 入口,路由注册,生命周期管理,BotManager -│ ├── config.py # pydantic-settings 配置管理 -│ ├── database.py # SQLite 数据库连接 -│ ├── models/base.py # ORM 模型(KnowledgePage, OCRImage 等) -│ ├── schemas/ # Pydantic 请求/响应 Schema -│ ├── api/v1/ # API 路由 -│ │ ├── pages.py # 知识页面 CRUD -│ │ ├── search.py # 关键词搜索 -│ │ ├── images.py # 图片 OCR + AI 搜索(SSE)+ 去重 + 删除 -│ │ ├── import_export.py # 文件导入导出 -│ │ └── settings.py # 系统设置 + 机器人管理 +│ ├── __init__.py # 包初始化,定义版本号 +│ ├── main.py # FastAPI 入口,路由注册,生命周期管理,BotManager +│ ├── config.py # pydantic-settings 配置管理(所有环境变量) +│ ├── database.py # SQLite 异步数据库连接管理 +│ ├── wework_bot.py # 企业微信智能机器人服务(WebSocket 长连接) +│ ├── api/ +│ │ └── v1/ +│ │ ├── pages.py # 知识页面 CRUD(列表/详情/创建/更新/删除/重建索引) +│ │ ├── search.py # 语义搜索(向量+全文混合) +│ │ ├── images.py # 图片 OCR + AI 搜索(SSE 流式)+ 去重 + 删除 +│ │ ├── import_export.py # 文件导入(.md/.txt/.docx)+ 目录导入 + 导出(JSON/Markdown) +│ │ └── settings.py # 系统设置 + 机器人管理 + 服务测试 + 统计信息 +│ ├── models/ +│ │ └── base.py # ORM 模型(KnowledgePage, KnowledgeChunk, OCRImage, WebsiteSettings) +│ ├── schemas/ +│ │ ├── ocr.py # OCR 相关 Schema +│ │ ├── page.py # 页面相关 Schema +│ │ └── search.py # 搜索相关 Schema │ ├── services/ -│ │ ├── ocr_service.py # OCR 识别服务(多 Provider) -│ │ ├── llm_service.py # LLM 服务(标签提取/搜索匹配) -│ │ ├── search_engine.py # BM25 倒排索引引擎 -│ │ ├── search_service.py # 搜索服务 -│ │ ├── embedding_service.py # 嵌入模型服务 -│ │ ├── import_service.py # 文件导入服务 -│ │ └── page_service.py # 页面 CRUD 服务 -│ ├── wework_bot.py # 企业微信智能机器人服务 -│ └── mcp/server.py # MCP Server 实现 -├── static/index.html # 前端单页应用 -├── data/images/ # 图片存储目录 -├── .env.example # 环境变量模板 -├── requirements.txt # Python 依赖 -├── Dockerfile # 应用镜像 -└── docker-compose.yml # Docker 编排 +│ │ ├── ocr_service.py # OCR 识别服务(4 种 Provider + auto fallback) +│ │ ├── llm_service.py # LLM 服务(聊天/查询扩展/批量判断) +│ │ ├── search_service.py # 知识库搜索服务(LIKE 关键词搜索) +│ │ ├── embedding_service.py # 嵌入模型服务(5 种 Provider) +│ │ ├── import_service.py # 文件导入服务(分块+向量化) +│ │ └── page_service.py # 页面 CRUD 服务 +│ └── mcp/ +│ └── server.py # MCP Server 实现(5 个工具供 AI Agent 调用) +├── static/ +│ └── index.html # 前端单页应用(苹果风格 SPA) +├── data/ +│ ├── images/ # 图片存储目录 +│ └── transcripts/ # 转写文字稿(.docx/.md) +├── docs/ +│ ├── IMAGE_API_GUIDE.md # 图片 API 集成指南 +│ └── 软著材料/ # 软件著作权申请材料 +├── .env.example # 环境变量模板 +├── .gitignore # Git 忽略规则 +├── pyproject.toml # Python 项目元数据 + 构建配置 +├── requirements.txt # Python 依赖清单 +├── Dockerfile # Docker 镜像构建 +└── docker-compose.yml # Docker Compose 编排 ``` +## 数据模型 + +| 模型 | 表名 | 说明 | +|------|------|------| +| `KnowledgePage` | knowledge_pages | 知识页面(标题/内容/来源/课程/讲师/日期/向量嵌入) | +| `KnowledgeChunk` | knowledge_chunks | 文本分块(关联页面/分块索引/内容/嵌入向量) | +| `OCRImage` | ocr_images | OCR 图片记录(文件路径/OCR文本/置信度/Provider/状态/文本块) | +| `WebsiteSettings` | website_settings | 网站设置(键值对,单例模式) | + ## 快速开始 ### 1. 环境准备 @@ -95,7 +136,7 @@ cd huibrain # 复制配置文件 cp .env.example .env -# 安装依赖 +# 安装依赖(Python >= 3.11) pip install -r requirements.txt ``` @@ -113,7 +154,7 @@ DEEPSEEK_API_KEY=your-deepseek-api-key DEEPSEEK_BASE_URL=http://your-ollama-host:11434/v1 DEEPSEEK_OCR_MODEL=deepseek-ocr:latest -# LLM(用于标签提取和搜索匹配) +# LLM(用于搜索匹配) MINIMAX_API_KEY=your-minimax-api-key MINIMAX_BASE_URL=https://api.minimaxi.com/v1 MINIMAX_CHAT_MODEL=MiniMax-M2.7 @@ -140,16 +181,20 @@ uvicorn app.main:app --host 0.0.0.0 --port 8765 docker-compose up -d ``` +- 单容器部署,数据持久化通过 `./data:/app/data` 卷挂载 +- 默认端口 8000,可通过 `APP_PORT` 环境变量调整 +- 内置健康检查,每 30 秒检查 `/health` 端点 + ## 图片搜索工作流 ``` 用户上传图片 - → DeepSeek Vision OCR 识别文字 + → OCR 识别文字(DeepSeek Vision / PaddleOCR / 阿里云 / 腾讯云,支持 auto fallback) → 检查 OCR 内容是否已存在(去重) - → MiniMax M2.7 提取关键词标签 - → 存入 SQLite + BM25 索引 + → 存入 SQLite 用户搜索 "孩子不想上学" + → LLM 查询扩展(生成语义相近的替代表述) → 从 DB 倒序取图片(每批 N 张,可配置) → 并发池发给 MiniMax M2.7 判断 → 匹配结果通过 SSE 实时推送到前端 @@ -162,8 +207,8 @@ docker-compose up -d | 场景 | 说明 | |------|------| -| 单聊发关键词 | 搜索图片知识库,返回匹配结果(图片+预览文字) | -| 单聊发图片 | 自动 OCR 识别并录入知识库(自动去重) | +| 单聊发关键词 | 清理口语化前缀 → 搜索图片 → 流式回复文本 + 逐张发送图片 | +| 单聊发图片 | 下载解密 → OCR 识别 → 自动去重入库 → 回复结果 | | 进入会话 | 自动发送欢迎语 | ### 配置 @@ -176,39 +221,80 @@ docker-compose up -d - 图片消息**仅支持单聊**,群聊中发图片不会触发机器人 - 机器人支持多人同时使用(asyncio 异步架构) +- 大图片支持分片上传(init → chunk → finish) - 配置修改后可通过网页端「重启机器人」按钮生效 ## API 概览 +### 知识页面 + | 方法 | 路径 | 说明 | |------|------|------| -| POST | `/api/v1/images/recognize-direct` | 上传并识别图片 | +| GET | `/api/v1/pages` | 知识页面列表(分页) | +| GET | `/api/v1/pages/{id}` | 获取知识页面详情 | +| POST | `/api/v1/pages` | 创建知识页面 | +| PUT | `/api/v1/pages/{id}` | 更新知识页面 | +| DELETE | `/api/v1/pages/{id}` | 删除知识页面 | +| POST | `/api/v1/pages/{id}/rebuild-index` | 重建页面嵌入索引 | + +### 搜索 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/search` | 关键词搜索知识库 | + +### 图片 OCR + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/images/recognize` | 上传并识别图片(两步:先上传再识别) | +| POST | `/api/v1/images/recognize-direct` | 一步上传并识别图片 | | POST | `/api/v1/images/batch-recognize` | 批量上传并识别 | +| POST | `/api/v1/images/import-path` | 服务器路径批量导入 | | GET | `/api/v1/images/search` | AI 搜索图片(SSE 流式) | | GET | `/api/v1/images` | 获取图片列表(分页) | | GET | `/api/v1/images/{id}` | 获取图片识别结果 | | DELETE | `/api/v1/images/{id}` | 删除图片 | | POST | `/api/v1/images/dedup` | 一键去重 | -| GET | `/api/v1/pages` | 知识页面列表 | -| POST | `/api/v1/pages` | 创建知识页面 | -| POST | `/api/v1/search` | 关键词搜索知识库 | -| POST | `/api/v1/import/file` | 导入文件 | + +### 导入导出 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/import/file` | 导入文件(.md/.txt/.docx) | +| POST | `/api/v1/import/directory` | 导入整个目录 | +| GET | `/api/v1/export` | 导出知识库(JSON/Markdown) | + +### 系统设置 + +| 方法 | 路径 | 说明 | +|------|------|------| | GET | `/api/v1/settings` | 获取系统设置 | | PUT | `/api/v1/settings` | 更新系统设置 | +| GET | `/api/v1/settings/stats` | 获取统计信息 | +| POST | `/api/v1/settings/test/embedding` | 测试嵌入模型连通性 | +| POST | `/api/v1/settings/test/llm` | 测试 LLM 服务连通性 | +| POST | `/api/v1/settings/test/ocr` | 测试 OCR 服务连通性 | | GET | `/api/v1/settings/bot/status` | 获取机器人状态 | | POST | `/api/v1/settings/bot/restart` | 重启机器人 | +### 系统 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/health` | 健康检查 | + ## 配置说明 ### OCR Provider | Provider | 说明 | 额外配置 | |----------|------|----------| -| `deepseek` | DeepSeek Vision(推荐) | `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL` | -| `paddleocr` | 本地 PaddleOCR | 无 | +| `deepseek` | DeepSeek Vision(推荐,支持 Ollama 自部署) | `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL` | +| `paddleocr` | 本地 PaddleOCR(免费、离线) | 无 | | `aliyun` | 阿里云 OCR | `ALIYUN_OCR_ACCESS_KEY`, `ALIYUN_OCR_SECRET` | | `tencent` | 腾讯云 OCR | `TENCENT_OCR_SECRET_ID`, `TENCENT_OCR_SECRET_KEY` | -| `auto` | 自动 fallback | 配置多个 Provider | +| `auto` | 自动 fallback(PaddleOCR → DeepSeek → 阿里云 → 腾讯云) | 配置多个 Provider | ### 嵌入模型 @@ -227,3 +313,16 @@ docker-compose up -d | 搜索返回数量 | 每次搜索最多返回多少条匹配结果 | 3 | 1-10 | | LLM 批量判断数量 | 每批发给 LLM 判断的图片数量 | 10 | 1-50 | | OCR 并发数量 | 多用户同时上传时 OCR 并发识别数量 | 1 | 1-10 | + +## 架构设计 + +- **策略模式**: OCR 和嵌入服务均使用抽象基类 + 工厂模式,支持多 Provider 热切换 +- **单例模式**: EmbeddingService、OCRService、LLMService 均为类方法单例,支持 `reset()` 重置 +- **异步架构**: 全异步(FastAPI + SQLAlchemy async + asyncio),企业微信机器人在后台线程独立事件循环运行 +- **SSE 流式**: 图片搜索使用 Server-Sent Events 实时推送结果,前端可即时展示 +- **并发控制**: OCR 并发通过 Semaphore 控制,LLM 搜索通过并发池(最多 10 个)控制 +- **运行时配置**: 所有参数均可通过网页端设置 API 动态修改,无需重启服务 + +## 许可证 + +MIT diff --git a/app/api/v1/images.py b/app/api/v1/images.py index 6492eb7..172a3b4 100644 --- a/app/api/v1/images.py +++ b/app/api/v1/images.py @@ -1,6 +1,6 @@ """ 图片 OCR API 路由 -OCR 识别 + BM25 倒排索引搜索 +OCR 识别 + AI 搜索 """ from __future__ import annotations @@ -24,7 +24,6 @@ from app.config import settings from app.database import get_db from app.models.base import OCRImage from app.services.ocr_service import OCRService -from app.services.search_engine import BM25Index, tokenize from app.services.llm_service import LLMService logger = logging.getLogger(__name__) @@ -45,9 +44,6 @@ def _get_ocr_semaphore() -> asyncio.Semaphore: ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"} -# 全局 BM25 索引实例(在 main.py 中初始化) -bm25_index: Optional[BM25Index] = None - def _check_image_ext(filename: str) -> str: suffix = os.path.splitext(filename)[1].lower() @@ -62,41 +58,8 @@ def _save_ocr_result(record: OCRImage, ocr_result, db: AsyncSession): record.confidence = ocr_result.confidence record.provider = ocr_result.provider record.status = "completed" - # 保留 keywords(如果有) - if hasattr(ocr_result, 'keywords') and ocr_result.keywords: - record.tags = json.dumps(ocr_result.keywords, ensure_ascii=False) -def _add_to_index(record: OCRImage): - """将 OCR 结果添加到 BM25 索引""" - if bm25_index is None or not record.ocr_text: - return - # 索引内容 = OCR 文本(权重最高) - index_text = record.ocr_text - # 如果有 tags/story_summary 也加入索引 - if record.tags: - try: - tags = json.loads(record.tags) - if tags: - index_text += "\n" + " ".join(tags) - except Exception: - pass - if record.story_summary: - index_text += "\n" + record.story_summary - - bm25_index.add_document( - doc_id=record.id, - text=index_text, - metadata={ - "file_path": record.file_path, - "tags": json.loads(record.tags) if record.tags else [], - "story_summary": record.story_summary or "", - "confidence": record.confidence, - "provider": record.provider, - "created_at": str(record.created_at), - }, - ) - async def _check_ocr_duplicate(ocr_text: str, db: AsyncSession) -> Optional[int]: """检查 OCR 文本是否已存在,返回已存在记录的 ID 或 None""" @@ -144,13 +107,11 @@ async def recognize_image(image_id: int, db: AsyncSession = Depends(get_db)): _save_ocr_result(image_record, ocr_result, db) await db.flush() await db.refresh(image_record) - _add_to_index(image_record) return { "id": image_record.id, "file_path": image_record.file_path, "ocr_text": image_record.ocr_text, - "tags": json.loads(image_record.tags) if image_record.tags else [], "confidence": image_record.confidence, "provider": image_record.provider, "status": image_record.status, @@ -208,12 +169,10 @@ async def batch_recognize(files: List[UploadFile], db: AsyncSession = Depends(ge _save_ocr_result(image_record, ocr_result, db) await db.flush() await db.refresh(image_record) - _add_to_index(image_record) results.append({ "id": image_record.id, "filename": file.filename, "file_path": str(dest_path), "status": "completed", "ocr_text": ocr_result.text, - "tags": json.loads(image_record.tags) if image_record.tags else [], "confidence": ocr_result.confidence, "provider": ocr_result.provider, "block_count": len(ocr_result.blocks), }) @@ -266,8 +225,7 @@ async def import_from_paths(data: PathImportRequest, db: AsyncSession = Depends( _save_ocr_result(image_record, ocr_result, db) await db.flush() await db.refresh(image_record) - _add_to_index(image_record) - results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "completed", "ocr_text": ocr_result.text, "tags": json.loads(image_record.tags) if image_record.tags else [], "confidence": ocr_result.confidence, "provider": ocr_result.provider}) + results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "completed", "ocr_text": ocr_result.text, "confidence": ocr_result.confidence, "provider": ocr_result.provider}) except Exception as e: image_record.status = "failed" image_record.error_message = str(e) @@ -315,12 +273,10 @@ async def recognize_direct(file: UploadFile, db: AsyncSession = Depends(get_db)) _save_ocr_result(image_record, ocr_result, db) await db.flush() await db.refresh(image_record) - _add_to_index(image_record) return { "id": image_record.id, "file_path": image_record.file_path, "original_filename": file.filename, "ocr_text": image_record.ocr_text, - "tags": json.loads(image_record.tags) if image_record.tags else [], "confidence": image_record.confidence, "provider": image_record.provider, "status": image_record.status, "blocks": [{"text": b.text, "bbox": b.bbox, "confidence": b.confidence} for b in ocr_result.blocks], @@ -420,7 +376,6 @@ async def search_images( "image_base64": image_base64, "ocr_text": item.ocr_text, "ocr_text_preview": (item.ocr_text[:150] + "...") if item.ocr_text and len(item.ocr_text) > 150 else item.ocr_text, - "tags": json.loads(item.tags) if item.tags else [], "confidence": item.confidence, "provider": item.provider, "created_at": str(item.created_at), @@ -489,9 +444,6 @@ async def delete_image(image_id: int, db: AsyncSession = Depends(get_db)): image_record = result.scalar_one_or_none() if image_record is None: raise HTTPException(status_code=404, detail="图片记录不存在") - # 从 BM25 索引移除 - if bm25_index is not None: - bm25_index.remove_document(image_record.id) # 删除磁盘上的图片文件 try: os.unlink(image_record.file_path) @@ -561,9 +513,6 @@ async def dedup_images(db: AsyncSession = Depends(get_db)): duplicates_found += len(group) - 1 kept += 1 for record in group[1:]: - # 从 BM25 索引移除 - if bm25_index is not None: - bm25_index.remove_document(record.id) # 删除磁盘上的图片文件 try: os.unlink(record.file_path) diff --git a/app/main.py b/app/main.py index 988ce02..eee9475 100644 --- a/app/main.py +++ b/app/main.py @@ -107,7 +107,7 @@ bot_manager = BotManager() async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: """ 应用生命周期管理: - - 启动时:初始化数据目录、验证数据库连接、预热嵌入服务、初始化 BM25 索引 + - 启动时:初始化数据目录、验证数据库连接、预热嵌入服务 - 关闭时:释放数据库连接 """ _logger = logging.getLogger(__name__) @@ -126,53 +126,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: except Exception as exc: _logger.warning("嵌入服务预热失败(将在首次使用时重试): %s", exc) - # ── 初始化 BM25 索引 ── - try: - from app.services.search_engine import BM25Index - import app.api.v1.images as images_module - - _images_bm25 = BM25Index() - - # 加载数据库中已有的 OCR 记录到索引 - from app.database import async_session_factory - from app.models.base import OCRImage - from sqlalchemy import select - - async with async_session_factory() as session: - result = await session.execute( - select(OCRImage).where(OCRImage.status == "completed") - ) - records = result.scalars().all() - for record in records: - if record.ocr_text: - index_text = record.ocr_text - if record.tags: - try: - tags = json.loads(record.tags) - if tags: - index_text += "\n" + " ".join(tags) - except Exception: - pass - if record.story_summary: - index_text += "\n" + record.story_summary - _images_bm25.add_document( - doc_id=record.id, - text=index_text, - metadata={ - "file_path": record.file_path, - "tags": json.loads(record.tags) if record.tags else [], - "story_summary": record.story_summary or "", - "confidence": record.confidence, - "provider": record.provider, - "created_at": str(record.created_at), - }, - ) - - images_module.bm25_index = _images_bm25 - _logger.info("BM25 索引加载完成: %d 条记录", _images_bm25.size) - except Exception as exc: - _logger.warning("BM25 索引初始化失败: %s", exc) - yield # ── 关闭 ── diff --git a/app/models/base.py b/app/models/base.py index e7b750a..2d4c256 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -66,20 +66,8 @@ class OCRImage(Base, TimestampMixin): status: Mapped[str] = mapped_column( String(20), default="pending", comment="状态: pending/processing/completed/failed" ) - tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="关键词标签(JSON 数组)") - story_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="故事摘要") blocks: Mapped[str | None] = mapped_column(Text, nullable=True, comment="OCR 文本块(JSON)") - @property - def tags_list(self) -> list[str]: - if self.tags is None: - return [] - return json.loads(self.tags) - - @tags_list.setter - def tags_list(self, value: list[str]): - self.tags = json.dumps(value) if value else None - @property def blocks_list(self) -> list[dict]: if self.blocks is None: diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 54ac2a5..2b967a2 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -236,158 +236,6 @@ class LLMService: return [] - @classmethod - async def extract_tags(cls, text: str) -> List[str]: - """ - 从文本中提取语义标签。 - 用于图片 OCR 内容的标签化和搜索查询的标签化。 - - Args: - text: 待提取标签的文本 - - Returns: - 标签列表,如 ["教育", "高考数学", "二次方程", "解题技巧"] - """ - messages = [ - { - "role": "system", - "content": ( - "你是一个内容标签提取助手。用户会给你一段文字内容," - "你需要从中提取 3-10 个能概括内容主题的标签。\n" - "标签要求:\n" - "1. 用简短的词语(2-6个字)\n" - "2. 涵盖主题、场景、情感、关键实体等维度\n" - "3. 考虑同义词和近义词(如'不想工作'也打上'躺平'标签)\n" - "4. 只输出 JSON 数组,不要其他文字\n" - '例如:["高考数学", "二次方程", "韦达定理", "解题技巧"]' - ), - }, - { - "role": "user", - "content": f"请从以下内容中提取标签:\n\n{text[:2000]}", - }, - ] - - try: - result = await cls.chat(messages, temperature=0.3, max_tokens=256) - result = result.strip() - if result.startswith("```"): - result = result.split("\n", 1)[-1] - if result.endswith("```"): - result = result[:-3] - result = result.strip() - tags = json.loads(result) - if isinstance(tags, list): - # 去重、清洗 - seen = set() - clean = [] - for t in tags: - t = str(t).strip() - if 1 <= len(t) <= 20 and t not in seen: - seen.add(t) - clean.append(t) - return clean[:15] - except Exception as exc: - logger.warning("标签提取失败: %s", exc) - - return [] - - @classmethod - async def extract_search_tags(cls, query: str) -> List[str]: - """ - 从用户搜索查询中提取标签,用于匹配数据库中存储的标签。 - 和 extract_tags 类似,但更侧重于提取搜索意图相关的标签。 - """ - messages = [ - { - "role": "system", - "content": ( - "你是一个搜索意图分析助手。用户会给你一个搜索查询," - "你需要提取 3-8 个可能匹配的标签词。\n" - "标签要求:\n" - "1. 用简短的词语(2-6个字)\n" - "2. 包含同义词和近义词\n" - "3. 包含上义词和下义词\n" - "4. 只输出 JSON 数组\n" - '例如:用户搜"孩子躺平",输出 ["躺平", "不上班", "年轻人", "就业", "摆烂", "啃老"]' - ), - }, - { - "role": "user", - "content": f"请从以下搜索查询中提取匹配标签:{query}", - }, - ] - - try: - result = await cls.chat(messages, temperature=0.3, max_tokens=256) - result = result.strip() - if result.startswith("```"): - result = result.split("\n", 1)[-1] - if result.endswith("```"): - result = result[:-3] - result = result.strip() - tags = json.loads(result) - if isinstance(tags, list): - seen = set() - clean = [] - for t in tags: - t = str(t).strip() - if 1 <= len(t) <= 20 and t not in seen: - seen.add(t) - clean.append(t) - return clean[:10] - except Exception as exc: - logger.warning("搜索标签提取失败: %s", exc) - - # 降级:直接用原始查询词作为标签 - return [query.strip()] if query.strip() else [] - - @classmethod - async def summarize_story(cls, text: str) -> str: - """ - 用 Qwen3-8B 提炼图片 OCR 文本的故事内容。 - 非思考模式,直接输出。 - """ - messages = [ - { - "role": "system", - "content": ( - "你是一个内容提炼助手。用户会给你一段从图片中识别出的文字内容," - "你需要提炼出其中讲述的故事或事件。\n" - "要求:\n" - "1. 用简洁的自然语言描述图片内容讲述的故事或事件\n" - "2. 保留关键人物、事件、情感、场景等核心信息\n" - "3. 50-200字\n" - "4. 直接输出提炼内容,不要加前缀" - ), - }, - { - "role": "user", - "content": f"/no_think\n请提炼以下内容的故事:\n\n{text[:3000]}", - }, - ] - - try: - from openai import AsyncOpenAI - client = AsyncOpenAI( - api_key=settings.OPENAI_API_KEY, - base_url=settings.OPENAI_BASE_URL, - ) - response = await client.chat.completions.create( - model="Qwen/Qwen3-8B", - messages=messages, - temperature=0.3, - max_tokens=512, - extra_body={"chat_template_kwargs": {"enable_thinking": False}}, - ) - content = response.choices[0].message.content or "" - # 清理可能的 /no_think 回显 - content = content.replace("/no_think", "").strip() - return content - except Exception as exc: - logger.warning("故事提炼失败: %s", exc) - return "" - @classmethod async def judge_batch(cls, query: str, articles: List[dict]) -> List[int]: """ diff --git a/app/services/search_engine.py b/app/services/search_engine.py deleted file mode 100644 index ef00e6a..0000000 --- a/app/services/search_engine.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -BM25 倒排索引搜索引擎 -用于图片 OCR 内容的全文搜索 -参考 Meilisearch / Whoosh 设计 -""" - -from __future__ import annotations - -import re -import math -import logging -from collections import Counter, defaultdict -from typing import List, Dict, Optional, Tuple - -logger = logging.getLogger(__name__) - -# 尝试导入 jieba,失败则用简单的字符分割 -try: - import jieba - jieba.setLogLevel(logging.WARNING) - def tokenize(text: str) -> List[str]: - """中文分词""" - return [w for w in jieba.cut_for_search(text) if len(w.strip()) > 1] -except ImportError: - logger.warning("jieba 未安装,使用简单分词(pip install jieba 获得更好效果)") - def tokenize(text: str) -> List[str]: - """简单分词:按空格和标点分割""" - return [w for w in re.split(r'[\s,.\-;:!?"\'/\\|()\[\]{}<>,。;:!?""''、()【】]+', text) if len(w.strip()) > 1] - - -class BM25Index: - """ - BM25 倒排索引 - - 支持增量添加文档 - - 支持删除文档 - - 支持中文分词(jieba) - - BM25 评分排序 - """ - - def __init__(self, k1: float = 1.5, b: float = 0.75): - """ - Args: - k1: 词频饱和参数(默认 1.5) - b: 文档长度归一化参数(默认 0.75) - """ - self.k1 = k1 - self.b = b - - # 倒排索引:term -> {doc_id: tf} - self.inverted_index: Dict[str, Dict[int, int]] = defaultdict(lambda: defaultdict(int)) - - # 文档信息 - self.doc_lengths: Dict[int, int] = {} # doc_id -> 文档长度(词数) - self.doc_count: int = 0 - self.avg_doc_length: float = 0.0 - - # 文档存储(存完整数据) - self.documents: Dict[int, dict] = {} - - # IDF 缓存 - self._idf_cache: Dict[str, float] = {} - - def add_document(self, doc_id: int, text: str, metadata: Optional[dict] = None): - """ - 添加文档到索引 - - Args: - doc_id: 文档 ID - text: 待索引的文本内容 - metadata: 附加元数据(如 file_path, tags 等) - """ - # 如果已存在,先删除 - if doc_id in self.documents: - self.remove_document(doc_id) - - tokens = tokenize(text) - self.doc_lengths[doc_id] = len(tokens) - self.documents[doc_id] = { - "text": text, - "tokens": tokens, - "metadata": metadata or {}, - } - - # 构建倒排索引 - tf = Counter(tokens) - for term, count in tf.items(): - self.inverted_index[term][doc_id] = count - - self.doc_count += 1 - self._update_avg_doc_length() - self._idf_cache.clear() # 文档数变了,IDF 需要重算 - - def remove_document(self, doc_id: int): - """从索引中删除文档""" - if doc_id not in self.documents: - return - - # 从倒排索引中移除 - tokens = self.documents[doc_id].get("tokens", []) - for term in set(tokens): - if term in self.inverted_index and doc_id in self.inverted_index[term]: - del self.inverted_index[term][doc_id] - if not self.inverted_index[term]: - del self.inverted_index[term] - - del self.documents[doc_id] - del self.doc_lengths[doc_id] - self.doc_count -= 1 - self._update_avg_doc_length() - self._idf_cache.clear() - - def _update_avg_doc_length(self): - """更新平均文档长度""" - if self.doc_count > 0: - self.avg_doc_length = sum(self.doc_lengths.values()) / self.doc_count - else: - self.avg_doc_length = 0.0 - - def _idf(self, term: str) -> float: - """ - 计算逆文档频率 IDF - IDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1) - 其中 N 是文档总数,df(t) 是包含 term 的文档数 - """ - if term in self._idf_cache: - return self._idf_cache[term] - - df = len(self.inverted_index.get(term, {})) - idf = math.log((self.doc_count - df + 0.5) / (df + 0.5) + 1) - - self._idf_cache[term] = idf - return idf - - def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]: - """ - BM25 搜索 - - Args: - query: 搜索查询(会自动分词) - top_k: 返回前 K 个结果 - - Returns: - [(doc_id, score), ...] 按 BM25 分数降序排列 - """ - if self.doc_count == 0 or self.avg_doc_length == 0: - return [] - - query_tokens = tokenize(query) - if not query_tokens: - return [] - - scores: Dict[int, float] = defaultdict(float) - - for term in query_tokens: - idf = self._idf(term) - postings = self.inverted_index.get(term, {}) - - for doc_id, tf in postings.items(): - dl = self.doc_lengths[doc_id] - # BM25 公式 - numerator = tf * (self.k1 + 1) - denominator = tf + self.k1 * (1 - self.b + self.b * dl / self.avg_doc_length) - scores[doc_id] += idf * numerator / denominator - - # 按分数降序排序 - ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) - return ranked[:top_k] - - def get_document(self, doc_id: int) -> Optional[dict]: - """获取文档完整数据""" - return self.documents.get(doc_id) - - def clear(self): - """清空索引""" - self.inverted_index.clear() - self.doc_lengths.clear() - self.documents.clear() - self._idf_cache.clear() - self.doc_count = 0 - self.avg_doc_length = 0.0 - - @property - def size(self) -> int: - """索引中的文档数量""" - return self.doc_count diff --git a/requirements.txt b/requirements.txt index ee4d393..170bb47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,9 +28,6 @@ python-frontmatter>=1.0.0,<2.0.0 # ── Word 文档解析 ── python-docx>=1.1.0,<2.0.0 -# ── 中文分词(BM25 搜索引擎) ── -jieba>=0.42.1 - # ── 工具库 ── numpy>=1.26.0,<3.0.0 diff --git a/static/index.html b/static/index.html index 3987f5a..3992220 100644 --- a/static/index.html +++ b/static/index.html @@ -2141,13 +2141,6 @@ queue.innerHTML = `
${escapeHtml(item.result.story_summary)}
` - : ''; return `故事摘要
${escapeHtml(result.story_summary)}
${escapeHtml(result.ocr_text || '无识别结果')}
${escapeHtml(data.ocr_text_preview || '')}
- ${tagsHtml(data.tags)}