Commit Β·
2db7cb0
0
Parent(s):
Initial commit: Explainable RAG system
Browse files- .gitignore +7 -0
- Dockerfile +19 -0
- README.md +0 -0
- app.py +210 -0
- requirements.txt +11 -0
- src/generation/generate.py +115 -0
- src/ingestion/chunk.py +177 -0
- src/ingestion/clean.py +21 -0
- src/ingestion/fetch_docs.py +178 -0
- src/retrieval/embed_store.py +42 -0
- src/retrieval/preprocess.py +8 -0
- src/retrieval/query.py +264 -0
- src/retrieval/test.py +13 -0
- static/main.js +240 -0
- static/mmr.js +245 -0
- static/rerank.js +126 -0
- static/style.css +845 -0
- templates/index.html +38 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
myenv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
data/
|
| 6 |
+
processed/
|
| 7 |
+
embeddings/
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 5000
|
| 11 |
+
|
| 12 |
+
CMD ["bash", "-c", "\
|
| 13 |
+
if [ ! -d embeddings ]; then \
|
| 14 |
+
echo 'Running setup...'; \
|
| 15 |
+
python src/ingestion/fetch_docs.py && \
|
| 16 |
+
python src/ingestion/chunk.py && \
|
| 17 |
+
python src/retrieval/embed_store.py; \
|
| 18 |
+
fi && \
|
| 19 |
+
python app.py"]
|
README.md
ADDED
|
File without changes
|
app.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
DEBUG = False # β set False to hide ALL noise
|
| 5 |
+
|
| 6 |
+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
| 7 |
+
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
| 8 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 9 |
+
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
import numpy as np
|
| 13 |
+
from flask import Flask, render_template, request, jsonify
|
| 14 |
+
from transformers import AutoTokenizer
|
| 15 |
+
from sklearn.decomposition import PCA
|
| 16 |
+
|
| 17 |
+
from src.retrieval.query import (
|
| 18 |
+
retrieve, embed_query, bm25_index, docs_all, metas_all,
|
| 19 |
+
mmr_from_embs, _session
|
| 20 |
+
)
|
| 21 |
+
from src.generation.generate import generate_answer, build_prompt, build_context
|
| 22 |
+
|
| 23 |
+
class SuppressOutput:
|
| 24 |
+
def __enter__(self):
|
| 25 |
+
if not DEBUG:
|
| 26 |
+
self._stdout = sys.stdout
|
| 27 |
+
self._stderr = sys.stderr
|
| 28 |
+
sys.stdout = open(os.devnull, "w")
|
| 29 |
+
sys.stderr = open(os.devnull, "w")
|
| 30 |
+
|
| 31 |
+
def __exit__(self, *args):
|
| 32 |
+
if not DEBUG:
|
| 33 |
+
sys.stdout.close()
|
| 34 |
+
sys.stderr.close()
|
| 35 |
+
sys.stdout = self._stdout
|
| 36 |
+
sys.stderr = self._stderr
|
| 37 |
+
|
| 38 |
+
app = Flask(__name__)
|
| 39 |
+
|
| 40 |
+
with SuppressOutput():
|
| 41 |
+
_tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-small-en-v1.5")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# -----------------------------
|
| 45 |
+
# BM25 IDF
|
| 46 |
+
# -----------------------------
|
| 47 |
+
def _build_idf(bm25):
|
| 48 |
+
return {term: max(0.0, float(val)) for term, val in bm25.idf.items()}
|
| 49 |
+
|
| 50 |
+
_idf_map = _build_idf(bm25_index)
|
| 51 |
+
_max_idf = max(_idf_map.values()) if _idf_map else 1.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# -----------------------------
|
| 55 |
+
# PCA FIT
|
| 56 |
+
# -----------------------------
|
| 57 |
+
def _fit_pca(n_components=128):
|
| 58 |
+
import random
|
| 59 |
+
from sentence_transformers import SentenceTransformer
|
| 60 |
+
|
| 61 |
+
sample = random.sample(docs_all, min(200, len(docs_all)))
|
| 62 |
+
|
| 63 |
+
with SuppressOutput():
|
| 64 |
+
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
|
| 65 |
+
embs = model.encode(sample, normalize_embeddings=True)
|
| 66 |
+
|
| 67 |
+
n_comp = min(n_components, embs.shape[0], embs.shape[1])
|
| 68 |
+
pca = PCA(n_components=n_comp)
|
| 69 |
+
pca.fit(embs)
|
| 70 |
+
return pca
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
_pca = _fit_pca(128)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# -----------------------------
|
| 77 |
+
# ROUTES
|
| 78 |
+
# -----------------------------
|
| 79 |
+
@app.route("/")
|
| 80 |
+
def home():
|
| 81 |
+
return render_template("index.html")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.route("/analyze_query", methods=["POST"])
|
| 85 |
+
def analyze_query():
|
| 86 |
+
data = request.json
|
| 87 |
+
query = data.get("query", "")
|
| 88 |
+
|
| 89 |
+
enc = _tokenizer(query, return_offsets_mapping=True)
|
| 90 |
+
input_ids = enc["input_ids"]
|
| 91 |
+
tokens_raw = _tokenizer.convert_ids_to_tokens(input_ids)
|
| 92 |
+
|
| 93 |
+
special = set(_tokenizer.all_special_tokens)
|
| 94 |
+
tokens = [
|
| 95 |
+
{"token": t, "id": int(i)}
|
| 96 |
+
for t, i in zip(tokens_raw, input_ids)
|
| 97 |
+
if t not in special
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
for tok in tokens:
|
| 101 |
+
word = tok["token"].lstrip("##").lower()
|
| 102 |
+
raw_idf = _idf_map.get(word, 0.0)
|
| 103 |
+
tok["idf"] = round(raw_idf, 4)
|
| 104 |
+
tok["idf_normalized"] = round(raw_idf / _max_idf, 4) if _max_idf else 0.0
|
| 105 |
+
|
| 106 |
+
idf_vals = [t["idf"] for t in tokens]
|
| 107 |
+
avg_idf = round(sum(idf_vals) / len(idf_vals), 4) if idf_vals else 0.0
|
| 108 |
+
unique_toks = len({t["token"] for t in tokens})
|
| 109 |
+
complexity = round(min(1.0, (len(tokens) / 20) * 0.4 + (avg_idf / _max_idf) * 0.6), 3)
|
| 110 |
+
|
| 111 |
+
q_emb = embed_query(query)
|
| 112 |
+
projected = _pca.transform(q_emb.reshape(1, -1))[0]
|
| 113 |
+
p_min, p_max = projected.min(), projected.max()
|
| 114 |
+
|
| 115 |
+
normed = (
|
| 116 |
+
((projected - p_min) / (p_max - p_min) * 2 - 1).tolist()
|
| 117 |
+
if p_max != p_min else [0.0] * len(projected)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return jsonify({
|
| 121 |
+
"tokens": tokens,
|
| 122 |
+
"embedding": [round(v, 4) for v in normed],
|
| 123 |
+
"stats": {
|
| 124 |
+
"token_count": len(tokens),
|
| 125 |
+
"unique_tokens": unique_toks,
|
| 126 |
+
"avg_idf": avg_idf,
|
| 127 |
+
"complexity": complexity,
|
| 128 |
+
}
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@app.route("/mmr_rerun", methods=["POST"])
|
| 133 |
+
def mmr_rerun():
|
| 134 |
+
if _session["query_emb"] is None:
|
| 135 |
+
return jsonify({"error": "No active session. Run a query first."}), 400
|
| 136 |
+
|
| 137 |
+
data = request.json
|
| 138 |
+
lambda_param = float(data.get("lambda", 0.7))
|
| 139 |
+
lambda_param = max(0.0, min(1.0, lambda_param))
|
| 140 |
+
|
| 141 |
+
selected = mmr_from_embs(
|
| 142 |
+
_session["query_emb"],
|
| 143 |
+
_session["doc_indices"],
|
| 144 |
+
_session["embs"],
|
| 145 |
+
k=10,
|
| 146 |
+
lambda_param=lambda_param
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return jsonify({
|
| 150 |
+
"selected_indices": selected,
|
| 151 |
+
"selected_local": [
|
| 152 |
+
_session["doc_indices"].index(s) for s in selected
|
| 153 |
+
],
|
| 154 |
+
"lambda": lambda_param,
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.route("/ask", methods=["POST"])
|
| 159 |
+
def ask():
|
| 160 |
+
data = request.json
|
| 161 |
+
query = data.get("query")
|
| 162 |
+
|
| 163 |
+
if DEBUG:
|
| 164 |
+
print(f"\n[API QUERY]: {query}\n")
|
| 165 |
+
|
| 166 |
+
t0 = time.perf_counter()
|
| 167 |
+
results, debug = retrieve(query)
|
| 168 |
+
t_retrieve = time.perf_counter() - t0
|
| 169 |
+
|
| 170 |
+
results = sorted(results, key=lambda x: (
|
| 171 |
+
x["meta"].get("chunk_id", 0),
|
| 172 |
+
x["meta"].get("global_chunk_id", 0)
|
| 173 |
+
))
|
| 174 |
+
|
| 175 |
+
docs = [r["text"] for r in results]
|
| 176 |
+
metas = [r["meta"] for r in results]
|
| 177 |
+
raw_scores = [float(r["rerank_score"]) for r in results]
|
| 178 |
+
|
| 179 |
+
context = build_context(docs, metas, raw_scores)
|
| 180 |
+
|
| 181 |
+
t1 = time.perf_counter()
|
| 182 |
+
prompt = build_prompt(query, context)
|
| 183 |
+
answer = generate_answer(prompt)
|
| 184 |
+
t_llm = time.perf_counter() - t1
|
| 185 |
+
|
| 186 |
+
sources = [
|
| 187 |
+
{"title": meta.get("title", "Source"), "url": meta.get("url", "")}
|
| 188 |
+
for meta in metas
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
stage_timings = debug.get("timings", {})
|
| 192 |
+
stage_timings["llm"] = round(t_llm * 1000)
|
| 193 |
+
stage_timings["total"] = round((t_retrieve + t_llm) * 1000)
|
| 194 |
+
|
| 195 |
+
return jsonify({
|
| 196 |
+
"answer": answer,
|
| 197 |
+
"sources": sources,
|
| 198 |
+
"chunks": docs,
|
| 199 |
+
"scores": raw_scores,
|
| 200 |
+
"raw_scores": raw_scores,
|
| 201 |
+
"debug": debug,
|
| 202 |
+
"timings": stage_timings
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# -----------------------------
|
| 207 |
+
# RUN
|
| 208 |
+
# -----------------------------
|
| 209 |
+
if __name__ == "__main__":
|
| 210 |
+
app.run(debug=DEBUG)
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
numpy
|
| 3 |
+
scikit-learn
|
| 4 |
+
transformers
|
| 5 |
+
sentence-transformers
|
| 6 |
+
chromadb
|
| 7 |
+
rank-bm25
|
| 8 |
+
umap-learn
|
| 9 |
+
requests
|
| 10 |
+
beautifulsoup4
|
| 11 |
+
tqdm
|
src/generation/generate.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from src.retrieval.query import retrieve
|
| 3 |
+
|
| 4 |
+
def build_prompt(query, context):
|
| 5 |
+
return f"""You are a helpful assistant. Answer using ONLY the context below. Do NOT use outside knowledge.
|
| 6 |
+
|
| 7 |
+
### Context
|
| 8 |
+
{context}
|
| 9 |
+
|
| 10 |
+
### Question
|
| 11 |
+
{query}
|
| 12 |
+
|
| 13 |
+
### Instructions
|
| 14 |
+
|
| 15 |
+
- Start directly with the answer. No introduction.
|
| 16 |
+
- Use only information from the context.
|
| 17 |
+
- Do NOT invent or modify commands beyond fixing formatting.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
### Structure
|
| 22 |
+
|
| 23 |
+
- If the question is conceptual or descriptive (e.g. "what is X", "explain X") β
|
| 24 |
+
explain fully using all relevant information from the context. Cover features,
|
| 25 |
+
use cases, and design principles if present. Do NOT use methods or numbered steps.
|
| 26 |
+
|
| 27 |
+
- If there is ONE clear workflow β use numbered steps.
|
| 28 |
+
|
| 29 |
+
- Only treat something as a separate method if the context explicitly presents it as a distinct approach to solving the same task.
|
| 30 |
+
- Do NOT create methods from examples, helper classes, or unrelated concepts.
|
| 31 |
+
- If the context describes only ONE workflow or approach, DO NOT create multiple methods.
|
| 32 |
+
- In that case, use numbered steps instead.
|
| 33 |
+
|
| 34 |
+
- If there are MULTIPLE valid ways to perform the task:
|
| 35 |
+
β group them into separate sections using:
|
| 36 |
+
|
| 37 |
+
### Method 1 - Name
|
| 38 |
+
### Method 2 - Name
|
| 39 |
+
|
| 40 |
+
- Each method must be self-contained.
|
| 41 |
+
- Do NOT mix commands from different methods.
|
| 42 |
+
- Do NOT merge all methods into one numbered list.
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
### Code Rules
|
| 47 |
+
|
| 48 |
+
- All commands MUST be inside fenced code blocks.
|
| 49 |
+
- Use:
|
| 50 |
+
- ```bash for shell commands
|
| 51 |
+
- ```python for Python code
|
| 52 |
+
|
| 53 |
+
- Commands must be valid and executable.
|
| 54 |
+
|
| 55 |
+
- Fix formatting issues:
|
| 56 |
+
- Merge broken tokens (". env" β ".env")
|
| 57 |
+
- Fix paths ("source . env /bin/activate" β "source .env/bin/activate")
|
| 58 |
+
- Split combined commands into separate lines
|
| 59 |
+
|
| 60 |
+
- NEVER:
|
| 61 |
+
- put multiple commands on the same line
|
| 62 |
+
- leave commands outside code blocks
|
| 63 |
+
- use inline code blocks like ```bash command```
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
### Output Requirements
|
| 68 |
+
|
| 69 |
+
- Output must be clean, valid markdown.
|
| 70 |
+
- Code blocks must render correctly.
|
| 71 |
+
|
| 72 |
+
### Answer
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
import requests
|
| 76 |
+
import os
|
| 77 |
+
|
| 78 |
+
def generate_answer(prompt, model="llama-3.1-8b-instant"):
|
| 79 |
+
response = requests.post(
|
| 80 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 81 |
+
headers={
|
| 82 |
+
"Authorization": f"Bearer {"API_KEY"}",
|
| 83 |
+
"Content-Type": "application/json"
|
| 84 |
+
},
|
| 85 |
+
json={
|
| 86 |
+
"model": model,
|
| 87 |
+
"max_tokens": 2048,
|
| 88 |
+
"messages": [
|
| 89 |
+
{"role": "user", "content": prompt}
|
| 90 |
+
],
|
| 91 |
+
"temperature": 0
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
+
data = response.json()
|
| 95 |
+
print("GROQ RESPONSE:", data)
|
| 96 |
+
|
| 97 |
+
return response.json()["choices"][0]["message"]["content"]
|
| 98 |
+
|
| 99 |
+
def build_context(docs, metas, scores):
|
| 100 |
+
context = ""
|
| 101 |
+
|
| 102 |
+
for i, (doc, meta, score) in enumerate(zip(docs, metas, scores)):
|
| 103 |
+
context += f"""
|
| 104 |
+
[Source {i+1} | Score: {round(score, 3)} | {meta.get('url', 'N/A')}]
|
| 105 |
+
Title: {meta.get("title", "N/A")}
|
| 106 |
+
|
| 107 |
+
{doc}
|
| 108 |
+
|
| 109 |
+
{"-"*50}
|
| 110 |
+
"""
|
| 111 |
+
return context
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
|
src/ingestion/chunk.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import json
|
| 3 |
+
from tqdm import tqdm
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
CHUNK_SIZE = 300
|
| 7 |
+
OVERLAP = 50
|
| 8 |
+
MIN_CHUNK_WORDS = 30
|
| 9 |
+
|
| 10 |
+
def split_by_headings(text: str) -> list[str]:
|
| 11 |
+
|
| 12 |
+
heading_re = re.compile(r'(?:^|\n)(?=\n?[A-Z][^\n]{2,80}\n)')
|
| 13 |
+
|
| 14 |
+
candidate_positions = [m.start() for m in heading_re.finditer(text)]
|
| 15 |
+
|
| 16 |
+
safe_positions = [0]
|
| 17 |
+
in_fence = False
|
| 18 |
+
fence_re = re.compile(r'```')
|
| 19 |
+
fence_positions = [m.start() for m in fence_re.finditer(text)]
|
| 20 |
+
fence_idx = 0
|
| 21 |
+
|
| 22 |
+
for pos in candidate_positions:
|
| 23 |
+
if pos == 0:
|
| 24 |
+
continue
|
| 25 |
+
while fence_idx < len(fence_positions) and fence_positions[fence_idx] < pos:
|
| 26 |
+
in_fence = not in_fence
|
| 27 |
+
fence_idx += 1
|
| 28 |
+
if not in_fence:
|
| 29 |
+
safe_positions.append(pos)
|
| 30 |
+
|
| 31 |
+
safe_positions.append(len(text))
|
| 32 |
+
sections = []
|
| 33 |
+
for i in range(len(safe_positions) - 1):
|
| 34 |
+
chunk = text[safe_positions[i]:safe_positions[i + 1]].strip()
|
| 35 |
+
if chunk:
|
| 36 |
+
sections.append(chunk)
|
| 37 |
+
|
| 38 |
+
return sections if sections else [text.strip()]
|
| 39 |
+
|
| 40 |
+
def split_section_into_parts(section: str) -> list[dict]:
|
| 41 |
+
|
| 42 |
+
parts = []
|
| 43 |
+
segments = re.split(r'(```[\s\S]*?```)', section)
|
| 44 |
+
|
| 45 |
+
for seg in segments:
|
| 46 |
+
seg = seg.strip()
|
| 47 |
+
if not seg:
|
| 48 |
+
continue
|
| 49 |
+
if seg.startswith("```"):
|
| 50 |
+
parts.append({'type': 'code', 'content': seg})
|
| 51 |
+
else:
|
| 52 |
+
sentences = re.split(r'(?<=[.!?])\s+', seg)
|
| 53 |
+
for sent in sentences:
|
| 54 |
+
sent = sent.strip()
|
| 55 |
+
if sent:
|
| 56 |
+
parts.append({'type': 'text', 'content': sent})
|
| 57 |
+
return parts
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def chunk_section(section: str, chunk_size: int = CHUNK_SIZE, overlap: int = OVERLAP) -> list[str]:
|
| 61 |
+
parts = split_section_into_parts(section)
|
| 62 |
+
|
| 63 |
+
chunks: list[str] = []
|
| 64 |
+
current_parts: list[dict] = []
|
| 65 |
+
current_words = 0
|
| 66 |
+
|
| 67 |
+
def flush(current_parts: list[dict]) -> list[str]:
|
| 68 |
+
text = "\n\n".join(p['content'] for p in current_parts).strip()
|
| 69 |
+
return text
|
| 70 |
+
|
| 71 |
+
def word_count(s: str) -> int:
|
| 72 |
+
return len(s.split())
|
| 73 |
+
|
| 74 |
+
for part in parts:
|
| 75 |
+
wc = word_count(part['content'])
|
| 76 |
+
if part['type'] == 'code':
|
| 77 |
+
if current_words + wc > chunk_size and current_parts:
|
| 78 |
+
chunks.append(flush(current_parts))
|
| 79 |
+
current_parts = []
|
| 80 |
+
current_words = 0
|
| 81 |
+
|
| 82 |
+
current_parts.append(part)
|
| 83 |
+
current_words += wc
|
| 84 |
+
|
| 85 |
+
if current_words >= chunk_size:
|
| 86 |
+
chunks.append(flush(current_parts))
|
| 87 |
+
current_parts = []
|
| 88 |
+
current_words = 0
|
| 89 |
+
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
if current_words + wc > chunk_size and current_parts:
|
| 93 |
+
chunks.append(flush(current_parts))
|
| 94 |
+
|
| 95 |
+
overlap_parts: list[dict] = []
|
| 96 |
+
overlap_words = 0
|
| 97 |
+
for prev in reversed(current_parts):
|
| 98 |
+
if prev['type'] == 'code':
|
| 99 |
+
pw = word_count(prev['content'])
|
| 100 |
+
if overlap_words + pw > overlap:
|
| 101 |
+
break
|
| 102 |
+
overlap_parts.insert(0, prev)
|
| 103 |
+
overlap_words += pw
|
| 104 |
+
|
| 105 |
+
current_parts = overlap_parts
|
| 106 |
+
current_words = overlap_words
|
| 107 |
+
|
| 108 |
+
current_parts.append(part)
|
| 109 |
+
current_words += wc
|
| 110 |
+
|
| 111 |
+
if current_parts:
|
| 112 |
+
chunks.append(flush(current_parts))
|
| 113 |
+
|
| 114 |
+
return chunks
|
| 115 |
+
|
| 116 |
+
def hybrid_chunk(doc: dict) -> list[str]:
|
| 117 |
+
sections = split_by_headings(doc["text"])
|
| 118 |
+
final_chunks: list[str] = []
|
| 119 |
+
|
| 120 |
+
for section in sections:
|
| 121 |
+
chunks = chunk_section(section)
|
| 122 |
+
final_chunks.extend(chunks)
|
| 123 |
+
|
| 124 |
+
return final_chunks
|
| 125 |
+
|
| 126 |
+
def chunk_documents(input_path="data/docs.json", output_path="processed/chunks.json"):
|
| 127 |
+
print("[INFO] Loading documents...\n")
|
| 128 |
+
|
| 129 |
+
with open(input_path, "r") as f:
|
| 130 |
+
docs = json.load(f)
|
| 131 |
+
|
| 132 |
+
print(f"[INFO] Loaded {len(docs)} documents\n")
|
| 133 |
+
|
| 134 |
+
all_chunks = []
|
| 135 |
+
global_id = 0
|
| 136 |
+
|
| 137 |
+
for doc in tqdm(docs, desc="Chunking documents"):
|
| 138 |
+
chunks = hybrid_chunk(doc)
|
| 139 |
+
|
| 140 |
+
chunks = [c for c in chunks if len(c.split()) >= MIN_CHUNK_WORDS]
|
| 141 |
+
|
| 142 |
+
print(f"[DEBUG] {doc['metadata']['title']} β {len(chunks)} chunks")
|
| 143 |
+
|
| 144 |
+
for local_id, chunk in enumerate(chunks):
|
| 145 |
+
text = chunk.strip()
|
| 146 |
+
if not text:
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
if text.count("```") % 2 != 0:
|
| 150 |
+
text += "\n```"
|
| 151 |
+
|
| 152 |
+
all_chunks.append({
|
| 153 |
+
"text": text,
|
| 154 |
+
"metadata": {
|
| 155 |
+
**doc["metadata"],
|
| 156 |
+
"chunk_id": local_id,
|
| 157 |
+
"global_chunk_id": global_id
|
| 158 |
+
}
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
global_id += 1
|
| 162 |
+
|
| 163 |
+
wc_list = [len(c["text"].split()) for c in all_chunks]
|
| 164 |
+
print(f"\n[INFO] Total chunks created: {len(all_chunks)}")
|
| 165 |
+
if wc_list:
|
| 166 |
+
print(f"[INFO] Word count β min: {min(wc_list)}, max: {max(wc_list)}, "
|
| 167 |
+
f"mean: {sum(wc_list)//len(wc_list)}, "
|
| 168 |
+
f"median: {sorted(wc_list)[len(wc_list)//2]}")
|
| 169 |
+
|
| 170 |
+
with open(output_path, "w") as f:
|
| 171 |
+
json.dump(all_chunks, f, indent=2)
|
| 172 |
+
|
| 173 |
+
print(f"[SUCCESS] Saved to {output_path}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
chunk_documents()
|
src/ingestion/clean.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def normalize_text(text: str) -> str:
|
| 5 |
+
text = re.sub(r'\r\n?', '\n', text)
|
| 6 |
+
text = re.sub(r'\.\s+([a-zA-Z0-9])', r'.\1', text)
|
| 7 |
+
text = re.sub(r'(\w)\s*/\s*(\w)', r'\1/\2', text)
|
| 8 |
+
text = re.sub(r'[ \t]+', ' ', text)
|
| 9 |
+
text = re.sub(r'\s*```\s*', '\n```\n', text)
|
| 10 |
+
text = re.sub(r'(?<!\n)(uv pip install)', r'\n\1', text)
|
| 11 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 12 |
+
return text.strip()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def clean_text(text: str) -> str:
|
| 16 |
+
text = re.sub(r'[ \t]+', ' ', text)
|
| 17 |
+
text = re.sub(r'\n\s*\n+', '\n\n', text)
|
| 18 |
+
text = text.replace('\xa0', ' ')
|
| 19 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 20 |
+
text = normalize_text(text)
|
| 21 |
+
return text.strip()
|
src/ingestion/fetch_docs.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
sys.path.insert(0, os.path.dirname(__file__))
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
from bs4 import BeautifulSoup
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
from tqdm import tqdm
|
| 12 |
+
from clean import clean_text
|
| 13 |
+
|
| 14 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
| 15 |
+
DOCS_OUTPUT = PROJECT_ROOT / "data" / "docs.json"
|
| 16 |
+
|
| 17 |
+
URLS = [
|
| 18 |
+
"https://huggingface.co/docs/transformers/index/",
|
| 19 |
+
"https://huggingface.co/docs/transformers/installation/",
|
| 20 |
+
"https://huggingface.co/docs/transformers/quicktour/",
|
| 21 |
+
"https://huggingface.co/docs/transformers/weightconverter",
|
| 22 |
+
"https://huggingface.co/docs/transformers/models",
|
| 23 |
+
"https://huggingface.co/docs/transformers/custom_models",
|
| 24 |
+
"https://huggingface.co/docs/transformers/monkey_patching",
|
| 25 |
+
"https://huggingface.co/docs/transformers/fusion_mapping",
|
| 26 |
+
"https://huggingface.co/docs/transformers/how_to_hack_models",
|
| 27 |
+
"https://huggingface.co/docs/transformers/model_sharing",
|
| 28 |
+
"https://huggingface.co/docs/transformers/serialization"
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def normalize_code_block(code: str) -> str:
|
| 33 |
+
lines = code.split("\n")
|
| 34 |
+
stripped = [l.rstrip() for l in lines]
|
| 35 |
+
|
| 36 |
+
# Drop leading/trailing blank lines
|
| 37 |
+
while stripped and not stripped[0]:
|
| 38 |
+
stripped.pop(0)
|
| 39 |
+
while stripped and not stripped[-1]:
|
| 40 |
+
stripped.pop()
|
| 41 |
+
|
| 42 |
+
if not stripped:
|
| 43 |
+
return ""
|
| 44 |
+
|
| 45 |
+
def fix_python_spacing(s: str) -> str:
|
| 46 |
+
import re
|
| 47 |
+
s = re.sub(r'\s*\(\s*', '(', s)
|
| 48 |
+
s = re.sub(r'\s*\)\s*', ')', s)
|
| 49 |
+
s = re.sub(r'\s*\[\s*', '[', s)
|
| 50 |
+
s = re.sub(r'\s*\]\s*', ']', s)
|
| 51 |
+
s = re.sub(r'\s*,\s*', ', ', s)
|
| 52 |
+
s = re.sub(r'\s*:\s*', ': ', s)
|
| 53 |
+
s = re.sub(r'\s*=\s*', '=', s)
|
| 54 |
+
s = re.sub(r'([^=!<>])=([^=])', r'\1 = \2', s)
|
| 55 |
+
s = re.sub(r'==', ' == ', s)
|
| 56 |
+
s = re.sub(r'!=', ' != ', s)
|
| 57 |
+
s = re.sub(r' +', ' ', s)
|
| 58 |
+
return s.strip()
|
| 59 |
+
|
| 60 |
+
if len(stripped) <= 4 and all(len(l.split()) <= 4 for l in stripped if l):
|
| 61 |
+
joined = " ".join(l for l in stripped if l)
|
| 62 |
+
return joined
|
| 63 |
+
|
| 64 |
+
fixed = []
|
| 65 |
+
for line in stripped:
|
| 66 |
+
fixed.append(fix_python_spacing(line))
|
| 67 |
+
return "\n".join(fixed)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def extract_main_text(soup):
|
| 71 |
+
main = soup.find("main")
|
| 72 |
+
if not main:
|
| 73 |
+
return ""
|
| 74 |
+
|
| 75 |
+
for tag in main.find_all(["nav", "footer", "aside", "script", "style"]):
|
| 76 |
+
tag.decompose()
|
| 77 |
+
|
| 78 |
+
for tag in main.find_all(True):
|
| 79 |
+
if tag.get_text(" ", strip=True).startswith("and get access to the augmented"):
|
| 80 |
+
tag.decompose()
|
| 81 |
+
break
|
| 82 |
+
|
| 83 |
+
texts = []
|
| 84 |
+
|
| 85 |
+
for tag in main.find_all(["h1", "h2", "h3", "p", "li", "pre"]):
|
| 86 |
+
|
| 87 |
+
if tag.name == "pre":
|
| 88 |
+
code_tag = tag.find("code")
|
| 89 |
+
raw = (code_tag or tag).get_text("\n")
|
| 90 |
+
|
| 91 |
+
lang = ""
|
| 92 |
+
cls = (code_tag or tag).get("class", [])
|
| 93 |
+
for c in cls:
|
| 94 |
+
if c.startswith("language-"):
|
| 95 |
+
lang = c.replace("language-", "")
|
| 96 |
+
break
|
| 97 |
+
|
| 98 |
+
normalized = normalize_code_block(raw)
|
| 99 |
+
if not normalized or len(normalized.strip()) < 3:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
texts.append(f"\n```{lang}\n{normalized}\n```\n")
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
content = tag.get_text(" ", strip=True)
|
| 106 |
+
|
| 107 |
+
if len(content) < 30:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
if tag.name in ["h1", "h2", "h3"]:
|
| 111 |
+
texts.append(f"\n{content}\n")
|
| 112 |
+
elif tag.name == "li":
|
| 113 |
+
texts.append(f"- {content}")
|
| 114 |
+
else:
|
| 115 |
+
texts.append(content)
|
| 116 |
+
|
| 117 |
+
return "\n\n".join(texts)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def process_url(url, retries=3):
|
| 121 |
+
headers = {"User-Agent": "Mozilla/5.0 (compatible; RAG-doc-fetcher/1.0)"}
|
| 122 |
+
|
| 123 |
+
for attempt in range(retries):
|
| 124 |
+
try:
|
| 125 |
+
response = requests.get(url, timeout=10, headers=headers)
|
| 126 |
+
response.raise_for_status()
|
| 127 |
+
|
| 128 |
+
print(f"[SUCCESS] Fetched: {url} (Attempt {attempt+1})\n")
|
| 129 |
+
|
| 130 |
+
soup = BeautifulSoup(response.text, "html.parser")
|
| 131 |
+
|
| 132 |
+
text = extract_main_text(soup)
|
| 133 |
+
cleaned = clean_text(text)
|
| 134 |
+
|
| 135 |
+
title = soup.title.string if soup.title else "No Title"
|
| 136 |
+
|
| 137 |
+
return {
|
| 138 |
+
"text": cleaned,
|
| 139 |
+
"metadata": {
|
| 140 |
+
"source": "huggingface",
|
| 141 |
+
"url": url,
|
| 142 |
+
"title": title
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
except requests.RequestException as e:
|
| 147 |
+
print(f"[Attempt {attempt+1}/{retries}] Failed: {url}")
|
| 148 |
+
print(f"Error: {e}")
|
| 149 |
+
|
| 150 |
+
if attempt < retries - 1:
|
| 151 |
+
print("Waiting 5 seconds before retry...\n")
|
| 152 |
+
time.sleep(5)
|
| 153 |
+
else:
|
| 154 |
+
print(f"[FAILED] All retries failed for: {url}\n")
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def main():
|
| 159 |
+
docs = []
|
| 160 |
+
|
| 161 |
+
for i, url in enumerate(tqdm(URLS, desc="Processing URLs")):
|
| 162 |
+
doc = process_url(url)
|
| 163 |
+
|
| 164 |
+
if doc and doc["text"].strip():
|
| 165 |
+
docs.append(doc)
|
| 166 |
+
|
| 167 |
+
if i < len(URLS) - 1:
|
| 168 |
+
time.sleep(0.75)
|
| 169 |
+
|
| 170 |
+
DOCS_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
with open(DOCS_OUTPUT, "w") as f:
|
| 172 |
+
json.dump(docs, f, indent=2)
|
| 173 |
+
|
| 174 |
+
print(f"Saved {len(docs)} docs to {DOCS_OUTPUT}")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
main()
|
src/retrieval/embed_store.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from tqdm import tqdm
|
| 3 |
+
from sentence_transformers import SentenceTransformer
|
| 4 |
+
from chromadb import PersistentClient
|
| 5 |
+
|
| 6 |
+
model = SentenceTransformer("BAAI/bge-small-en-v1.5")
|
| 7 |
+
|
| 8 |
+
client = PersistentClient(path="embeddings/")
|
| 9 |
+
collection = client.get_or_create_collection(name="rag_docs")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def embed_and_store(input_path="processed/chunks.json"):
|
| 13 |
+
with open(input_path, "r") as f:
|
| 14 |
+
chunks = json.load(f)
|
| 15 |
+
|
| 16 |
+
documents, metadatas, ids = [], [], []
|
| 17 |
+
|
| 18 |
+
for i, chunk in enumerate(tqdm(chunks)):
|
| 19 |
+
documents.append("passage: " + chunk["text"])
|
| 20 |
+
metadatas.append(chunk["metadata"])
|
| 21 |
+
ids.append(f"chunk_{i}")
|
| 22 |
+
|
| 23 |
+
client.delete_collection(name="rag_docs")
|
| 24 |
+
collection = client.get_or_create_collection(name="rag_docs")
|
| 25 |
+
|
| 26 |
+
embeddings = model.encode(
|
| 27 |
+
documents,
|
| 28 |
+
batch_size=32,
|
| 29 |
+
normalize_embeddings=True,
|
| 30 |
+
show_progress_bar=True
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
collection.add(
|
| 34 |
+
documents=documents,
|
| 35 |
+
metadatas=metadatas,
|
| 36 |
+
ids=ids,
|
| 37 |
+
embeddings=embeddings.tolist()
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
embed_and_store()
|
src/retrieval/preprocess.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from nltk.stem import WordNetLemmatizer
|
| 2 |
+
from nltk.tokenize import word_tokenize
|
| 3 |
+
|
| 4 |
+
lemmatizer = WordNetLemmatizer()
|
| 5 |
+
|
| 6 |
+
def preprocess(text):
|
| 7 |
+
tokens = word_tokenize(text.lower())
|
| 8 |
+
return [lemmatizer.lemmatize(t) for t in tokens]
|
src/retrieval/query.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import time
|
| 3 |
+
import numpy as np
|
| 4 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
| 5 |
+
from chromadb import PersistentClient
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from rank_bm25 import BM25Okapi
|
| 8 |
+
|
| 9 |
+
# ------------------ MODELS ------------------
|
| 10 |
+
embed_model = SentenceTransformer("BAAI/bge-small-en-v1.5")
|
| 11 |
+
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
|
| 12 |
+
|
| 13 |
+
# ------------------ DB ------------------
|
| 14 |
+
client = PersistentClient(path="embeddings/")
|
| 15 |
+
collection = client.get_collection(name="rag_docs")
|
| 16 |
+
|
| 17 |
+
data = collection.get(include=["documents", "metadatas"])
|
| 18 |
+
docs_all = data["documents"]
|
| 19 |
+
metas_all = data["metadatas"]
|
| 20 |
+
ids_all = data["ids"]
|
| 21 |
+
|
| 22 |
+
# ------------------ BM25 INDEX ------------------
|
| 23 |
+
def tokenize(text: str) -> list[str]:
|
| 24 |
+
return re.findall(r"\w+", text.lower())
|
| 25 |
+
|
| 26 |
+
bm25_corpus = [tokenize(doc) for doc in docs_all]
|
| 27 |
+
bm25_index = BM25Okapi(bm25_corpus)
|
| 28 |
+
|
| 29 |
+
def bm25_search(query: str, top_n: int = 25) -> list[tuple[int, float]]:
|
| 30 |
+
tokens = tokenize(query)
|
| 31 |
+
raw_scores = bm25_index.get_scores(tokens)
|
| 32 |
+
max_s, min_s = raw_scores.max(), raw_scores.min()
|
| 33 |
+
norm = (raw_scores - min_s) / (max_s - min_s) if max_s != min_s else np.zeros_like(raw_scores)
|
| 34 |
+
top_indices = np.argsort(norm)[::-1][:top_n]
|
| 35 |
+
return [(int(i), float(norm[i])) for i in top_indices]
|
| 36 |
+
|
| 37 |
+
# ------------------ EMBEDDING ------------------
|
| 38 |
+
@lru_cache(maxsize=128)
|
| 39 |
+
def embed_query(query: str):
|
| 40 |
+
return embed_model.encode("query: " + query, normalize_embeddings=True)
|
| 41 |
+
|
| 42 |
+
# ------------------ HYBRID FUSION ------------------
|
| 43 |
+
def hybrid_fusion(vector_indices, vector_scores, bm25_results, alpha=0.5, top_n=25):
|
| 44 |
+
vec_map = dict(zip(vector_indices, vector_scores))
|
| 45 |
+
bm25_map = dict(bm25_results)
|
| 46 |
+
fused = [
|
| 47 |
+
(idx, vec_map.get(idx, 0.0), bm25_map.get(idx, 0.0),
|
| 48 |
+
alpha * vec_map.get(idx, 0.0) + (1 - alpha) * bm25_map.get(idx, 0.0))
|
| 49 |
+
for idx in set(vec_map) | set(bm25_map)
|
| 50 |
+
]
|
| 51 |
+
fused.sort(key=lambda x: x[3], reverse=True)
|
| 52 |
+
return fused[:top_n]
|
| 53 |
+
|
| 54 |
+
# ------------------ MMR ------------------
|
| 55 |
+
def mmr(query_emb, doc_indices, k=10, lambda_param=0.7):
|
| 56 |
+
embs = [
|
| 57 |
+
np.array(collection.get(ids=[ids_all[i]], include=["embeddings"])["embeddings"][0])
|
| 58 |
+
for i in doc_indices
|
| 59 |
+
]
|
| 60 |
+
embs = [e / np.linalg.norm(e) for e in embs]
|
| 61 |
+
query_emb = query_emb / np.linalg.norm(query_emb)
|
| 62 |
+
sims = [np.dot(query_emb, e) for e in embs]
|
| 63 |
+
|
| 64 |
+
best_idx = int(np.argmax(sims))
|
| 65 |
+
selected = [doc_indices[best_idx]]
|
| 66 |
+
sel_idx = [best_idx]
|
| 67 |
+
mmr_debug = []
|
| 68 |
+
|
| 69 |
+
while len(selected) < min(k, len(doc_indices)):
|
| 70 |
+
scores = [
|
| 71 |
+
(lambda_param * sims[i] - (1 - lambda_param) * max(np.dot(embs[i], embs[j]) for j in sel_idx),
|
| 72 |
+
i, sims[i], max(np.dot(embs[i], embs[j]) for j in sel_idx))
|
| 73 |
+
for i in range(len(doc_indices)) if i not in sel_idx
|
| 74 |
+
]
|
| 75 |
+
if not scores:
|
| 76 |
+
break
|
| 77 |
+
_, idx, rel, div = max(scores)
|
| 78 |
+
selected.append(doc_indices[idx])
|
| 79 |
+
sel_idx.append(idx)
|
| 80 |
+
mmr_debug.append({
|
| 81 |
+
"doc_index": doc_indices[idx],
|
| 82 |
+
"relevance": float(rel),
|
| 83 |
+
"diversity_penalty": float(div),
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
return selected, mmr_debug
|
| 87 |
+
|
| 88 |
+
# ------------------ MMR (rerun with cached embs) ------------------
|
| 89 |
+
def mmr_from_embs(query_emb, doc_indices, embs, k=10, lambda_param=0.7):
|
| 90 |
+
"""Same as mmr() but uses pre-fetched embeddings β for fast lambda slider reruns."""
|
| 91 |
+
embs_n = [e / np.linalg.norm(e) for e in embs]
|
| 92 |
+
query_emb = query_emb / np.linalg.norm(query_emb)
|
| 93 |
+
sims = [np.dot(query_emb, e) for e in embs_n]
|
| 94 |
+
|
| 95 |
+
best_idx = int(np.argmax(sims))
|
| 96 |
+
selected = [doc_indices[best_idx]]
|
| 97 |
+
sel_idx = [best_idx]
|
| 98 |
+
|
| 99 |
+
while len(selected) < min(k, len(doc_indices)):
|
| 100 |
+
scores = [
|
| 101 |
+
(lambda_param * sims[i] - (1 - lambda_param) * max(np.dot(embs_n[i], embs_n[j]) for j in sel_idx),
|
| 102 |
+
i, sims[i], max(np.dot(embs_n[i], embs_n[j]) for j in sel_idx))
|
| 103 |
+
for i in range(len(doc_indices)) if i not in sel_idx
|
| 104 |
+
]
|
| 105 |
+
if not scores:
|
| 106 |
+
break
|
| 107 |
+
_, idx, rel, div = max(scores)
|
| 108 |
+
selected.append(doc_indices[idx])
|
| 109 |
+
sel_idx.append(idx)
|
| 110 |
+
|
| 111 |
+
return selected
|
| 112 |
+
|
| 113 |
+
# ------------------ RERANK ------------------
|
| 114 |
+
def rerank(query, doc_indices, top_k=7):
|
| 115 |
+
docs = [docs_all[i] for i in doc_indices]
|
| 116 |
+
pairs = [[query, doc] for doc in docs]
|
| 117 |
+
scores = reranker.predict(pairs)
|
| 118 |
+
s_arr = np.array(scores, dtype=float)
|
| 119 |
+
if s_arr.max() != s_arr.min():
|
| 120 |
+
s_arr = (s_arr - s_arr.min()) / (s_arr.max() - s_arr.min())
|
| 121 |
+
else:
|
| 122 |
+
s_arr = np.ones_like(s_arr)
|
| 123 |
+
scored = sorted(zip(doc_indices, s_arr.tolist()), key=lambda x: x[1], reverse=True)
|
| 124 |
+
return scored[:top_k], scored
|
| 125 |
+
|
| 126 |
+
_session = {
|
| 127 |
+
"query_emb": None, # np.ndarray
|
| 128 |
+
"doc_indices": None, # list[int]
|
| 129 |
+
"embs": None, # list[np.ndarray] β raw (not normalized)
|
| 130 |
+
"sims": None, # list[float] query-doc cosine sims
|
| 131 |
+
"umap_coords": None, # list[[x,y]] β computed once per query
|
| 132 |
+
"sim_matrix": None, # NxN similarity matrix between candidates
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
def _compute_umap(embs_norm):
|
| 136 |
+
try:
|
| 137 |
+
import umap
|
| 138 |
+
n = len(embs_norm)
|
| 139 |
+
n_neighbors = min(5, n - 1)
|
| 140 |
+
reducer = umap.UMAP(n_components=2, n_neighbors=n_neighbors,
|
| 141 |
+
min_dist=0.1, random_state=42, verbose=False)
|
| 142 |
+
coords = reducer.fit_transform(np.array(embs_norm))
|
| 143 |
+
for dim in range(2):
|
| 144 |
+
mn, mx = coords[:, dim].min(), coords[:, dim].max()
|
| 145 |
+
if mx != mn:
|
| 146 |
+
coords[:, dim] = (coords[:, dim] - mn) / (mx - mn)
|
| 147 |
+
return coords.tolist()
|
| 148 |
+
except Exception as e:
|
| 149 |
+
print(f"[UMAP] Failed: {e}")
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
def _compute_sim_matrix(embs_norm):
|
| 153 |
+
mat = np.array(embs_norm)
|
| 154 |
+
sim = mat @ mat.T
|
| 155 |
+
return np.clip(sim, -1, 1).tolist()
|
| 156 |
+
|
| 157 |
+
RERANK_THRESHOLD = 0.3
|
| 158 |
+
HYBRID_ALPHA = 0.7
|
| 159 |
+
|
| 160 |
+
def retrieve(query, top_k=7):
|
| 161 |
+
print(f"\nπ Query: {query}")
|
| 162 |
+
timings = {}
|
| 163 |
+
|
| 164 |
+
# --- EMBED ---
|
| 165 |
+
t = time.perf_counter()
|
| 166 |
+
query_emb = embed_query(query)
|
| 167 |
+
timings["embed"] = round((time.perf_counter() - t) * 1000)
|
| 168 |
+
|
| 169 |
+
# --- VECTOR SEARCH ---
|
| 170 |
+
t = time.perf_counter()
|
| 171 |
+
results = collection.query(query_embeddings=[query_emb.tolist()], n_results=25)
|
| 172 |
+
vector_ids = results["ids"][0]
|
| 173 |
+
vector_dists = results["distances"][0]
|
| 174 |
+
vector_scores = [1 - d for d in vector_dists]
|
| 175 |
+
vector_indices = [ids_all.index(i) for i in vector_ids]
|
| 176 |
+
timings["vector"] = round((time.perf_counter() - t) * 1000)
|
| 177 |
+
print(f"[Vector Search] Retrieved: {len(vector_indices)} chunks")
|
| 178 |
+
|
| 179 |
+
# --- BM25 ---
|
| 180 |
+
t = time.perf_counter()
|
| 181 |
+
bm25_results = bm25_search(query, top_n=25)
|
| 182 |
+
timings["bm25"] = round((time.perf_counter() - t) * 1000)
|
| 183 |
+
print(f"[BM25] Retrieved: {len(bm25_results)} chunks")
|
| 184 |
+
|
| 185 |
+
# --- HYBRID FUSION ---
|
| 186 |
+
t = time.perf_counter()
|
| 187 |
+
fused = hybrid_fusion(vector_indices, vector_scores, bm25_results, alpha=HYBRID_ALPHA)
|
| 188 |
+
hybrid_indices = [idx for idx, _, _, _ in fused]
|
| 189 |
+
score_lookup = {idx: (vs, bs, hs) for idx, vs, bs, hs in fused}
|
| 190 |
+
timings["hybrid"] = round((time.perf_counter() - t) * 1000)
|
| 191 |
+
print(f"[Hybrid] Fused: {len(hybrid_indices)} chunks")
|
| 192 |
+
|
| 193 |
+
# --- FETCH EMBEDDINGS for MMR + cache ---
|
| 194 |
+
t = time.perf_counter()
|
| 195 |
+
raw_embs = [
|
| 196 |
+
np.array(collection.get(ids=[ids_all[i]], include=["embeddings"])["embeddings"][0])
|
| 197 |
+
for i in hybrid_indices
|
| 198 |
+
]
|
| 199 |
+
embs_norm = [e / np.linalg.norm(e) for e in raw_embs]
|
| 200 |
+
query_emb_n = query_emb / np.linalg.norm(query_emb)
|
| 201 |
+
sims = [float(np.dot(query_emb_n, e)) for e in embs_norm]
|
| 202 |
+
|
| 203 |
+
# --- MMR ---
|
| 204 |
+
mmr_selected = mmr_from_embs(query_emb, hybrid_indices, raw_embs, k=10)
|
| 205 |
+
mmr_debug = []
|
| 206 |
+
timings["mmr"] = round((time.perf_counter() - t) * 1000)
|
| 207 |
+
print(f"[MMR] Selected: {len(mmr_selected)} chunks")
|
| 208 |
+
|
| 209 |
+
# --- CACHE session ---
|
| 210 |
+
umap_coords = _compute_umap(embs_norm)
|
| 211 |
+
sim_matrix = _compute_sim_matrix(embs_norm)
|
| 212 |
+
|
| 213 |
+
_session["query_emb"] = query_emb
|
| 214 |
+
_session["doc_indices"] = hybrid_indices
|
| 215 |
+
_session["embs"] = raw_embs
|
| 216 |
+
_session["sims"] = sims
|
| 217 |
+
_session["umap_coords"] = umap_coords
|
| 218 |
+
_session["sim_matrix"] = sim_matrix
|
| 219 |
+
|
| 220 |
+
# --- RERANK ---
|
| 221 |
+
t = time.perf_counter()
|
| 222 |
+
top_final, full_rerank = rerank(query, mmr_selected, top_k)
|
| 223 |
+
top_final = [(i, score) for i, score in top_final if score >= RERANK_THRESHOLD]
|
| 224 |
+
timings["rerank"] = round((time.perf_counter() - t) * 1000)
|
| 225 |
+
print(f"[Reranker] Selected: {len(top_final)} chunks (threshold: {RERANK_THRESHOLD})")
|
| 226 |
+
|
| 227 |
+
# --- OUTPUT ---
|
| 228 |
+
final = [
|
| 229 |
+
{
|
| 230 |
+
"text": docs_all[i].replace("passage: ", ""),
|
| 231 |
+
"meta": metas_all[i],
|
| 232 |
+
"rerank_score": float(score),
|
| 233 |
+
"vector_score": score_lookup.get(i, (0, 0, 0))[0],
|
| 234 |
+
"bm25_score": score_lookup.get(i, (0, 0, 0))[1],
|
| 235 |
+
"hybrid_score": score_lookup.get(i, (0, 0, 0))[2],
|
| 236 |
+
}
|
| 237 |
+
for i, score in top_final
|
| 238 |
+
]
|
| 239 |
+
|
| 240 |
+
# --- MMR without diversity (pure relevance) ---
|
| 241 |
+
no_mmr_selected = [
|
| 242 |
+
hybrid_indices[i] for i in np.argsort(sims)[::-1][:10]
|
| 243 |
+
]
|
| 244 |
+
|
| 245 |
+
debug_info = {
|
| 246 |
+
"vector_count": len(vector_indices),
|
| 247 |
+
"bm25_count": len(bm25_results),
|
| 248 |
+
"hybrid_count": len(hybrid_indices),
|
| 249 |
+
"mmr_count": len(mmr_selected),
|
| 250 |
+
"rerank_count": len(top_final),
|
| 251 |
+
"mmr_details": mmr_debug,
|
| 252 |
+
"mmr_selected": mmr_selected,
|
| 253 |
+
"no_mmr_selected": no_mmr_selected,
|
| 254 |
+
"rerank_full": full_rerank,
|
| 255 |
+
"score_lookup": {str(k): v for k, v in score_lookup.items()},
|
| 256 |
+
"timings": timings,
|
| 257 |
+
"umap_coords": umap_coords,
|
| 258 |
+
"sim_matrix": sim_matrix,
|
| 259 |
+
"doc_indices": hybrid_indices,
|
| 260 |
+
"sims": sims,
|
| 261 |
+
"doc_previews": [docs_all[i][:80].replace("passage: ", "") for i in hybrid_indices],
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return final, debug_info
|
src/retrieval/test.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from query import retrieve
|
| 2 |
+
|
| 3 |
+
if __name__ == "__main__":
|
| 4 |
+
q = "how to install transformers"
|
| 5 |
+
results, debug = retrieve(q)
|
| 6 |
+
|
| 7 |
+
for i, r in enumerate(results):
|
| 8 |
+
print(f"\n--- Result {i+1} ---")
|
| 9 |
+
print("Score:", r["rerank_score"])
|
| 10 |
+
print(r["text"][:300])
|
| 11 |
+
|
| 12 |
+
print("\n--- DEBUG INFO ---")
|
| 13 |
+
print(debug)
|
static/main.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function idfColor(n) {
|
| 2 |
+
const stops = [
|
| 3 |
+
{ t: 0.0, r: 148, g: 163, b: 184 }, { t: 0.3, r: 99, g: 102, b: 241 },
|
| 4 |
+
{ t: 0.7, r: 139, g: 92, b: 246 }, { t: 1.0, r: 236, g: 72, b: 153 }
|
| 5 |
+
];
|
| 6 |
+
const v = Math.max(0, Math.min(1, n));
|
| 7 |
+
let lo = stops[0], hi = stops[stops.length - 1];
|
| 8 |
+
for (let i = 0; i < stops.length - 1; i++) {
|
| 9 |
+
if (v >= stops[i].t && v <= stops[i + 1].t) { lo = stops[i]; hi = stops[i + 1]; break; }
|
| 10 |
+
}
|
| 11 |
+
const t2 = lo.t === hi.t ? 0 : (v - lo.t) / (hi.t - lo.t);
|
| 12 |
+
return `rgb(${Math.round(lo.r + (hi.r - lo.r) * t2)},${Math.round(lo.g + (hi.g - lo.g) * t2)},${Math.round(lo.b + (hi.b - lo.b) * t2)})`;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function heatmapColor(v) {
|
| 16 |
+
if (v >= 0) { const i = Math.round(v * 255); return `rgb(255,${255 - i},${255 - i})`; }
|
| 17 |
+
const i = Math.round(-v * 255); return `rgb(${255 - i},${255 - i},255)`;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function escHtml(s) {
|
| 21 |
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function renderQueryPanel(d) {
|
| 25 |
+
const container = document.getElementById("query-panel");
|
| 26 |
+
if (!d) { container.innerHTML = ""; return; }
|
| 27 |
+
|
| 28 |
+
const maxIdfTok = d.tokens.reduce((best, t) => t.idf > best.idf ? t : best, d.tokens[0]);
|
| 29 |
+
|
| 30 |
+
const pills = d.tokens.map(tok => {
|
| 31 |
+
const bg = idfColor(tok.idf_normalized);
|
| 32 |
+
const alpha = (0.15 + tok.idf_normalized * 0.85).toFixed(2);
|
| 33 |
+
return `<div class="token-pill">
|
| 34 |
+
<div class="pill-text" style="background:${bg};opacity:${alpha};min-width:32px;text-align:center">${escHtml(tok.token.replace('##', 'Β·'))}</div>
|
| 35 |
+
<div class="pill-id">#${tok.id}</div>
|
| 36 |
+
<div class="pill-idf">idf ${tok.idf.toFixed(2)}</div>
|
| 37 |
+
</div>`;
|
| 38 |
+
}).join("");
|
| 39 |
+
|
| 40 |
+
const cells = d.embedding.map((v, i) =>
|
| 41 |
+
`<div class="heatmap-cell" style="background:${heatmapColor(v)}" title="dim ${i}: ${v.toFixed(3)}"></div>`
|
| 42 |
+
).join("");
|
| 43 |
+
|
| 44 |
+
const cpx = d.stats.complexity, cpxPct = (cpx * 100).toFixed(0);
|
| 45 |
+
const cpxLabel = cpx < 0.33 ? "Simple" : cpx < 0.66 ? "Moderate" : "Complex";
|
| 46 |
+
|
| 47 |
+
const embNote = maxIdfTok
|
| 48 |
+
? `<div class="embed-note"> <b>"${escHtml(maxIdfTok.token)}"</b> has the highest IDF (${maxIdfTok.idf.toFixed(2)}) β it pulls the embedding vector the most and takes maximum retrieval weight.</div>`
|
| 49 |
+
: "";
|
| 50 |
+
|
| 51 |
+
container.innerHTML = `
|
| 52 |
+
<div class="query-panel">
|
| 53 |
+
<div class="panel-title">π Query Analysis</div>
|
| 54 |
+
<div class="token-row">${pills}</div>
|
| 55 |
+
<div class="heatmap-label">Query Embedding β ${d.embedding.length}-dim PCA projection
|
| 56 |
+
<span style="color:#94a3b8;margin-left:8px">β <span style="color:#ef4444">positive</span> β <span style="color:#6366f1">negative</span></span>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="heatmap-strip">${cells}</div>
|
| 59 |
+
${embNote}
|
| 60 |
+
<div class="stats-row">
|
| 61 |
+
<div class="stat-chip"><div class="stat-label">Tokens</div><div class="stat-value">${d.stats.token_count}</div></div>
|
| 62 |
+
<div class="stat-chip"><div class="stat-label">Unique</div><div class="stat-value">${d.stats.unique_tokens}</div></div>
|
| 63 |
+
<div class="stat-chip"><div class="stat-label">Avg IDF</div><div class="stat-value">${d.stats.avg_idf}</div></div>
|
| 64 |
+
<div class="complexity-wrap">
|
| 65 |
+
<div class="stat-label">Complexity β ${cpxLabel} (${cpxPct}%)</div>
|
| 66 |
+
<div class="complexity-bar-bg"><div class="complexity-bar-fill" style="width:${cpxPct}%"></div></div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>`;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
const STAGES = [
|
| 74 |
+
{ key: "embed", label: "Embed", cls: "seg-embed" },
|
| 75 |
+
{ key: "vector", label: "Vector", cls: "seg-vector" },
|
| 76 |
+
{ key: "bm25", label: "BM25", cls: "seg-bm25" },
|
| 77 |
+
{ key: "hybrid", label: "Hybrid", cls: "seg-hybrid" },
|
| 78 |
+
{ key: "mmr", label: "MMR", cls: "seg-mmr" },
|
| 79 |
+
{ key: "rerank", label: "Rerank", cls: "seg-rerank" },
|
| 80 |
+
{ key: "llm", label: "LLM", cls: "seg-llm" },
|
| 81 |
+
];
|
| 82 |
+
const SEG_COLORS = {
|
| 83 |
+
"seg-embed": "#6366f1", "seg-vector": "#0ea5e9", "seg-bm25": "#10b981",
|
| 84 |
+
"seg-hybrid": "#f59e0b", "seg-mmr": "#8b5cf6", "seg-rerank": "#ef4444", "seg-llm": "#64748b"
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
function renderLatencyPanel(timings) {
|
| 88 |
+
const container = document.getElementById("latency-panel");
|
| 89 |
+
if (!timings) { container.innerHTML = ""; return; }
|
| 90 |
+
const total = timings.total || STAGES.reduce((s, st) => s + (timings[st.key] || 0), 0);
|
| 91 |
+
|
| 92 |
+
const segments = STAGES.map((s, idx) => {
|
| 93 |
+
const ms = timings[s.key] || 0, pct = total > 0 ? (ms / total) * 100 : 0;
|
| 94 |
+
const isLast = idx === STAGES.length - 1;
|
| 95 |
+
const inner = pct > 5 ? ms + "ms" : "";
|
| 96 |
+
return `<div class="latency-segment ${s.cls}" style="${isLast ? 'flex:1' : 'width:' + pct.toFixed(2) + '%'}"
|
| 97 |
+
title="${s.label}: ${ms}ms (${pct.toFixed(1)}%)">${inner}</div>`;
|
| 98 |
+
}).join("");
|
| 99 |
+
|
| 100 |
+
const smallLabels = STAGES.map(s => {
|
| 101 |
+
const ms = timings[s.key] || 0, pct = total > 0 ? (ms / total) * 100 : 0;
|
| 102 |
+
if (ms === 0 || pct > 5) return "";
|
| 103 |
+
return `<span class="latency-small-label" style="color:${SEG_COLORS[s.cls]}">${s.label} ${ms}ms</span>`;
|
| 104 |
+
}).filter(Boolean).join("");
|
| 105 |
+
const smallRow = smallLabels ? `<div class="latency-small-row">${smallLabels}</div>` : "";
|
| 106 |
+
|
| 107 |
+
const chips = STAGES.map(s => {
|
| 108 |
+
const ms = timings[s.key] || 0, pct = total > 0 ? ((ms / total) * 100).toFixed(1) : "0.0";
|
| 109 |
+
return `<div class="latency-chip">
|
| 110 |
+
<div class="chip-dot" style="background:${SEG_COLORS[s.cls]}"></div>
|
| 111 |
+
${s.label} <span class="chip-time">${ms}ms</span>
|
| 112 |
+
<span style="color:#94a3b8">${pct}%</span></div>`;
|
| 113 |
+
}).join("");
|
| 114 |
+
|
| 115 |
+
const slowest = STAGES.reduce((b, s) => (timings[s.key] || 0) > (timings[b.key] || 0) ? s : b, STAGES[0]);
|
| 116 |
+
const slowestMs = timings[slowest.key] || 0;
|
| 117 |
+
const slowestPct = total > 0 ? Math.round((slowestMs / total) * 100) : 0;
|
| 118 |
+
const slowestCallout = slowestMs > 0
|
| 119 |
+
? `<div class="slowest-callout"> <b>${slowest.label}</b> is taking the most time β <b>${slowestMs}ms</b> (${slowestPct}% of total)</div>`
|
| 120 |
+
: "";
|
| 121 |
+
|
| 122 |
+
const retrieval = STAGES.filter(s => s.key !== "llm");
|
| 123 |
+
const bottleneck = retrieval.reduce((b, s) => (timings[s.key] || 0) > (timings[b.key] || 0) ? s : b, retrieval[0]);
|
| 124 |
+
const bPct = total > 0 ? Math.round((timings[bottleneck.key] / total) * 100) : 0;
|
| 125 |
+
const retrievalCallout = bPct > 15 && slowest.key !== "llm"
|
| 126 |
+
? `<div class="bottleneck-callout">β Retrieval bottleneck: <b>${bottleneck.label}</b> taking <b>${bPct}%</b> of total (${timings[bottleneck.key]}ms)</div>`
|
| 127 |
+
: "";
|
| 128 |
+
const llmPct = total > 0 ? Math.round(((timings.llm || 0) / total) * 100) : 0;
|
| 129 |
+
const llmCallout = llmPct > 50
|
| 130 |
+
? `<div class="llm-callout">π¬ LLM generation is <b>${llmPct}%</b> of total time (${timings.llm}ms) β retrieval is fast β
</div>`
|
| 131 |
+
: "";
|
| 132 |
+
|
| 133 |
+
container.innerHTML = `
|
| 134 |
+
<div class="latency-panel">
|
| 135 |
+
<div class="panel-title">β± Latency Timeline</div>
|
| 136 |
+
<div class="latency-bar">${segments}</div>
|
| 137 |
+
${smallRow}
|
| 138 |
+
<div class="latency-meta">${chips}<div class="latency-total">Total: ${total}ms</div></div>
|
| 139 |
+
${slowestCallout}${retrievalCallout}${llmCallout}
|
| 140 |
+
</div>`;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
function scoreColor(s) {
|
| 145 |
+
if (s >= 0.7) return { bg: "#dcfce7", color: "#166534" };
|
| 146 |
+
if (s >= 0.4) return { bg: "#fef9c3", color: "#854d0e" };
|
| 147 |
+
return { bg: "#fee2e2", color: "#991b1b" };
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function renderChunksPanel(chunks, scores, raw_scores, sources) {
|
| 151 |
+
const container = document.getElementById("chunks-panel");
|
| 152 |
+
let html = `<h4 style="margin-top:28px;margin-bottom:12px;color:#1e293b">π Context Chunks</h4>`;
|
| 153 |
+
|
| 154 |
+
const order = scores
|
| 155 |
+
.map((s, i) => ({ i, s }))
|
| 156 |
+
.sort((a, b) => b.s - a.s)
|
| 157 |
+
.map(x => x.i);
|
| 158 |
+
|
| 159 |
+
order.forEach(i => {
|
| 160 |
+
const score = scores[i];
|
| 161 |
+
const sc = scoreColor(score);
|
| 162 |
+
const src = sources[i] || {};
|
| 163 |
+
const url = src.url || "";
|
| 164 |
+
const title = src.title || url;
|
| 165 |
+
|
| 166 |
+
let displayUrl = url;
|
| 167 |
+
try {
|
| 168 |
+
const u = new URL(url);
|
| 169 |
+
displayUrl = u.hostname + u.pathname.replace(/\/$/, "");
|
| 170 |
+
} catch (_) { }
|
| 171 |
+
|
| 172 |
+
const sourceTag = url
|
| 173 |
+
? `<a class="chunk-source-link" href="${escHtml(url)}" target="_blank" title="${escHtml(title)}">
|
| 174 |
+
<span class="chunk-source-icon">π</span>${escHtml(displayUrl)}
|
| 175 |
+
</a>`
|
| 176 |
+
: "";
|
| 177 |
+
|
| 178 |
+
html += `
|
| 179 |
+
<div class="chunk-card">
|
| 180 |
+
<div class="chunk-card-header">
|
| 181 |
+
<span class="chunk-card-title">Chunk ${i + 1}</span>
|
| 182 |
+
<span class="chunk-score-badge" style="background:${sc.bg};color:${sc.color}">
|
| 183 |
+
score ${score.toFixed(3)}
|
| 184 |
+
</span>
|
| 185 |
+
${sourceTag}
|
| 186 |
+
</div>
|
| 187 |
+
<div class="chunk-card-body">
|
| 188 |
+
<div class="chunk-content">${marked.parse(chunks[i])}</div>
|
| 189 |
+
</div>
|
| 190 |
+
</div>`;
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
container.innerHTML = html;
|
| 194 |
+
hljs.highlightAll();
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
async function ask() {
|
| 199 |
+
const query = document.getElementById("query").value.trim();
|
| 200 |
+
if (!query) return;
|
| 201 |
+
|
| 202 |
+
document.getElementById("answer").innerHTML = "<p style='color:#94a3b8'>Thinking...</p>";
|
| 203 |
+
document.getElementById("latency-panel").innerHTML = "";
|
| 204 |
+
document.getElementById("query-panel").innerHTML = "<p style='font-size:12px;color:#94a3b8;padding:12px 0'>Analyzing query...</p>";
|
| 205 |
+
document.getElementById("retrieval-panel").innerHTML = "";
|
| 206 |
+
document.getElementById("mmr-panel").innerHTML = "";
|
| 207 |
+
document.getElementById("chunks-panel").innerHTML = "";
|
| 208 |
+
|
| 209 |
+
const [askRes, analyzeRes] = await Promise.all([
|
| 210 |
+
fetch("/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) }),
|
| 211 |
+
fetch("/analyze_query", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) })
|
| 212 |
+
]);
|
| 213 |
+
const [data, analysis] = await Promise.all([askRes.json(), analyzeRes.json()]);
|
| 214 |
+
|
| 215 |
+
// 1. Answer
|
| 216 |
+
document.getElementById("answer").innerHTML =
|
| 217 |
+
`<h3>Answer:</h3><div class="answer-box">${marked.parse(data.answer)}</div>`;
|
| 218 |
+
hljs.highlightAll();
|
| 219 |
+
|
| 220 |
+
// 2. Latency
|
| 221 |
+
renderLatencyPanel(data.timings);
|
| 222 |
+
|
| 223 |
+
// 3. Query Analysis
|
| 224 |
+
renderQueryPanel(analysis);
|
| 225 |
+
|
| 226 |
+
// 4. Retrieval Comparison
|
| 227 |
+
renderRetrievalTable(data.comparison_rows);
|
| 228 |
+
|
| 229 |
+
// 5. MMR
|
| 230 |
+
renderMMRPanel(data.mmr_data);
|
| 231 |
+
|
| 232 |
+
// 6. Chunks
|
| 233 |
+
renderChunksPanel(data.chunks, data.scores, data.raw_scores, data.sources);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 237 |
+
document.getElementById("query").addEventListener("keydown", e => {
|
| 238 |
+
if (e.key === "Enter") ask();
|
| 239 |
+
});
|
| 240 |
+
});
|
static/mmr.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let _p3 = {
|
| 2 |
+
coords: null, // [[x,y], ...] UMAP coords, local index
|
| 3 |
+
sims: null, // [float, ...] query-doc similarities
|
| 4 |
+
previews: null, // [str, ...] chunk previews
|
| 5 |
+
docIndices: null, // [int, ...] global doc indices
|
| 6 |
+
mmrSelected: null, // set of global doc indices β current MMR selection
|
| 7 |
+
noMmrSelected: null, // set β pure relevance top-10
|
| 8 |
+
lambda: 0.7,
|
| 9 |
+
sliderTimeout: null,
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
function renderMMRPanel(mmrData) {
|
| 13 |
+
const container = document.getElementById("mmr-panel");
|
| 14 |
+
if (!mmrData || !mmrData.umap_coords) {
|
| 15 |
+
container.innerHTML = `<div class="p3-panel"><div class="panel-title">π΅ MMR Visualization</div><p style="color:#94a3b8;font-size:13px">UMAP unavailable β install umap-learn</p></div>`;
|
| 16 |
+
return;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
_p3.coords = mmrData.umap_coords;
|
| 20 |
+
_p3.sims = mmrData.sims;
|
| 21 |
+
_p3.previews = mmrData.doc_previews;
|
| 22 |
+
_p3.docIndices = mmrData.doc_indices;
|
| 23 |
+
_p3.mmrSelected = new Set(mmrData.mmr_selected);
|
| 24 |
+
_p3.noMmrSelected = new Set(mmrData.no_mmr_selected);
|
| 25 |
+
_p3.lambda = 0.7;
|
| 26 |
+
|
| 27 |
+
container.innerHTML = `
|
| 28 |
+
<div class="p3-panel">
|
| 29 |
+
<div class="panel-title">π΅ MMR Visualization</div>
|
| 30 |
+
|
| 31 |
+
<!-- Lambda slider -->
|
| 32 |
+
<div class="p3-slider-row">
|
| 33 |
+
<span class="p3-slider-label">Ξ» = <b id="p3-lambda-val">0.70</b></span>
|
| 34 |
+
<input id="p3-lambda" type="range" min="0" max="1" step="0.05" value="0.7" class="p3-slider">
|
| 35 |
+
<span class="p3-slider-hint">β diversity relevance β</span>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- UMAP + side-by-side row -->
|
| 39 |
+
<div class="p3-top-row">
|
| 40 |
+
|
| 41 |
+
<!-- UMAP scatter -->
|
| 42 |
+
<div class="p3-scatter-wrap">
|
| 43 |
+
<div class="p3-section-label">UMAP Scatter β candidate chunks</div>
|
| 44 |
+
<canvas id="p3-umap" width="340" height="280" class="p3-canvas"></canvas>
|
| 45 |
+
<div class="p3-scatter-legend">
|
| 46 |
+
<span><span class="p3-dot-lg" style="background:#10b981"></span> MMR selected</span>
|
| 47 |
+
<span><span class="p3-dot-lg" style="background:#ef4444"></span> Rejected</span>
|
| 48 |
+
</div>
|
| 49 |
+
<div id="p3-tooltip" class="p3-scatter-tooltip"></div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- Side by side: No MMR vs MMR -->
|
| 53 |
+
<div class="p3-side-wrap">
|
| 54 |
+
<div class="p3-side-col">
|
| 55 |
+
<div class="p3-section-label" style="color:#f59e0b">Without MMR (top relevance)</div>
|
| 56 |
+
<div id="p3-no-mmr-list" class="p3-chunk-list"></div>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="p3-side-col">
|
| 59 |
+
<div class="p3-section-label" style="color:#10b981">With MMR (Ξ»=<span id="p3-side-lambda">0.70</span>)</div>
|
| 60 |
+
<div id="p3-mmr-list" class="p3-chunk-list"></div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<!-- Similarity matrix -->
|
| 67 |
+
<div class="p3-section-label" style="margin-top:18px">Similarity Matrix β top 10 MMR candidates</div>
|
| 68 |
+
<div class="p3-matrix-wrap">
|
| 69 |
+
<canvas id="p3-simmatrix" class="p3-canvas"></canvas>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
</div>`;
|
| 73 |
+
|
| 74 |
+
_drawAll();
|
| 75 |
+
_drawSideBySide();
|
| 76 |
+
_drawSimMatrix(mmrData.sim_matrix);
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
document.getElementById("p3-lambda").addEventListener("input", function () {
|
| 80 |
+
_p3.lambda = parseFloat(this.value);
|
| 81 |
+
document.getElementById("p3-lambda-val").textContent = _p3.lambda.toFixed(2);
|
| 82 |
+
document.getElementById("p3-side-lambda").textContent = _p3.lambda.toFixed(2);
|
| 83 |
+
clearTimeout(_p3.sliderTimeout);
|
| 84 |
+
_p3.sliderTimeout = setTimeout(_rerrunMMR, 300);
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function _drawAll() {
|
| 89 |
+
const canvas = document.getElementById("p3-umap");
|
| 90 |
+
if (!canvas) return;
|
| 91 |
+
const ctx = canvas.getContext("2d");
|
| 92 |
+
const W = canvas.width, H = canvas.height;
|
| 93 |
+
const PAD = 24;
|
| 94 |
+
|
| 95 |
+
ctx.clearRect(0, 0, W, H);
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
_p3.coords.forEach(([x, y], i) => {
|
| 99 |
+
const cx = PAD + x * (W - PAD * 2);
|
| 100 |
+
const cy = PAD + y * (H - PAD * 2);
|
| 101 |
+
const isSelected = _p3.mmrSelected.has(_p3.docIndices[i]);
|
| 102 |
+
const sim = _p3.sims[i] || 0;
|
| 103 |
+
|
| 104 |
+
if (isSelected) {
|
| 105 |
+
ctx.beginPath();
|
| 106 |
+
ctx.arc(cx, cy, 11, 0, Math.PI * 2);
|
| 107 |
+
ctx.fillStyle = "rgba(16,185,129,0.18)";
|
| 108 |
+
ctx.fill();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
ctx.beginPath();
|
| 112 |
+
ctx.arc(cx, cy, isSelected ? 8 : 5, 0, Math.PI * 2);
|
| 113 |
+
ctx.fillStyle = isSelected ? "#10b981" : "#ef4444";
|
| 114 |
+
ctx.globalAlpha = isSelected ? 1 : 0.55 + sim * 0.45;
|
| 115 |
+
ctx.fill();
|
| 116 |
+
ctx.globalAlpha = 1;
|
| 117 |
+
|
| 118 |
+
if (isSelected) {
|
| 119 |
+
ctx.font = "bold 9px Arial";
|
| 120 |
+
ctx.fillStyle = "#065f46";
|
| 121 |
+
ctx.fillText(i, cx + 10, cy + 4);
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
canvas.onmousemove = (e) => {
|
| 126 |
+
const rect = canvas.getBoundingClientRect();
|
| 127 |
+
const mx = e.clientX - rect.left;
|
| 128 |
+
const my = e.clientY - rect.top;
|
| 129 |
+
const PAD = 24;
|
| 130 |
+
|
| 131 |
+
let hit = null;
|
| 132 |
+
_p3.coords.forEach(([x, y], i) => {
|
| 133 |
+
const cx = PAD + x * (W - PAD * 2);
|
| 134 |
+
const cy = PAD + y * (H - PAD * 2);
|
| 135 |
+
if (Math.hypot(mx - cx, my - cy) < 10) hit = i;
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
const tip = document.getElementById("p3-tooltip");
|
| 139 |
+
if (hit !== null) {
|
| 140 |
+
const isSelected = _p3.mmrSelected.has(_p3.docIndices[hit]);
|
| 141 |
+
tip.innerHTML = `
|
| 142 |
+
<b>${isSelected ? "β
Selected" : "β Rejected"}</b> β chunk ${hit}<br>
|
| 143 |
+
sim: ${(_p3.sims[hit] || 0).toFixed(3)}<br>
|
| 144 |
+
<span style="color:#cbd5e1">${escP3(_p3.previews[hit] || "")}</span>`;
|
| 145 |
+
tip.style.display = "block";
|
| 146 |
+
tip.style.left = (mx + 12) + "px";
|
| 147 |
+
tip.style.top = (my + 12) + "px";
|
| 148 |
+
} else {
|
| 149 |
+
tip.style.display = "none";
|
| 150 |
+
}
|
| 151 |
+
};
|
| 152 |
+
canvas.onmouseleave = () => {
|
| 153 |
+
const tip = document.getElementById("p3-tooltip");
|
| 154 |
+
if (tip) tip.style.display = "none";
|
| 155 |
+
};
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function _drawSideBySide() {
|
| 159 |
+
const noMmrEl = document.getElementById("p3-no-mmr-list");
|
| 160 |
+
const mmrEl = document.getElementById("p3-mmr-list");
|
| 161 |
+
if (!noMmrEl || !mmrEl) return;
|
| 162 |
+
|
| 163 |
+
function makeList(selectedSet) {
|
| 164 |
+
return _p3.docIndices
|
| 165 |
+
.map((docIdx, i) => ({ docIdx, i, sim: _p3.sims[i] || 0, preview: _p3.previews[i] || "" }))
|
| 166 |
+
.filter(({ docIdx }) => selectedSet.has(docIdx))
|
| 167 |
+
.sort((a, b) => b.sim - a.sim)
|
| 168 |
+
.map(({ i, sim, preview }) => `
|
| 169 |
+
<div class="p3-side-item">
|
| 170 |
+
<div class="p3-side-sim">${sim.toFixed(3)}</div>
|
| 171 |
+
<div class="p3-side-text">${escP3(preview)}β¦</div>
|
| 172 |
+
</div>`)
|
| 173 |
+
.join("");
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
noMmrEl.innerHTML = makeList(_p3.noMmrSelected);
|
| 177 |
+
mmrEl.innerHTML = makeList(_p3.mmrSelected);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function _drawSimMatrix(simMatrix) {
|
| 181 |
+
if (!simMatrix) return;
|
| 182 |
+
const localSelected = _p3.docIndices
|
| 183 |
+
.map((d, i) => ({ d, i }))
|
| 184 |
+
.filter(({ d }) => _p3.mmrSelected.has(d))
|
| 185 |
+
.map(({ i }) => i)
|
| 186 |
+
.slice(0, 10);
|
| 187 |
+
|
| 188 |
+
const N = localSelected.length;
|
| 189 |
+
const CELL = 32;
|
| 190 |
+
const canvas = document.getElementById("p3-simmatrix");
|
| 191 |
+
if (!canvas || N === 0) return;
|
| 192 |
+
|
| 193 |
+
canvas.width = N * CELL;
|
| 194 |
+
canvas.height = N * CELL;
|
| 195 |
+
const ctx = canvas.getContext("2d");
|
| 196 |
+
|
| 197 |
+
for (let r = 0; r < N; r++) {
|
| 198 |
+
for (let c = 0; c < N; c++) {
|
| 199 |
+
const ri = localSelected[r];
|
| 200 |
+
const ci = localSelected[c];
|
| 201 |
+
const val = (simMatrix[ri] && simMatrix[ri][ci] != null) ? simMatrix[ri][ci] : 0;
|
| 202 |
+
ctx.fillStyle = simHeatColor(val);
|
| 203 |
+
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
|
| 204 |
+
|
| 205 |
+
ctx.font = "9px Arial";
|
| 206 |
+
ctx.fillStyle = val > 0.6 ? "#fff" : "#334155";
|
| 207 |
+
ctx.textAlign = "center";
|
| 208 |
+
ctx.fillText(val.toFixed(2), c * CELL + CELL / 2, r * CELL + CELL / 2 + 3);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
ctx.font = "bold 9px Arial";
|
| 213 |
+
ctx.fillStyle = "#64748b";
|
| 214 |
+
ctx.textAlign = "center";
|
| 215 |
+
for (let i = 0; i < N; i++) {
|
| 216 |
+
ctx.fillText(localSelected[i], i * CELL + CELL / 2, N * CELL + 12);
|
| 217 |
+
ctx.fillText(localSelected[i], -6, i * CELL + CELL / 2 + 3);
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function simHeatColor(v) {
|
| 222 |
+
const t = Math.max(0, Math.min(1, v));
|
| 223 |
+
const r = Math.round(255 - t * (255 - 99));
|
| 224 |
+
const g = Math.round(255 - t * (255 - 102));
|
| 225 |
+
const b = Math.round(255 - t * (255 - 241));
|
| 226 |
+
return `rgb(${r},${g},${b})`;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
async function _rerrunMMR() {
|
| 230 |
+
const res = await fetch("/mmr_rerun", {
|
| 231 |
+
method: "POST",
|
| 232 |
+
headers: { "Content-Type": "application/json" },
|
| 233 |
+
body: JSON.stringify({ lambda: _p3.lambda })
|
| 234 |
+
});
|
| 235 |
+
const data = await res.json();
|
| 236 |
+
if (data.error) { console.warn("MMR rerun:", data.error); return; }
|
| 237 |
+
|
| 238 |
+
_p3.mmrSelected = new Set(data.selected_indices);
|
| 239 |
+
_drawAll();
|
| 240 |
+
_drawSideBySide();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function escP3(s) {
|
| 244 |
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 245 |
+
}
|
static/rerank.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Store full texts in a module-level array β avoids HTML attribute escaping bugs
|
| 2 |
+
let _p2FullTexts = [];
|
| 3 |
+
|
| 4 |
+
function scoreBar(val, color) {
|
| 5 |
+
const pct = (val * 100).toFixed(0);
|
| 6 |
+
return `
|
| 7 |
+
<div class="p2-score-wrap" title="${val.toFixed(4)}">
|
| 8 |
+
<div class="p2-bar-bg">
|
| 9 |
+
<div class="p2-bar-fill" style="width:${pct}%;background:${color}"></div>
|
| 10 |
+
</div>
|
| 11 |
+
<span class="p2-score-num">${val.toFixed(3)}</span>
|
| 12 |
+
</div>`;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function rankArrow(delta) {
|
| 16 |
+
if (delta > 0) return `<span class="p2-arrow up" title="Moved up ${delta}">β²${delta}</span>`;
|
| 17 |
+
if (delta < 0) return `<span class="p2-arrow down" title="Moved down ${Math.abs(delta)}">βΌ${Math.abs(delta)}</span>`;
|
| 18 |
+
return `<span class="p2-arrow flat" title="No change">β</span>`;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function rowColor(rerank, passed) {
|
| 22 |
+
if (!passed) return "rgba(239,68,68,0.06)";
|
| 23 |
+
if (rerank >= 0.7) return "rgba(16,185,129,0.08)";
|
| 24 |
+
if (rerank >= 0.4) return "rgba(245,158,11,0.07)";
|
| 25 |
+
return "rgba(99,102,241,0.05)";
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function borderColor(rerank, passed) {
|
| 29 |
+
if (!passed) return "#fca5a5";
|
| 30 |
+
if (rerank >= 0.7) return "#6ee7b7";
|
| 31 |
+
if (rerank >= 0.4) return "#fcd34d";
|
| 32 |
+
return "#c7d2fe";
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function escHtml(s) {
|
| 36 |
+
return String(s)
|
| 37 |
+
.replace(/&/g, "&")
|
| 38 |
+
.replace(/</g, "<")
|
| 39 |
+
.replace(/>/g, ">")
|
| 40 |
+
.replace(/"/g, """)
|
| 41 |
+
.replace(/'/g, "'");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function renderRetrievalTable(rows) {
|
| 45 |
+
const container = document.getElementById("retrieval-panel");
|
| 46 |
+
if (!rows || !rows.length) { container.innerHTML = ""; return; }
|
| 47 |
+
|
| 48 |
+
_p2FullTexts = rows.map(r => r.text_full || "");
|
| 49 |
+
|
| 50 |
+
const topRow = [...rows].sort((a, b) => b.rerank_score - a.rerank_score)[0];
|
| 51 |
+
|
| 52 |
+
const tableRows = rows.map((r, i) => {
|
| 53 |
+
const isTop = r.idx === topRow.idx;
|
| 54 |
+
const bg = rowColor(r.rerank_score, r.passed_threshold);
|
| 55 |
+
const border = borderColor(r.rerank_score, r.passed_threshold);
|
| 56 |
+
const dropped = !r.passed_threshold ? `<span class="p2-dropped">DROPPED</span>` : "";
|
| 57 |
+
const winner = isTop ? `<span class="p2-winner" title="Highest combined rerank score">β
top</span>` : "";
|
| 58 |
+
const preview = escHtml((r.text_preview || "").trim());
|
| 59 |
+
|
| 60 |
+
return `
|
| 61 |
+
<tr class="p2-row" style="background:${bg};border-left:3px solid ${border}">
|
| 62 |
+
<td class="p2-td p2-rank">#${r.pre_rank + 1}</td>
|
| 63 |
+
<td class="p2-td p2-rank">#${r.post_rank + 1} ${winner}${dropped}</td>
|
| 64 |
+
<td class="p2-td p2-preview-cell" data-idx="${i}">
|
| 65 |
+
<div class="p2-preview">${preview}</div>
|
| 66 |
+
<div class="p2-tooltip"></div>
|
| 67 |
+
</td>
|
| 68 |
+
<td class="p2-td">${scoreBar(r.vector_score, "#0ea5e9")}</td>
|
| 69 |
+
<td class="p2-td">${scoreBar(r.bm25_score, "#10b981")}</td>
|
| 70 |
+
<td class="p2-td">${scoreBar(r.hybrid_score, "#f59e0b")}</td>
|
| 71 |
+
<td class="p2-td">${scoreBar(r.rerank_score, r.passed_threshold ? "#6366f1" : "#ef4444")}</td>
|
| 72 |
+
<td class="p2-td p2-delta">${rankArrow(r.rank_delta)}</td>
|
| 73 |
+
</tr>`;
|
| 74 |
+
}).join("");
|
| 75 |
+
|
| 76 |
+
container.innerHTML = `
|
| 77 |
+
<div class="p2-panel">
|
| 78 |
+
<div class="panel-title">π Retrieval Comparison</div>
|
| 79 |
+
<div class="p2-legend">
|
| 80 |
+
<span><span class="p2-dot" style="background:#0ea5e9"></span>Vector</span>
|
| 81 |
+
<span><span class="p2-dot" style="background:#10b981"></span>BM25</span>
|
| 82 |
+
<span><span class="p2-dot" style="background:#f59e0b"></span>Hybrid</span>
|
| 83 |
+
<span><span class="p2-dot" style="background:#6366f1"></span>Reranker</span>
|
| 84 |
+
<span class="p2-legend-sep">|</span>
|
| 85 |
+
<span><span class="p2-dot" style="background:#6ee7b7;border:1px solid #10b981"></span>passed</span>
|
| 86 |
+
<span><span class="p2-dot" style="background:#fca5a5;border:1px solid #ef4444"></span>dropped</span>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="p2-table-wrap">
|
| 89 |
+
<table class="p2-table">
|
| 90 |
+
<thead>
|
| 91 |
+
<tr>
|
| 92 |
+
<th class="p2-th">Original Rank</th>
|
| 93 |
+
<th class="p2-th">Rerank</th>
|
| 94 |
+
<th class="p2-th">Chunk</th>
|
| 95 |
+
<th class="p2-th">Vector</th>
|
| 96 |
+
<th class="p2-th">BM25</th>
|
| 97 |
+
<th class="p2-th">Hybrid</th>
|
| 98 |
+
<th class="p2-th">Reranker</th>
|
| 99 |
+
<th class="p2-th">Ξ</th>
|
| 100 |
+
</tr>
|
| 101 |
+
</thead>
|
| 102 |
+
<tbody>${tableRows}</tbody>
|
| 103 |
+
</table>
|
| 104 |
+
</div>
|
| 105 |
+
</div>`;
|
| 106 |
+
|
| 107 |
+
container.querySelectorAll(".p2-preview-cell").forEach(cell => {
|
| 108 |
+
const tooltip = cell.querySelector(".p2-tooltip");
|
| 109 |
+
const idx = parseInt(cell.dataset.idx);
|
| 110 |
+
tooltip.textContent = _p2FullTexts[idx] || "";
|
| 111 |
+
|
| 112 |
+
cell.addEventListener("mouseenter", () => tooltip.classList.add("visible"));
|
| 113 |
+
cell.addEventListener("mouseleave", () => tooltip.classList.remove("visible"));
|
| 114 |
+
cell.addEventListener("mousemove", e => {
|
| 115 |
+
const spaceBelow = window.innerHeight - e.clientY;
|
| 116 |
+
tooltip.style.left = "0px";
|
| 117 |
+
if (spaceBelow < 220) {
|
| 118 |
+
tooltip.style.bottom = "100%";
|
| 119 |
+
tooltip.style.top = "auto";
|
| 120 |
+
} else {
|
| 121 |
+
tooltip.style.top = "100%";
|
| 122 |
+
tooltip.style.bottom = "auto";
|
| 123 |
+
}
|
| 124 |
+
});
|
| 125 |
+
});
|
| 126 |
+
}
|
static/style.css
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
Reset & Base
|
| 3 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
* {
|
| 5 |
+
box-sizing: border-box;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
body {
|
| 9 |
+
font-family: Arial, sans-serif;
|
| 10 |
+
background: #f5f7fa;
|
| 11 |
+
margin: 0;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
p {
|
| 15 |
+
line-height: 1.5;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
Layout
|
| 20 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 21 |
+
.container {
|
| 22 |
+
max-width: 900px;
|
| 23 |
+
margin: 50px auto;
|
| 24 |
+
padding: 25px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
h1 {
|
| 29 |
+
margin-bottom: 25px;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.input-container {
|
| 33 |
+
display: flex;
|
| 34 |
+
gap: 10px;
|
| 35 |
+
justify-content: center;
|
| 36 |
+
margin-bottom: 20px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
input {
|
| 40 |
+
padding: 12px;
|
| 41 |
+
font-size: 16px;
|
| 42 |
+
flex: 1;
|
| 43 |
+
border-radius: 8px;
|
| 44 |
+
border: 1px solid #ccc;
|
| 45 |
+
max-width: 600px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
button {
|
| 49 |
+
padding: 12px 16px;
|
| 50 |
+
font-size: 16px;
|
| 51 |
+
border-radius: 8px;
|
| 52 |
+
border: none;
|
| 53 |
+
background: #007bff;
|
| 54 |
+
color: white;
|
| 55 |
+
cursor: pointer;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
button:hover {
|
| 59 |
+
background: #0056b3;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
Answer box
|
| 64 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 65 |
+
#answer {
|
| 66 |
+
margin-top: 20px;
|
| 67 |
+
width: 100%;
|
| 68 |
+
text-align: left;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.answer-box pre {
|
| 72 |
+
background: #807d7d;
|
| 73 |
+
padding: 14px;
|
| 74 |
+
border-radius: 10px;
|
| 75 |
+
overflow-x: auto;
|
| 76 |
+
margin: 10px 0;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.answer-box pre code {
|
| 80 |
+
background: transparent !important;
|
| 81 |
+
color: #f8f8f2 !important;
|
| 82 |
+
font-family: "Courier New", monospace;
|
| 83 |
+
font-size: 14px;
|
| 84 |
+
display: block;
|
| 85 |
+
white-space: pre;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.answer-box code:not(pre code) {
|
| 89 |
+
background: #eee;
|
| 90 |
+
color: #333;
|
| 91 |
+
padding: 2px 6px;
|
| 92 |
+
border-radius: 4px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.answer-box ul,
|
| 96 |
+
.answer-box ol {
|
| 97 |
+
text-align: left;
|
| 98 |
+
padding-left: 20px;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.query-panel,
|
| 102 |
+
.latency-panel,
|
| 103 |
+
.p2-panel,
|
| 104 |
+
.p3-panel {
|
| 105 |
+
margin-top: 28px;
|
| 106 |
+
border: 1px solid #e2e8f0;
|
| 107 |
+
border-radius: 10px;
|
| 108 |
+
padding: 20px 24px;
|
| 109 |
+
background: #f8fafc;
|
| 110 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.panel-title {
|
| 114 |
+
margin: 0 0 16px;
|
| 115 |
+
font-size: 13px;
|
| 116 |
+
font-weight: 600;
|
| 117 |
+
text-transform: uppercase;
|
| 118 |
+
letter-spacing: 0.08em;
|
| 119 |
+
color: #64748b;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.bottleneck-callout,
|
| 123 |
+
.llm-callout,
|
| 124 |
+
.slowest-callout {
|
| 125 |
+
margin-top: 8px;
|
| 126 |
+
padding: 8px 12px;
|
| 127 |
+
border-left-width: 3px;
|
| 128 |
+
border-left-style: solid;
|
| 129 |
+
border-radius: 4px;
|
| 130 |
+
font-size: 12px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.bottleneck-callout {
|
| 134 |
+
background: #fff7ed;
|
| 135 |
+
border-left-color: #ef4444;
|
| 136 |
+
color: #7c2d12;
|
| 137 |
+
margin-top: 12px;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.bottleneck-callout b {
|
| 141 |
+
color: #dc2626;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.llm-callout {
|
| 145 |
+
background: #f0f9ff;
|
| 146 |
+
border-left-color: #64748b;
|
| 147 |
+
color: #1e3a5f;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.llm-callout b {
|
| 151 |
+
color: #334155;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.slowest-callout {
|
| 155 |
+
background: #fdf4ff;
|
| 156 |
+
border-left-color: #a855f7;
|
| 157 |
+
color: #581c87;
|
| 158 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.slowest-callout b {
|
| 162 |
+
color: #7e22ce;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.p2-dot,
|
| 166 |
+
.p3-dot-lg {
|
| 167 |
+
display: inline-block;
|
| 168 |
+
width: 10px;
|
| 169 |
+
height: 10px;
|
| 170 |
+
margin-right: 4px;
|
| 171 |
+
vertical-align: middle;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.p2-dot {
|
| 175 |
+
border-radius: 3px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.p3-dot-lg {
|
| 179 |
+
border-radius: 50%;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
.p2-winner,
|
| 184 |
+
.p2-dropped {
|
| 185 |
+
display: inline-block;
|
| 186 |
+
margin-left: 5px;
|
| 187 |
+
font-size: 10px;
|
| 188 |
+
border-radius: 4px;
|
| 189 |
+
padding: 1px 5px;
|
| 190 |
+
font-weight: 600;
|
| 191 |
+
border-width: 1px;
|
| 192 |
+
border-style: solid;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.p2-winner {
|
| 196 |
+
background: #fef9c3;
|
| 197 |
+
color: #854d0e;
|
| 198 |
+
border-color: #fde047;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.p2-dropped {
|
| 202 |
+
background: #fee2e2;
|
| 203 |
+
color: #991b1b;
|
| 204 |
+
border-color: #fca5a5;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
.p2-tooltip,
|
| 209 |
+
.p3-scatter-tooltip {
|
| 210 |
+
display: none;
|
| 211 |
+
position: absolute;
|
| 212 |
+
pointer-events: none;
|
| 213 |
+
background: #1e293b;
|
| 214 |
+
color: #e2e8f0;
|
| 215 |
+
border-radius: 6px;
|
| 216 |
+
font-family: Arial, sans-serif;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.p2-tooltip {
|
| 220 |
+
top: 100%;
|
| 221 |
+
left: 0;
|
| 222 |
+
z-index: 100;
|
| 223 |
+
width: 380px;
|
| 224 |
+
max-height: 200px;
|
| 225 |
+
overflow-y: auto;
|
| 226 |
+
font-size: 12px;
|
| 227 |
+
line-height: 1.6;
|
| 228 |
+
padding: 12px 14px;
|
| 229 |
+
border-radius: 8px;
|
| 230 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
| 231 |
+
white-space: pre-wrap;
|
| 232 |
+
word-break: break-word;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.p3-scatter-tooltip {
|
| 236 |
+
font-size: 11px;
|
| 237 |
+
line-height: 1.5;
|
| 238 |
+
padding: 8px 10px;
|
| 239 |
+
max-width: 220px;
|
| 240 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
| 241 |
+
z-index: 50;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.p2-tooltip.visible,
|
| 245 |
+
.p3-scatter-tooltip.visible {
|
| 246 |
+
display: block;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.p2-legend,
|
| 250 |
+
.p3-scatter-legend {
|
| 251 |
+
display: flex;
|
| 252 |
+
flex-wrap: wrap;
|
| 253 |
+
gap: 14px;
|
| 254 |
+
align-items: center;
|
| 255 |
+
font-size: 12px;
|
| 256 |
+
color: #475569;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.p2-legend {
|
| 260 |
+
margin-bottom: 14px;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.p3-scatter-legend {
|
| 264 |
+
margin-top: 8px;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.p2-legend-sep {
|
| 268 |
+
color: #cbd5e1;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.p2-bar-bg,
|
| 272 |
+
.complexity-bar-bg {
|
| 273 |
+
height: 6px;
|
| 274 |
+
background: #e2e8f0;
|
| 275 |
+
border-radius: 3px;
|
| 276 |
+
overflow: hidden;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.p2-bar-fill,
|
| 280 |
+
.complexity-bar-fill {
|
| 281 |
+
height: 100%;
|
| 282 |
+
border-radius: 3px;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.p2-bar-fill {
|
| 286 |
+
transition: width 0.4s ease;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.complexity-bar-fill {
|
| 290 |
+
background: linear-gradient(90deg, #10b981, #f59e0b, #ef4444);
|
| 291 |
+
transition: width 0.5s ease;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 295 |
+
Query Analysis
|
| 296 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 297 |
+
.token-row {
|
| 298 |
+
display: flex;
|
| 299 |
+
flex-wrap: wrap;
|
| 300 |
+
gap: 8px;
|
| 301 |
+
margin-bottom: 20px;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.token-pill {
|
| 305 |
+
display: flex;
|
| 306 |
+
flex-direction: column;
|
| 307 |
+
align-items: center;
|
| 308 |
+
gap: 3px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.pill-text {
|
| 312 |
+
padding: 4px 10px;
|
| 313 |
+
border-radius: 20px;
|
| 314 |
+
font-size: 13px;
|
| 315 |
+
font-weight: 600;
|
| 316 |
+
color: #fff;
|
| 317 |
+
letter-spacing: 0.02em;
|
| 318 |
+
white-space: nowrap;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.pill-id {
|
| 322 |
+
font-size: 9px;
|
| 323 |
+
color: #94a3b8;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.pill-idf {
|
| 327 |
+
font-size: 9px;
|
| 328 |
+
color: #64748b;
|
| 329 |
+
font-weight: 500;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.heatmap-label {
|
| 333 |
+
font-size: 11px;
|
| 334 |
+
color: #64748b;
|
| 335 |
+
margin-bottom: 6px;
|
| 336 |
+
font-weight: 500;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.heatmap-strip {
|
| 340 |
+
display: flex;
|
| 341 |
+
height: 22px;
|
| 342 |
+
border-radius: 4px;
|
| 343 |
+
overflow: hidden;
|
| 344 |
+
width: 100%;
|
| 345 |
+
gap: 1px;
|
| 346 |
+
margin-bottom: 10px;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.heatmap-cell {
|
| 350 |
+
flex: 1;
|
| 351 |
+
border-radius: 1px;
|
| 352 |
+
transition: opacity 0.15s;
|
| 353 |
+
cursor: default;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.heatmap-cell:hover {
|
| 357 |
+
opacity: 0.7;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.embed-note {
|
| 361 |
+
font-size: 12px;
|
| 362 |
+
color: #475569;
|
| 363 |
+
background: #eff6ff;
|
| 364 |
+
border-left: 3px solid #6366f1;
|
| 365 |
+
border-radius: 4px;
|
| 366 |
+
padding: 7px 12px;
|
| 367 |
+
margin-bottom: 14px;
|
| 368 |
+
font-family: Arial, sans-serif;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.stats-row {
|
| 372 |
+
display: flex;
|
| 373 |
+
flex-wrap: wrap;
|
| 374 |
+
gap: 16px;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.stat-chip {
|
| 378 |
+
display: flex;
|
| 379 |
+
flex-direction: column;
|
| 380 |
+
gap: 2px;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.stat-label {
|
| 384 |
+
font-size: 10px;
|
| 385 |
+
text-transform: uppercase;
|
| 386 |
+
letter-spacing: 0.06em;
|
| 387 |
+
color: #94a3b8;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.stat-value {
|
| 391 |
+
font-size: 15px;
|
| 392 |
+
font-weight: 700;
|
| 393 |
+
color: #0f172a;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.complexity-wrap {
|
| 397 |
+
display: flex;
|
| 398 |
+
flex-direction: column;
|
| 399 |
+
gap: 4px;
|
| 400 |
+
flex: 1;
|
| 401 |
+
min-width: 140px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 405 |
+
Retrieval Comparison Table
|
| 406 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 407 |
+
.p2-panel {
|
| 408 |
+
width: 100vw;
|
| 409 |
+
max-width: 100vw;
|
| 410 |
+
margin-left: calc(50% - 50vw);
|
| 411 |
+
margin-right: calc(50% - 50vw);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.p2-table-wrap {
|
| 415 |
+
overflow-x: auto;
|
| 416 |
+
border-radius: 8px;
|
| 417 |
+
border: 1px solid #e2e8f0;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.p2-table {
|
| 421 |
+
width: 100%;
|
| 422 |
+
border-collapse: collapse;
|
| 423 |
+
font-size: 12px;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.p2-th {
|
| 427 |
+
padding: 8px 12px;
|
| 428 |
+
text-align: left;
|
| 429 |
+
font-size: 11px;
|
| 430 |
+
font-weight: 600;
|
| 431 |
+
text-transform: uppercase;
|
| 432 |
+
letter-spacing: 0.06em;
|
| 433 |
+
color: #64748b;
|
| 434 |
+
background: #f1f5f9;
|
| 435 |
+
border-bottom: 1px solid #e2e8f0;
|
| 436 |
+
white-space: nowrap;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.p2-row {
|
| 440 |
+
border-bottom: 1px solid #e2e8f0;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.p2-row:last-child {
|
| 444 |
+
border-bottom: none;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.p2-td {
|
| 448 |
+
padding: 10px 12px;
|
| 449 |
+
vertical-align: middle;
|
| 450 |
+
color: #1e293b;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.p2-rank {
|
| 454 |
+
font-weight: 700;
|
| 455 |
+
font-size: 13px;
|
| 456 |
+
white-space: nowrap;
|
| 457 |
+
min-width: 80px;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.p2-preview-cell {
|
| 461 |
+
position: relative;
|
| 462 |
+
max-width: 260px;
|
| 463 |
+
min-width: 160px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.p2-preview {
|
| 467 |
+
font-size: 12px;
|
| 468 |
+
color: #334155;
|
| 469 |
+
white-space: nowrap;
|
| 470 |
+
overflow: hidden;
|
| 471 |
+
text-overflow: ellipsis;
|
| 472 |
+
max-width: 240px;
|
| 473 |
+
cursor: default;
|
| 474 |
+
font-family: Arial, sans-serif;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.p2-score-wrap {
|
| 478 |
+
display: flex;
|
| 479 |
+
align-items: center;
|
| 480 |
+
gap: 6px;
|
| 481 |
+
min-width: 90px;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.p2-bar-bg {
|
| 485 |
+
flex: 1;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.p2-score-num {
|
| 489 |
+
font-size: 11px;
|
| 490 |
+
font-weight: 600;
|
| 491 |
+
color: #475569;
|
| 492 |
+
min-width: 36px;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.p2-delta {
|
| 496 |
+
text-align: center;
|
| 497 |
+
min-width: 40px;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.p2-arrow {
|
| 501 |
+
font-size: 12px;
|
| 502 |
+
font-weight: 700;
|
| 503 |
+
padding: 2px 5px;
|
| 504 |
+
border-radius: 4px;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.p2-arrow.up {
|
| 508 |
+
color: #059669;
|
| 509 |
+
background: #d1fae5;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.p2-arrow.down {
|
| 513 |
+
color: #dc2626;
|
| 514 |
+
background: #fee2e2;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.p2-arrow.flat {
|
| 518 |
+
color: #94a3b8;
|
| 519 |
+
background: #f1f5f9;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
/* ββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββββββββββ
|
| 523 |
+
MMR Visualization
|
| 524 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 525 |
+
.p3-section-label {
|
| 526 |
+
font-size: 11px;
|
| 527 |
+
font-weight: 600;
|
| 528 |
+
text-transform: uppercase;
|
| 529 |
+
letter-spacing: 0.07em;
|
| 530 |
+
color: #64748b;
|
| 531 |
+
margin-bottom: 8px;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.p3-slider-row {
|
| 535 |
+
display: flex;
|
| 536 |
+
align-items: center;
|
| 537 |
+
gap: 12px;
|
| 538 |
+
margin-bottom: 18px;
|
| 539 |
+
flex-wrap: wrap;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.p3-slider-label {
|
| 543 |
+
font-size: 13px;
|
| 544 |
+
color: #334155;
|
| 545 |
+
min-width: 70px;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.p3-slider {
|
| 549 |
+
flex: 1;
|
| 550 |
+
max-width: 260px;
|
| 551 |
+
accent-color: #6366f1;
|
| 552 |
+
cursor: pointer;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.p3-slider-hint {
|
| 556 |
+
font-size: 11px;
|
| 557 |
+
color: #94a3b8;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.p3-top-row {
|
| 561 |
+
display: flex;
|
| 562 |
+
gap: 20px;
|
| 563 |
+
flex-wrap: wrap;
|
| 564 |
+
align-items: flex-start;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.p3-scatter-wrap {
|
| 568 |
+
position: relative;
|
| 569 |
+
flex-shrink: 0;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.p3-canvas {
|
| 573 |
+
display: block;
|
| 574 |
+
border-radius: 8px;
|
| 575 |
+
border: 1px solid #e2e8f0;
|
| 576 |
+
background: #fff;
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
.p3-side-wrap {
|
| 580 |
+
display: flex;
|
| 581 |
+
gap: 14px;
|
| 582 |
+
flex: 1;
|
| 583 |
+
min-width: 280px;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.p3-side-col {
|
| 587 |
+
flex: 1;
|
| 588 |
+
min-width: 120px;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.p3-chunk-list {
|
| 592 |
+
display: flex;
|
| 593 |
+
flex-direction: column;
|
| 594 |
+
gap: 6px;
|
| 595 |
+
max-height: 280px;
|
| 596 |
+
overflow-y: auto;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.p3-side-item {
|
| 600 |
+
display: flex;
|
| 601 |
+
gap: 8px;
|
| 602 |
+
padding: 6px 8px;
|
| 603 |
+
background: #fff;
|
| 604 |
+
border: 1px solid #e2e8f0;
|
| 605 |
+
border-radius: 6px;
|
| 606 |
+
font-size: 11px;
|
| 607 |
+
align-items: flex-start;
|
| 608 |
+
font-family: Arial, sans-serif;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.p3-side-sim {
|
| 612 |
+
font-weight: 700;
|
| 613 |
+
color: #6366f1;
|
| 614 |
+
white-space: nowrap;
|
| 615 |
+
flex-shrink: 0;
|
| 616 |
+
font-family: 'JetBrains Mono', monospace;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
.p3-side-text {
|
| 620 |
+
color: #475569;
|
| 621 |
+
overflow: hidden;
|
| 622 |
+
display: -webkit-box;
|
| 623 |
+
-webkit-line-clamp: 2;
|
| 624 |
+
-webkit-box-orient: vertical;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.p3-matrix-wrap {
|
| 628 |
+
overflow-x: auto;
|
| 629 |
+
margin-top: 6px;
|
| 630 |
+
padding-bottom: 16px;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 634 |
+
Latency Timeline
|
| 635 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 636 |
+
.latency-bar {
|
| 637 |
+
display: flex;
|
| 638 |
+
height: 28px;
|
| 639 |
+
border-radius: 6px;
|
| 640 |
+
overflow: hidden;
|
| 641 |
+
width: 100%;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
.latency-segment {
|
| 645 |
+
display: flex;
|
| 646 |
+
align-items: center;
|
| 647 |
+
justify-content: center;
|
| 648 |
+
font-size: 11px;
|
| 649 |
+
font-weight: 600;
|
| 650 |
+
color: #fff;
|
| 651 |
+
white-space: nowrap;
|
| 652 |
+
overflow: hidden;
|
| 653 |
+
transition: opacity 0.2s;
|
| 654 |
+
cursor: default;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.latency-segment:last-child {
|
| 658 |
+
flex: 1;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.latency-segment:hover {
|
| 662 |
+
opacity: 0.85;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.seg-embed {
|
| 666 |
+
background: #6366f1;
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.seg-vector {
|
| 670 |
+
background: #0ea5e9;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.seg-bm25 {
|
| 674 |
+
background: #10b981;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.seg-hybrid {
|
| 678 |
+
background: #f59e0b;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.seg-mmr {
|
| 682 |
+
background: #8b5cf6;
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.seg-rerank {
|
| 686 |
+
background: #ef4444;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.seg-llm {
|
| 690 |
+
background: #64748b;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.latency-small-row {
|
| 694 |
+
display: flex;
|
| 695 |
+
flex-wrap: wrap;
|
| 696 |
+
gap: 8px;
|
| 697 |
+
margin-top: 6px;
|
| 698 |
+
margin-bottom: 2px;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.latency-small-label {
|
| 702 |
+
font-size: 10px;
|
| 703 |
+
font-weight: 700;
|
| 704 |
+
background: #f1f5f9;
|
| 705 |
+
border-radius: 4px;
|
| 706 |
+
padding: 2px 6px;
|
| 707 |
+
white-space: nowrap;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.latency-meta {
|
| 711 |
+
display: flex;
|
| 712 |
+
flex-wrap: wrap;
|
| 713 |
+
gap: 12px;
|
| 714 |
+
margin-top: 14px;
|
| 715 |
+
align-items: center;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.latency-chip {
|
| 719 |
+
display: flex;
|
| 720 |
+
align-items: center;
|
| 721 |
+
gap: 6px;
|
| 722 |
+
font-size: 12px;
|
| 723 |
+
color: #334155;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.chip-dot {
|
| 727 |
+
width: 10px;
|
| 728 |
+
height: 10px;
|
| 729 |
+
border-radius: 3px;
|
| 730 |
+
flex-shrink: 0;
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
.chip-time {
|
| 734 |
+
font-weight: 700;
|
| 735 |
+
color: #0f172a;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.latency-total {
|
| 739 |
+
margin-left: auto;
|
| 740 |
+
font-size: 13px;
|
| 741 |
+
font-weight: 700;
|
| 742 |
+
color: #0f172a;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 746 |
+
Chunk cards
|
| 747 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 748 |
+
.chunk-card {
|
| 749 |
+
border: 1px solid #e2e8f0;
|
| 750 |
+
border-radius: 10px;
|
| 751 |
+
background: #fff;
|
| 752 |
+
margin-bottom: 14px;
|
| 753 |
+
overflow: hidden;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.chunk-card-header {
|
| 757 |
+
display: flex;
|
| 758 |
+
align-items: center;
|
| 759 |
+
justify-content: space-between;
|
| 760 |
+
padding: 10px 14px;
|
| 761 |
+
background: #f8fafc;
|
| 762 |
+
border-bottom: 1px solid #e2e8f0;
|
| 763 |
+
gap: 10px;
|
| 764 |
+
flex-wrap: wrap;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
.chunk-card-title {
|
| 768 |
+
font-size: 14px;
|
| 769 |
+
font-weight: 700;
|
| 770 |
+
color: #1e293b;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.chunk-score-badge {
|
| 774 |
+
font-size: 12px;
|
| 775 |
+
font-weight: 700;
|
| 776 |
+
padding: 3px 8px;
|
| 777 |
+
border-radius: 20px;
|
| 778 |
+
white-space: nowrap;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.chunk-source-link {
|
| 782 |
+
font-size: 14px;
|
| 783 |
+
color: #6366f1;
|
| 784 |
+
text-decoration: none;
|
| 785 |
+
display: flex;
|
| 786 |
+
align-items: center;
|
| 787 |
+
gap: 4px;
|
| 788 |
+
overflow: hidden;
|
| 789 |
+
text-overflow: ellipsis;
|
| 790 |
+
white-space: nowrap;
|
| 791 |
+
max-width: 380px;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.chunk-source-link:hover {
|
| 795 |
+
text-decoration: underline;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
.chunk-source-icon {
|
| 799 |
+
font-size: 12px;
|
| 800 |
+
flex-shrink: 0;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
.chunk-card-body {
|
| 804 |
+
padding: 14px;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
.chunk-content {
|
| 808 |
+
font-size: 14px;
|
| 809 |
+
color: #334155;
|
| 810 |
+
line-height: 1.6;
|
| 811 |
+
text-align: left;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
.chunk-content pre,
|
| 815 |
+
.chunk-content code {
|
| 816 |
+
background: none !important;
|
| 817 |
+
color: inherit !important;
|
| 818 |
+
padding: 0 !important;
|
| 819 |
+
border-radius: 0 !important;
|
| 820 |
+
font-family: inherit !important;
|
| 821 |
+
white-space: normal !important;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.chunk {
|
| 825 |
+
border: 1px solid #ddd;
|
| 826 |
+
padding: 12px;
|
| 827 |
+
margin-bottom: 12px;
|
| 828 |
+
border-radius: 10px;
|
| 829 |
+
background: white;
|
| 830 |
+
text-align: left;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.chunk pre,
|
| 834 |
+
.chunk code {
|
| 835 |
+
background: none !important;
|
| 836 |
+
color: inherit !important;
|
| 837 |
+
padding: 0 !important;
|
| 838 |
+
border-radius: 0 !important;
|
| 839 |
+
font-family: inherit !important;
|
| 840 |
+
white-space: normal !important;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
.source {
|
| 844 |
+
margin-top: 8px;
|
| 845 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<title>RAG Assistant</title>
|
| 6 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
| 9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
| 10 |
+
</head>
|
| 11 |
+
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<div class="container">
|
| 15 |
+
|
| 16 |
+
<h1>π RAG Assistant</h1>
|
| 17 |
+
|
| 18 |
+
<div class="input-container">
|
| 19 |
+
<input id="query" placeholder="Ask something about Transformers...">
|
| 20 |
+
<button onclick="ask()">Ask</button>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<div id="answer"></div>
|
| 24 |
+
<div id="latency-panel"></div>
|
| 25 |
+
<div id="query-panel"></div>
|
| 26 |
+
<div id="retrieval-panel"></div>
|
| 27 |
+
<div id="mmr-panel"></div>
|
| 28 |
+
<div id="chunks-panel"></div>
|
| 29 |
+
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<script src="{{ url_for('static', filename='rerank.js') }}"></script>
|
| 33 |
+
<script src="{{ url_for('static', filename='mmr.js') }}"></script>
|
| 34 |
+
<script src="{{ url_for('static', filename='main.js') }}"></script>
|
| 35 |
+
|
| 36 |
+
</body>
|
| 37 |
+
|
| 38 |
+
</html>
|