diff --git a/docs/plan-assistant-genai.md b/docs/plan-assistant-genai.md index 1dfca15..31a28dc 100644 --- a/docs/plan-assistant-genai.md +++ b/docs/plan-assistant-genai.md @@ -399,6 +399,40 @@ se informa del número de llamadas y del modelo**, y se espera. --- +## PR 4b — Modelo de embeddings (se decide con datos) + +PR aparte, después del PR 4. **Qué:** decidir si se cambia el modelo de embeddings actual, +`paraphrase-multilingual-MiniLM-L12-v2`. Ya es multilingüe, así que el motivo no es el idioma. +**Por qué:** con ese modelo, el SKU aceptable de q06 y q10 queda fuera del top 10 de la recuperación. +Eso pone un techo de 8/10 a E1–E3, por mucho que acierte el selector. La causa es que los nombres +comerciales confunden («Bota **Glaciar**», «Tienda **Refugio**») y que la respuesta exige una +inferencia: pasar de «no resbalar en hielo» a crampones, y de «cargar a la espalda» a mochila. + +Comprobación gratuita en memoria (sin llamadas a Claude y con el dataset sin cambios). Posiciones +del SKU aceptable: + +| Modelo | Dims | Preguntas con un SKU aceptable en el top 10 | q05 | q06 | q10 | Migración | +|---|---|---|---|---|---|---| +| MiniLM-L12 (actual) | 384 | 8/10 | 7 | 11 | 16 | — | +| multilingual-e5-small | 384 | 9/10 | 15 | 7 | 3 | no | +| multilingual-e5-base | 768 | 8/10 | 16 | 15 | 4 | sí | +| multilingual-e5-large | 1024 | 10/10 | 10 (justo) | 4 | 2 | sí | + +- **e5-small y e5-base:** no están en la lista de fastembed 0.9.1. Se cargan con + `add_custom_model`, a partir del ONNX oficial de Hugging Face y con pooling *mean*. +- **Las tres e5:** usan los prefijos `query:` y `passage:`. +- **e5-large:** pesa 2,24 GB frente a 0,22 GB; la imagen crece en consecuencia. + +**Cómo se decide:** +- **Línea base:** E1–E3 con el modelo actual. +- **Comparación:** la recuperación de cada candidato. +- **Criterio:** e5-small evita la migración, pero pierde q05. e5-large recupera las diez preguntas, + a cambio de la migración a `vector(1024)`, el tamaño de la imagen y la latencia. +- **Si se cambia de modelo:** migración, prefijos en el adaptador, reindexado, y E1–E3 otra vez + contra la línea base. + +--- + ## PR 5 — Evaluador jev Añade un segundo `CandidateAssessor` sobre un modelo de decisión. Nada fuera del adaptador cambia: diff --git a/docs/step-18-assistant-agent.md b/docs/step-18-assistant-agent.md index 23d592b..ae47ce0 100644 --- a/docs/step-18-assistant-agent.md +++ b/docs/step-18-assistant-agent.md @@ -148,6 +148,22 @@ cubre ambas preguntas con 300 llamadas y deja el total en unas **930**. top 10 de la recuperación en q06 (`MT-0020`) ni en q10 (`MT-0017`). En q09 faltan 2 de sus 4. Por eso E1–E3 llegan como mucho a 8/10, y el informe lo muestra en la columna `reachable`. +### Línea base (decisión del usuario) + +- **Modelo de embeddings:** E1–E3 se ejecutan con el modelo actual, + `paraphrase-multilingual-MiniLM-L12-v2`. Es multilingüe; se comprobó en la configuración, en el + Dockerfile y en el catálogo de fastembed. +- **Dataset:** no cambia. +- **Informe:** da dos números. + - **Extremo a extremo:** aciertos sobre 10, junto al techo de recuperación (8/10: en q06 y q10 + no hay ningún SKU aceptable entre los candidatos). + - **Solo selector:** aciertos contando únicamente las preguntas que tienen un SKU aceptable entre + los candidatos. +- **Cambio de modelo:** queda como PR 4b del plan, y se decidirá con estos datos. +- **Ejecución pendiente:** el primer intento se paró en la primera tanda de peticiones: la API + respondió 400 por falta de crédito. Ninguna de esas peticiones se facturó. Se repite cuando haya + crédito. + ## Correcciones tras la code review (3b) La revisión (`/code-review`, lanzada por el usuario) dio 10 hallazgos y se comprobó cada uno contra el diff --git a/tests/evals/harness.py b/tests/evals/harness.py index 6238e38..7511459 100644 --- a/tests/evals/harness.py +++ b/tests/evals/harness.py @@ -169,6 +169,20 @@ def reachable(self) -> bool: ) +def baseline_lines(runs: Sequence[SelectorRun]) -> tuple[str, str]: + reachable = [run for run in runs if run.reachable] + unreachable = [run.question.id for run in runs if not run.reachable] + ceiling = f"retrieval ceiling {len(reachable)}/{len(runs)}" + if unreachable: + ceiling += f" ({', '.join(unreachable)}: no acceptable SKU among the candidates)" + end_to_end = accuracy_line([r.question for r in runs], [r.outcome for r in runs]) + selector_only = accuracy_line([r.question for r in reachable], [r.outcome for r in reachable]) + return ( + f"end-to-end {end_to_end}; {ceiling}", + f"selector only {selector_only} (questions with an acceptable SKU among the candidates)", + ) + + def threshold_table(runs: Sequence[SelectorRun]) -> list[ThresholdRow]: rows = [] for requirements_threshold, min_score in grid(THRESHOLD_GRID, THRESHOLD_GRID): diff --git a/tests/evals/test_eval_scoring.py b/tests/evals/test_eval_scoring.py index 88b1ce9..7d00195 100644 --- a/tests/evals/test_eval_scoring.py +++ b/tests/evals/test_eval_scoring.py @@ -7,6 +7,7 @@ EvalQuestion, SelectorRun, accuracy_line, + baseline_lines, is_correct, load_dataset, outcome_of, @@ -131,6 +132,26 @@ def test_an_acceptable_sku_outside_the_candidates_is_unreachable(self) -> None: assert run.reachable is False assert replace(run, question=NO_RECOMMENDATION).reachable is True + def test_baseline_gives_end_to_end_and_selector_only(self) -> None: + good = _candidate("MT-0002", 0.9) + missing = _candidate("MT-0001", 0.9) + runs = [ + _run(RECOMMEND, "chosen:MT-0002", good), + _run(replace(RECOMMEND, id="q06"), "chosen:MT-0001", missing), + _run(NO_RECOMMENDATION, "below_threshold", missing), + ] + + end_to_end, selector_only = baseline_lines(runs) + + assert end_to_end == ( + "end-to-end 2/3: recommend 1/2, no_recommendation 1/1; " + "retrieval ceiling 2/3 (q06: no acceptable SKU among the candidates)" + ) + assert selector_only == ( + "selector only 2/2: recommend 1/1, no_recommendation 1/1 " + "(questions with an acceptable SKU among the candidates)" + ) + async def test_api_calls_flag_many_rate_limits(self) -> None: calls = ApiCalls(requests=20) calls.statuses[200] = 19 @@ -151,3 +172,14 @@ def _candidate(sku: str, score: float) -> Candidate: content=sku, retrieval_score=score, ) + + +def _run(question: EvalQuestion, outcome: str, candidate: Candidate) -> SelectorRun: + return SelectorRun( + question=question, + outcome=outcome, + latency_ms=1, + candidates=(candidate,), + assessments=(), + unassessed=0, + ) diff --git a/tests/evals/test_eval_selector.py b/tests/evals/test_eval_selector.py index 67ed14b..d75cc44 100644 --- a/tests/evals/test_eval_selector.py +++ b/tests/evals/test_eval_selector.py @@ -10,8 +10,8 @@ RecordingAssessor, SelectorRun, Tokens, - accuracy_line, assessment_tokens, + baseline_lines, candidate_of, latency_line, outcome_of, @@ -105,14 +105,13 @@ async def _run_selector( def _print(title: str, report: SelectorReport, baseline: SelectorReport | None = None) -> None: runs = report.runs print(f"\n{title}") - print(f" result {accuracy_line([r.question for r in runs], [r.outcome for r in runs])}") + for line in baseline_lines(runs): + print(f" {line}") if baseline is not None: same = sum( run.outcome == base.outcome for run, base in zip(runs, baseline.runs, strict=True) ) print(f" same outcome as E1: {same}/{len(runs)}") - reachable = sum(run.reachable for run in runs) - print(f" retrieval ceiling: an acceptable outcome is reachable in {reachable}/{len(runs)}") for run in runs: verdict = "ok" if run.correct else "WRONG" print(