feat: v1.2.0 - 图片去重与管理、微信机器人优化、搜索设置可配置

主要功能:
- 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate)
- 图片管理 Tab:展示所有图片、手动删除、一键去重
- 搜索结果详情弹窗增加删除按钮(带确认弹窗)
- 图片管理卡片点击查看详情(复用 showOcrDetailModal)
- 搜索限制和 LLM 批量判断数量可通过网站设置
- MiniMax API 调用添加 reasoning_split=True
- 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量
- 版本号升级至 1.2.0
This commit is contained in:
EduBrain Dev
2026-04-13 22:25:08 +08:00
commit b17786b57b
56 changed files with 9300 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
"""
语义搜索服务
支持 PostgreSQL向量+全文混合)和 SQLiteLIKE 关键词搜索)
"""
from __future__ import annotations
import json
import logging
import time
from typing import List, Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import IS_SQLITE
from app.schemas.search import SearchRequest, SearchResult, SearchResponse
logger = logging.getLogger(__name__)
class SearchService:
"""语义搜索服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def search(self, request: SearchRequest) -> SearchResponse:
"""
执行搜索。
- PostgreSQL: 向量搜索 + 中文全文搜索混合排序
- SQLite: LIKE 关键词搜索(无向量能力)
"""
start_time = time.time()
if IS_SQLITE:
return await self._search_sqlite(request, start_time)
else:
return await self._search_postgres(request, start_time)
# ──────────────────────────── SQLite 搜索 ────────────────────────────
async def _search_sqlite(self, request: SearchRequest, start_time: float) -> SearchResponse:
"""SQLite 模式:使用 LIKE 关键词搜索"""
# LLM 查询扩展(可选)
expanded_queries = [request.query]
try:
from app.services.llm_service import LLMService
expanded_queries = await LLMService.expand_query(request.query)
except Exception as exc:
logger.debug("查询扩展跳过: %s", exc)
results: list[SearchResult] = []
seen_chunk_ids = set()
for q in expanded_queries:
like_pattern = f"%{q}%"
sql = text("""
SELECT
kc.id AS chunk_id,
kp.id AS page_id,
kp.title AS page_title,
kc.content,
kp.course_name,
kp.teacher_name,
kp.live_date
FROM knowledge_chunks kc
JOIN knowledge_pages kp ON kc.page_id = kp.id
WHERE kc.content LIKE :pattern
AND (:course IS NULL OR kp.course_name = :course)
AND (:teacher IS NULL OR kp.teacher_name = :teacher)
ORDER BY kc.id
LIMIT :limit
""")
params = {
"pattern": like_pattern,
"course": request.course_name,
"teacher": request.teacher_name,
"limit": request.top_k,
}
result = await self.db.execute(sql, params)
rows = result.fetchall()
for row in rows:
if row.chunk_id not in seen_chunk_ids:
seen_chunk_ids.add(row.chunk_id)
# 简单的相关性评分:关键词出现次数
score = min(1.0, row.content.lower().count(q.lower()) * 0.2 + 0.5)
results.append(SearchResult(
chunk_id=row.chunk_id,
page_id=row.page_id,
page_title=row.page_title,
content=row.content,
score=round(score, 4),
course_name=row.course_name,
teacher_name=row.teacher_name,
live_date=row.live_date,
highlight=None,
))
results.sort(key=lambda x: x.score, reverse=True)
results = results[:request.top_k]
elapsed_ms = (time.time() - start_time) * 1000
return SearchResponse(
query=request.query,
total=len(results),
results=results,
elapsed_ms=round(elapsed_ms, 2),
)
# ──────────────────────────── PostgreSQL 搜索 ────────────────────────────
async def _search_postgres(self, request: SearchRequest, start_time: float) -> SearchResponse:
"""PostgreSQL 模式:向量搜索 + 中文全文搜索混合"""
# LLM 查询扩展
expanded_queries = [request.query]
if request.use_fulltext:
try:
from app.services.llm_service import LLMService
expanded_queries = await LLMService.expand_query(request.query)
except Exception as exc:
logger.debug("查询扩展跳过: %s", exc)
# 生成查询向量
from app.services.embedding_service import EmbeddingService
all_query_embeddings = await EmbeddingService.embed_batch(expanded_queries)
# 构建过滤条件
where_clauses = []
params: dict = {}
if request.course_name:
where_clauses.append("kp.course_name = :course_name")
params["course_name"] = request.course_name
if request.teacher_name:
where_clauses.append("kp.teacher_name = :teacher_name")
params["teacher_name"] = request.teacher_name
if request.live_date_from:
where_clauses.append("kp.live_date >= :live_date_from")
params["live_date_from"] = request.live_date_from
if request.live_date_to:
where_clauses.append("kp.live_date <= :live_date_to")
params["live_date_to"] = request.live_date_to
where_sql = ""
if where_clauses:
where_sql = "AND " + " AND ".join(where_clauses)
# 向量搜索
vector_results = []
params["limit"] = request.top_k * 2
for q_embedding in all_query_embeddings:
embedding_str = "[" + ",".join(str(x) for x in q_embedding) + "]"
vector_sql = f"""
SELECT
kc.id AS chunk_id,
kp.id AS page_id,
kp.title AS page_title,
kc.content,
1 - (kc.embedding <=> :embedding::vector) AS vector_score,
0.0 AS text_score,
kp.course_name,
kp.teacher_name,
kp.live_date
FROM knowledge_chunks kc
JOIN knowledge_pages kp ON kc.page_id = kp.id
WHERE kc.embedding IS NOT NULL
{where_sql}
ORDER BY kc.embedding <=> :embedding::vector
LIMIT :limit
"""
v_params = {**params, "embedding": embedding_str}
result = await self.db.execute(text(vector_sql), v_params)
vector_results.extend(result.fetchall())
# 全文搜索
text_results = []
if request.use_fulltext:
for q in expanded_queries:
clean_query = q.replace("'", "''")
fulltext_sql = f"""
SELECT
kc.id AS chunk_id,
kp.id AS page_id,
kp.title AS page_title,
kc.content,
0.0 AS vector_score,
ts_rank_cd(kc.search_vector, to_tsquery('chinese_zh', :query)) AS text_score,
kp.course_name,
kp.teacher_name,
kp.live_date,
ts_headline('chinese_zh', kc.content, to_tsquery('chinese_zh', :query),
'MaxWords=50, MinWords=20, ShortWord=2') AS highlight
FROM knowledge_chunks kc
JOIN knowledge_pages kp ON kc.page_id = kp.id
WHERE kc.search_vector @@ to_tsquery('chinese_zh', :query)
{where_sql}
ORDER BY text_score DESC
LIMIT :limit
"""
ft_params = {**params, "query": clean_query}
ft_result = await self.db.execute(text(fulltext_sql), ft_params)
text_results.extend(ft_result.fetchall())
# 合并结果
merged: dict[int, dict] = {}
for row in vector_results:
chunk_id = row.chunk_id
if chunk_id not in merged:
merged[chunk_id] = {
"chunk_id": chunk_id,
"page_id": row.page_id,
"page_title": row.page_title,
"content": row.content,
"vector_score": row.vector_score,
"text_score": 0.0,
"course_name": row.course_name,
"teacher_name": row.teacher_name,
"live_date": row.live_date,
"highlight": None,
}
else:
merged[chunk_id]["vector_score"] = max(
merged[chunk_id]["vector_score"], row.vector_score
)
for row in text_results:
chunk_id = row.chunk_id
if chunk_id not in merged:
merged[chunk_id] = {
"chunk_id": chunk_id,
"page_id": row.page_id,
"page_title": row.page_title,
"content": row.content,
"vector_score": 0.0,
"text_score": row.text_score or 0.0,
"course_name": row.course_name,
"teacher_name": row.teacher_name,
"live_date": row.live_date,
"highlight": row.highlight,
}
else:
merged[chunk_id]["text_score"] = max(
merged[chunk_id]["text_score"], row.text_score or 0.0
)
if row.highlight:
merged[chunk_id]["highlight"] = row.highlight
# 计算综合得分
max_text_score = max(
(r["text_score"] for r in merged.values()), default=1.0
) or 1.0
scored_results = []
for item in merged.values():
normalized_text = item["text_score"] / max_text_score if max_text_score > 0 else 0
combined_score = 0.7 * item["vector_score"] + 0.3 * normalized_text
if combined_score >= request.threshold:
scored_results.append(
SearchResult(
chunk_id=item["chunk_id"],
page_id=item["page_id"],
page_title=item["page_title"],
content=item["content"],
score=round(combined_score, 4),
course_name=item["course_name"],
teacher_name=item["teacher_name"],
live_date=item["live_date"],
highlight=item["highlight"],
)
)
scored_results.sort(key=lambda x: x.score, reverse=True)
scored_results = scored_results[:request.top_k]
elapsed_ms = (time.time() - start_time) * 1000
return SearchResponse(
query=request.query,
total=len(scored_results),
results=scored_results,
elapsed_ms=round(elapsed_ms, 2),
)