rag/wwwroot/knowledge_bases_list/search_result.dspy

365 lines
17 KiB
Plaintext
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.

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 rag_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(在线文本) / qwen3-vl-embedding(在线多模态) / clip-vith14(GPU本地)
emb_engine = 'clip-vith14'
try:
async with get_sor_context(env, 'rag') as sor:
krecs = await sor.sqlExe("SELECT embedding_engine FROM rag_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
is_vl = emb_engine == 'qwen3-vl-embedding'
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'
# VDB 服务地址:读 upapp.rag-vdb生产已切内网不硬编码
try:
async with get_sor_context(env, 'rag') as sor:
_u = await sor.sqlExe("SELECT baseurl FROM upapp WHERE id='rag-vdb'", {})
VDB_BASE = (_u[0].baseurl or '').rstrip('/') if _u else 'https://vectordb.opencomputing.net:10443'
except:
VDB_BASE = 'https://vectordb.opencomputing.net:10443'
if query:
# 1. Embed在线vl原生 / bge-m3兼容GPUCLIP /mme
vec = []
if is_vl:
try:
from rag.vl_online import vl_embed_contents
vec = await vl_embed_contents([{"text": query}]) or []
except:
vec = []
elif emb_engine == 'bge-m3':
try:
from rag.init import _online_embed
_vs = await _online_embed(env, [query])
vec = _vs[0] if _vs else []
except:
vec = []
else:
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', VDB_BASE + '/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 rag_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 rag_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"])
# 在线重排mm_rerank未配置/失败则保持召回分排序)
rr_applied = False
try:
from rag.vl_online import vl_rerank
cand = hits[:top_k * 3]
if cand:
rr = await vl_rerank(query, [h.get("text", "") for h in cand])
if rr and isinstance(rr.get("scores"), list) and len(rr["scores"]) == len(cand):
for i, s in enumerate(rr["scores"]):
cand[i]["score"] = s
cand.sort(key=lambda x: x.get("score", 0), reverse=True)
rr_applied = True
except Exception as e:
info('[search_result] rerank failed: %s' % e)
if not rr_applied:
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 rag_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 rag_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 "🔍 标签检索"
# 补全 hits 的 file_name/file_path向量/关键词召回路径只有 chunk 信息,缺文件名)
try:
need = sorted({h.get("doc_id", "") for h in hits if h.get("doc_id") and not h.get("file_name")})
if need:
ph = []
nsq_d = {}
for i, did in enumerate(need):
ph.append("${ddid_" + str(i) + "}$")
nsq_d["ddid_" + str(i)] = did
async with get_sor_context(env, 'rag') as sor:
drecs = await sor.sqlExe(
"SELECT id, file_name, file_path FROM rag_documents WHERE id IN (" + ",".join(ph) + ")", nsq_d)
dmap = {r.id: (r.file_name or '', r.file_path or '') for r in drecs}
for h in hits:
if not h.get("file_name") and h.get("doc_id") in dmap:
fn, fp = dmap[h["doc_id"]]
h["file_name"] = fn
if not h.get("file_path"):
h["file_path"] = fp
except Exception as e:
info('[search_result] enrich file_name failed: %s' % e)
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.get('file_name') or (h.get('id') or '')[: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.get('file_name') or ''}", "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)