- create_kb/new_kb_form 加向量引擎选择(文本 bge-m3 / 多媒体 clip-vith14) - upload_file/batch_ingest/search_result/init.py 按 embedding_engine 路由到 /txte 或 /mme - 文本知识库上传媒体文件友好拒绝(不再崩溃报错)
303 lines
15 KiB
Plaintext
303 lines
15 KiB
Plaintext
ns = params_kw.copy()
|
|
kb_id = ns.get('kb_id', '')
|
|
env = request._run_ns
|
|
query = ns.get("query") or ns.get("value") or ns.get("keyword") or ns.get("text") or ""
|
|
tag_ids_str = ns.get("tag_ids", "")
|
|
# Defensive: legacy UI cached in browser double-encodes the keyword (%E5%88%86... arrives as-is).
|
|
# Normal path: server already decoded once, no '%' present, this is a no-op.
|
|
if "%" in query:
|
|
try:
|
|
from urllib.parse import unquote
|
|
d = unquote(query)
|
|
if d != query:
|
|
info(f'[search_result] unquote fallback: {query!r} -> {d!r}')
|
|
query = d
|
|
except Exception:
|
|
pass
|
|
info(f'[search_result] params_kw={params_kw}')
|
|
info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} tag_ids={tag_ids_str!r} method={request.method} url={request.url}')
|
|
|
|
file_data = None
|
|
file_name = None
|
|
try:
|
|
reader = await request.multipart()
|
|
while True:
|
|
part = await reader.next()
|
|
if part is None: break
|
|
if part.name == "file": file_data = await part.read(); file_name = part.filename; break
|
|
except: pass
|
|
if not file_data:
|
|
try: file_data = await request.read();
|
|
except: pass
|
|
if file_data and len(file_data) < 10: file_data = None
|
|
|
|
import base64, io
|
|
top_k = int(ns.get('top_k', 5))
|
|
|
|
# 0. Parse tag filter FIRST — find doc_ids that match ALL selected tags
|
|
tag_doc_ids = None
|
|
tag_info = ""
|
|
if tag_ids_str:
|
|
wanted_tags = [t.strip() for t in tag_ids_str.split(",") if t.strip()]
|
|
if wanted_tags:
|
|
try:
|
|
async with get_sor_context(env, 'rag') as sor:
|
|
placeholders = []
|
|
nsq = {}
|
|
kb_filter = ""
|
|
if kb_id and kb_id != "all":
|
|
kb_filter = "kb_id=${kb_id}$ AND "
|
|
nsq["kb_id"] = kb_id
|
|
for i, tid in enumerate(wanted_tags):
|
|
placeholders.append("${tid_" + str(i) + "}$")
|
|
nsq["tid_" + str(i)] = tid
|
|
sql = ("SELECT media_id FROM media_tags "
|
|
"WHERE " + kb_filter + "media_type='document' AND tag_id IN (" + ",".join(placeholders) + ") "
|
|
"GROUP BY media_id HAVING COUNT(DISTINCT tag_id)=" + str(len(wanted_tags)))
|
|
recs = await sor.sqlExe(sql, nsq)
|
|
tag_doc_ids = set(r.media_id for r in recs)
|
|
tag_info = " (标签: " + ",".join(wanted_tags[:3]) + ("..." if len(wanted_tags)>3 else "") + ")"
|
|
info(f'[search_result] tag filter: {len(tag_doc_ids)} docs match {len(wanted_tags)} tags')
|
|
except Exception as e:
|
|
info(f'[search_result] tag filter error: {e}')
|
|
|
|
# Early return only when nothing is provided
|
|
if not query and not tag_ids_str and not file_data:
|
|
return json.dumps({
|
|
"widgettype": "VBox", "options": {"padding": "20px", "spacing": "16px"},
|
|
"subwidgets": [
|
|
{"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 20, "fontWeight": "bold"}},
|
|
{"widgettype": "Text", "options": {"text": "请输入检索内容或选择标签", "color": "#666", "marginTop": "20px"}}
|
|
]
|
|
}, ensure_ascii=False)
|
|
|
|
hits = []
|
|
raw_rows = []
|
|
kw_rows = []
|
|
|
|
# 读知识库向量引擎,决定用文本(bge-m3 /txte)还是多媒体(CLIP /mme) embedding
|
|
emb_engine = 'clip-vith14'
|
|
try:
|
|
async with get_sor_context(env, 'rag') as sor:
|
|
krecs = await sor.sqlExe("SELECT embedding_engine FROM knowledge_bases WHERE id=${kb_id}$", {"kb_id": kb_id})
|
|
if krecs:
|
|
emb_engine = (getattr(krecs[0], 'embedding_engine', '') or 'clip-vith14').strip()
|
|
except: pass
|
|
if emb_engine == 'bge-m3':
|
|
emb_url = 'https://embedding.opencomputing.net:10443/txte/api/embed'
|
|
emb_model = 'bge-m3'
|
|
else:
|
|
emb_url = 'https://embedding.opencomputing.net:10443/mme/api/embed'
|
|
emb_model = 'CLIP-ViT-H-14'
|
|
|
|
if query:
|
|
# 1. Embed
|
|
vec = []
|
|
try:
|
|
client = StreamHttpClient()
|
|
resp = await client.request('POST', emb_url,
|
|
json={"texts": [query], "model": emb_model})
|
|
emb = json.loads(resp)
|
|
vec = emb.get("text_embeddings", emb.get("embeddings", [[]]))[0]
|
|
except:
|
|
pass
|
|
if not vec:
|
|
return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False)
|
|
|
|
# 2. VDB search
|
|
try:
|
|
recall_n = top_k * 3
|
|
client2 = StreamHttpClient()
|
|
resp2 = await client2.request('POST', 'https://vectordb.opencomputing.net:10443/v1/query',
|
|
json={"colname": kb_id, "vector": vec, "pagerows": recall_n, "output_fields": ["*"]})
|
|
vdb = json.loads(resp2)
|
|
raw_rows = vdb.get("data", {}).get("rows", [])
|
|
if not isinstance(raw_rows, list):
|
|
raw_rows = []
|
|
except:
|
|
pass
|
|
|
|
# 3. Keyword recall (hybrid search: vector alone misses technical terms)
|
|
kw_ids = set()
|
|
try:
|
|
tokens = [t for t in query.split() if t][:5] or [query]
|
|
conds = []
|
|
nsq = {"kb_id": kb_id}
|
|
for i, t in enumerate(tokens):
|
|
conds.append("content LIKE ${kw_" + str(i) + "}$")
|
|
nsq["kw_" + str(i)] = "%" + t + "%"
|
|
ksql = "SELECT id, doc_id, content FROM document_chunks WHERE kb_id=${kb_id}$ AND (" + " OR ".join(conds) + ") LIMIT 20"
|
|
async with get_sor_context(env, 'rag') as sor:
|
|
krecs = await sor.sqlExe(ksql, nsq)
|
|
for r in krecs:
|
|
kw_ids.add(r.id)
|
|
kw_rows.append({"id": r.id, "doc_id": r.doc_id or "", "score": 0.99, "text": r.content or '', "kw": True})
|
|
except Exception as e:
|
|
info('[search_result] keyword recall failed: %s' % e)
|
|
|
|
seen = set()
|
|
for row in raw_rows:
|
|
rid = str(row.get("id", ""))
|
|
score = row.get("score", 0)
|
|
# Resolve doc_id from chunk id pattern "xxxx_c0"
|
|
doc_id = rid.rsplit('_c', 1)[0] if '_c' in rid else rid
|
|
# Look up chunk text from DB
|
|
chunk_text = ''
|
|
async with get_sor_context(env, 'rag') as sor:
|
|
recs = await sor.sqlExe(
|
|
"SELECT content, doc_id, metadata FROM document_chunks WHERE id=${id}$",
|
|
{"id": rid})
|
|
chunk_meta = {}
|
|
if recs:
|
|
chunk_text = recs[0].content or ''
|
|
doc_id = recs[0].doc_id or doc_id
|
|
try:
|
|
chunk_meta = json.loads(recs[0].metadata) if recs[0].metadata else {}
|
|
except:
|
|
pass
|
|
# Tag filter
|
|
if tag_doc_ids is not None and doc_id not in tag_doc_ids:
|
|
continue
|
|
if chunk_text:
|
|
is_kw = rid in kw_ids
|
|
hit = {"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw}
|
|
if chunk_meta:
|
|
hit.update({k: chunk_meta[k] for k in ('bbox', 'start_time', 'end_time') if k in chunk_meta})
|
|
hits.append(hit)
|
|
seen.add(rid)
|
|
for kr in kw_rows:
|
|
if kr["id"] not in seen:
|
|
if tag_doc_ids is not None and kr["doc_id"] not in tag_doc_ids:
|
|
continue
|
|
hits.append(kr)
|
|
seen.add(kr["id"])
|
|
|
|
hits.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
hits = hits[:top_k]
|
|
|
|
else:
|
|
# Tag-only search: query documents table directly (handles text + non-text docs)
|
|
if tag_doc_ids:
|
|
try:
|
|
async with get_sor_context(env, 'rag') as sor:
|
|
placeholders2 = []
|
|
nsq2 = {}
|
|
kb_cond = ""
|
|
if kb_id and kb_id != "all":
|
|
kb_cond = "kb_id=${kb_id}$ AND "
|
|
nsq2["kb_id"] = kb_id
|
|
for i, did in enumerate(tag_doc_ids):
|
|
placeholders2.append("${did_" + str(i) + "}$")
|
|
nsq2["did_" + str(i)] = did
|
|
# Get documents (exclude face/voice generated derivatives)
|
|
sql2 = ("SELECT id, file_name, kb_id, file_path FROM documents WHERE " + kb_cond +
|
|
"id IN (" + ",".join(placeholders2) + ") AND (metadata IS NULL OR metadata NOT LIKE '%%\"face\"%%') ORDER BY created_at DESC LIMIT " + str(top_k * 2))
|
|
docs = await sor.sqlExe(sql2, nsq2)
|
|
doc_list = [(r.id, r.file_name or '', r.kb_id or '', r.file_path or '') for r in docs]
|
|
|
|
if doc_list:
|
|
doc_ids = [d[0] for d in doc_list]
|
|
# Try to get chunks for these docs
|
|
chunk_placeholders = []
|
|
nsq_c = {}
|
|
for i, did in enumerate(doc_ids):
|
|
chunk_placeholders.append("${cdid_" + str(i) + "}$")
|
|
nsq_c["cdid_" + str(i)] = did
|
|
chunks_sql = ("SELECT id, doc_id, content FROM document_chunks WHERE doc_id IN (" +
|
|
",".join(chunk_placeholders) + ") ORDER BY created_at DESC LIMIT " + str(top_k))
|
|
chunk_recs = await sor.sqlExe(chunks_sql, nsq_c)
|
|
chunks_by_doc = {}
|
|
for cr in chunk_recs:
|
|
if cr.doc_id not in chunks_by_doc:
|
|
chunks_by_doc[cr.doc_id] = []
|
|
chunks_by_doc[cr.doc_id].append((cr.id, cr.content or ''))
|
|
|
|
for did, fname, kbid, fpath in doc_list[:top_k]:
|
|
chs = chunks_by_doc.get(did, [])
|
|
if chs:
|
|
for cid, ctext in chs[:2]: # up to 2 chunks per doc
|
|
hits.append({"id": cid, "doc_id": did, "score": 1.0, "text": ctext, "file_name": fname, "file_path": fpath, "kw": False})
|
|
else:
|
|
# Non-text document: render as media widget
|
|
ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''
|
|
hits.append({"id": did, "doc_id": did, "score": 1.0, "text": "", "file_name": fname, "file_path": fpath, "file_ext": ext, "kw": False, "is_media": True})
|
|
except Exception as e:
|
|
info(f'[search_result] tag-only lookup error: {e}')
|
|
|
|
header_text = f"🔍 检索: {query}" if query else "🔍 标签检索"
|
|
subwidgets = [
|
|
{"widgettype": "Text", "options": {"text": header_text + tag_info, "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}},
|
|
{"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果" + (f" (召回 {len(raw_rows)} 条)" if raw_rows else ""), "cfontsize": 13, "color": "#888", "marginBottom": "16px"}}
|
|
]
|
|
|
|
if not hits:
|
|
subwidgets.append({"widgettype": "Text", "options": {"text": "😔 未找到相关内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "marginTop": "40px"}})
|
|
else:
|
|
def safe_url(p):
|
|
if not p:
|
|
return ''
|
|
if p.startswith('/idfile'):
|
|
return p
|
|
return '/idfile' + p
|
|
|
|
video_exts = {'mp4', 'avi', 'mov', 'mkv', 'webm'}
|
|
audio_exts = {'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac'}
|
|
image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
|
|
|
|
for i, h in enumerate(hits):
|
|
is_media = h.get("is_media", False)
|
|
score_pct = round(float(h["score"]) * 100, 1)
|
|
color = "#3b82f6" if score_pct > 60 else ("#10b981" if score_pct > 30 else "#f59e0b")
|
|
badges = [{"widgettype": "Text", "options": {"text": f"{score_pct}%", "cfontsize": 11, "bgcolor": color, "color": "#fff", "padding": "2px 8px", "borderRadius": "10px"}}]
|
|
if h.get("kw"):
|
|
badges.append({"widgettype": "Text", "options": {"text": "📌 关键词命中", "cfontsize": 11, "bgcolor": "#f59e0b", "color": "#fff", "padding": "2px 8px", "borderRadius": "10px", "marginLeft": "6px"}})
|
|
|
|
card_subwidgets = [
|
|
{"widgettype": "HBox", "options": {"alignItems": "center", "marginBottom": "6px"}, "subwidgets": [
|
|
{"widgettype": "Text", "options": {"text": f"#{i+1}", "cfontsize": 12, "fontWeight": "bold", "color": color, "marginRight": "8px"}},
|
|
*badges,
|
|
{"widgettype": "Text", "options": {"text": f" {h['file_name'] or h['id'][:16]}", "cfontsize": 11, "color": "#999", "marginLeft": "8px"}}
|
|
]}
|
|
]
|
|
|
|
if is_media and h.get("file_ext"):
|
|
media_url = entire_url(safe_url(h.get("file_path", "")))
|
|
ext = h["file_ext"]
|
|
if ext in video_exts:
|
|
if media_url:
|
|
card_subwidgets.append({"widgettype": "Html", "options": {"html": "<video controls autoplay muted playsinline style=\"width:100%;max-height:400px\" src=\"" + media_url + "\"></video>", "padding": "4px 0"}})
|
|
elif ext in audio_exts:
|
|
if media_url:
|
|
card_subwidgets.append({"widgettype": "Html", "options": {"html": "<audio controls preload=\"metadata\" style=\"width:100%\" src=\"" + media_url + "\"></audio>", "padding": "4px 0"}})
|
|
elif ext in image_exts:
|
|
if media_url:
|
|
card_subwidgets.append({"widgettype": "Image", "options": {"url": media_url, "width": "100%", "cheight": 14, "objectFit": "contain", "bgcolor": "#f0f0f0"}})
|
|
else:
|
|
card_subwidgets.append({"widgettype": "Text", "options": {"text": f"📎 {h['file_name']}", "cfontsize": 13, "color": "#888"}})
|
|
# Position info (bbox for images, timestamps for video/audio)
|
|
meta_info = []
|
|
bbox = h.get("bbox")
|
|
if bbox and isinstance(bbox, dict):
|
|
meta_info.append(f"📍 ({bbox.get('x1',0):.0f},{bbox.get('y1',0):.0f})-({bbox.get('x2',0):.0f},{bbox.get('y2',0):.0f})")
|
|
start_t = h.get("start_time")
|
|
end_t = h.get("end_time")
|
|
if start_t is not None or end_t is not None:
|
|
st = f"{start_t:.1f}s" if start_t is not None else "0s"
|
|
et = f"{end_t:.1f}s" if end_t is not None else ""
|
|
if et:
|
|
meta_info.append(f"⏱ {st} → {et}")
|
|
else:
|
|
meta_info.append(f"⏱ {st}")
|
|
if meta_info:
|
|
card_subwidgets.append({"widgettype": "Text", "options": {"text": " · ".join(meta_info), "cfontsize": 10, "color": "#888", "marginTop": "2px"}})
|
|
else:
|
|
card_subwidgets.append({"widgettype": "Text", "options": {"text": h["text"][:300], "cfontsize": 13, "color": "#333", "lineHeight": "1.6"}})
|
|
|
|
subwidgets.append({
|
|
"widgettype": "VBox",
|
|
"options": {"padding": "12px 16px", "marginBottom": "8px", "border": "1px solid #e0e0e0", "borderLeft": f"3px solid {color}", "bgcolor": "#fafafa", "borderRadius": "4px"},
|
|
"subwidgets": card_subwidgets
|
|
})
|
|
|
|
return json.dumps({"widgettype": "VBox", "options": {"padding": "20px", "spacing": "4px"}, "subwidgets": subwidgets}, ensure_ascii=False, default=str)
|