Ctrl K
ComunidadGuías…

cortexm/bench/baselines.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Baseline retrievers for the neuro-symbolic delta table.

* ``bm25_rag``      — lexical RAG over raw chunks (the "context stuffer"
                      proxy: BEAM's Vanilla/RAG baselines score 12-25%).
* ``vector_only``   — our VSA palace WITHOUT the symbolic read path:
                      pure neural fact-level RAG. Isolates exactly what
                      the symbolic Trace contributes.
"""

from __future__ import annotations

import math
import re
from collections import Counter

from cortexm.text.tokenizer import STOPWORDS, words


# ---------------------------------------------------------------- BM25
class BM25Index:
    def __init__(self, docs: list[dict], k1: float = 1.5, b: float = 0.75,
                 delta: float = 0.0) -> None:
        self.k1, self.b, self.delta = k1, b, delta
        self.docs = docs
        self.doc_ids = [d["id"] for d in docs]
        self.doc_len = []
        self.tf: list[Counter] = []
        self.postings: dict[str, list[tuple[int, int]]] = {}
        for i, d in enumerate(docs):
            toks = [t for t in words(d["text"]) if t not in STOPWORDS]
            self.doc_len.append(len(toks))
            counts = Counter(toks)
            self.tf.append(counts)
            for t, c in counts.items():
                self.postings.setdefault(t, []).append((i, c))
        self.N = len(docs) or 1
        self.avgdl = (sum(self.doc_len) / self.N) if self.N else 1.0
        self.df = {t: len(p) for t, p in self.postings.items()}

    def search(self, query: str, k: int = 8) -> list[tuple[str, float]]:
        qtf = Counter(t for t in words(query) if t not in STOPWORDS)
        scores: dict[int, float] = {}
        for t, _ in qtf.items():
            postings = self.postings.get(t)
            if not postings:
                continue
            idf = math.log(1 + (self.N - self.df[t] + 0.5) / (self.df[t] + 0.5))
            for i, c in postings:
                dl = self.doc_len[i] or 1
                # BM25+ (Lv & Zhai): the delta lower-bounds the TF
                # component so short docs with tf=1 aren't crushed by
                # the length norm. delta=0.0 reproduces classic Okapi.
                s = idf * ((c * (self.k1 + 1)) / (
                    c + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
                    + self.delta)
                scores[i] = scores.get(i, 0.0) + s
        top = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
        return [(self.doc_ids[i], s) for i, s in top]

    def doc_text(self, doc_id: str) -> str:
        for d in self.docs:
            if d["id"] == doc_id:
                return d["text"]
        return ""


def bm25_context(index: BM25Index, query: str, k: int = 8) -> str:
    hits = index.search(query, k)
    parts = [index.doc_text(did)[:220] for did, _ in hits]
    return "\n".join(f"- {p}" for p in parts if p)


# ------------------------------------------------------- vector-only
def vector_only_context(memory, query: str, user_id: str, k: int = 8) -> str:
    """VSA palace search → source chunks. No temporal logic, no
    contradiction chains, no symbolic expansion — neural retrieval only."""
    q_vec = memory.palace.embedder.embed(query)
    scope = {f.id for f in memory.store.query_facts(user_id=user_id,
                                                    active=True)}
    hits = memory.palace.search(q_vec, max(k * 3, 24),
                                candidate_ids=scope or None)
    seen_chunks: list[str] = []
    seen_ids = set()
    for fid, score in hits:
        f = memory.store.get_fact(fid)
        if not f or not f.source_id:
            continue
        chunk = memory.store.get_chunk(f.source_id)
        if chunk and chunk["id"] not in seen_ids:
            seen_ids.add(chunk["id"])
            seen_chunks.append(chunk["text"][:240])
        if len(seen_chunks) >= k:
            break
    return "\n".join(f"- {c}" for c in seen_chunks)