Ctrl K
ComunidadGuías…

lib/knowledge-search.js

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

Archivo original ↗Preguntar a KiBot
// Shared BM25 adapter for Clay Knowledge surfaces.
//
// There is exactly one ranking implementation in Clay: the generic
// buildIndex/searchIndex engine in session-search.js, including its tokenizer
// with CJK/Japanese/Thai bigram segmentation. This module is a thin adapter
// over it, not a second ranker.
//
// Field weighting is expressed by controlled repetition of a field's text
// before indexing, which is how session-search.js already weights digests.
// A title repeated three times contributes three times the term frequency of
// the body, so BM25 saturation and length normalisation still apply normally.
//
// Long records are segmented rather than truncated. A record whose body runs to
// tens of thousands of characters is indexed as several overlapping documents,
// so a term that appears only near the end is still found. Short fields (title,
// name, tags) are anchors and are repeated into every segment, so weighting is
// identical in each one. Segments are collapsed back to one result per original
// item by taking its best-scoring segment; the caller still applies its own
// score, recency, and reference tie-breaks on top.

var sessionSearch = require("./session-search");

var MAX_SNIPPET_CHARS = 320;
var SNIPPET_LEAD_CHARS = 80;
// A field at or under this length is an anchor, replicated into every segment.
// Anything longer is windowed.
var SEGMENT_CHARS = 8000;
// Overlap so a term straddling a window boundary is still indexed intact.
var SEGMENT_OVERLAP = 800;
// Memory bound. A record needing more windows than this is indexed up to the
// cap; the cap is high enough that no realistic Knowledge record reaches it.
var MAX_SEGMENTS_PER_ITEM = 64;

function repeat(text, weight) {
  var out = [];
  for (var i = 0; i < weight; i++) out.push(text);
  return out.join(" ");
}

function normalizeWeight(weight) {
  return Number.isFinite(weight) ? Math.max(1, Math.floor(weight)) : 1;
}

function usableFields(fields) {
  var out = [];
  for (var i = 0; i < (fields || []).length; i++) {
    var field = fields[i];
    if (!field || typeof field.text !== "string" || !field.text) continue;
    out.push({ text: field.text, weight: normalizeWeight(field.weight) });
  }
  return out;
}

// Kept for callers and tests that want the single-document form. Anchors and
// short bodies produce exactly this text.
function weightedText(fields) {
  var parts = [];
  var usable = usableFields(fields);
  for (var i = 0; i < usable.length; i++) parts.push(repeat(usable[i].text, usable[i].weight));
  return parts.join(" ");
}

var SEGMENT_STRIDE = SEGMENT_CHARS - SEGMENT_OVERLAP;
// The maximum searchable characters of one long field that can be indexed
// before the segment cap stops coverage. Reported to callers so an incomplete
// index is stated rather than implied.
var MAX_INDEXED_CHARS = SEGMENT_CHARS + (MAX_SEGMENTS_PER_ITEM - 1) * SEGMENT_STRIDE;

// One shared plan drives both the indexed documents and the coverage report,
// so what a caller is told was indexed is exactly what was indexed.
function planSegments(fields) {
  var usable = usableFields(fields);
  var anchors = [];
  var longs = [];
  for (var i = 0; i < usable.length; i++) {
    if (usable[i].text.length > SEGMENT_CHARS) longs.push(usable[i]);
    else anchors.push(usable[i]);
  }

  var windows = [];
  var totalChars = 0;
  var indexedChars = 0;
  for (var l = 0; l < longs.length; l++) {
    var text = longs[l].text;
    totalChars += text.length;
    var covered = 0;
    for (var offset = 0; offset < text.length && windows.length < MAX_SEGMENTS_PER_ITEM; offset += SEGMENT_STRIDE) {
      var end = Math.min(text.length, offset + SEGMENT_CHARS);
      windows.push({ field: longs[l], start: offset, end: end });
      covered = end;
      if (end >= text.length) break;
    }
    indexedChars += covered;
  }
  for (var a = 0; a < anchors.length; a++) {
    totalChars += anchors[a].text.length;
    indexedChars += anchors[a].text.length;
  }

  return {
    anchors: anchors,
    longs: longs,
    windows: windows,
    totalChars: totalChars,
    indexedChars: indexedChars,
    complete: indexedChars >= totalChars,
  };
}

// How much of an item's searchable text this adapter can index. `complete` is
// false when the segment cap stopped short, which means a term appearing only
// in the uncovered tail cannot be ranked.
function coverage(fields) {
  var plan = planSegments(fields);
  return { totalChars: plan.totalChars, indexedChars: plan.indexedChars, complete: plan.complete };
}

// One item becomes one or more BM25 documents. Short fields anchor every
// segment so title and tag weighting is consistent wherever the body matched.
function segmentTexts(fields) {
  var plan = planSegments(fields);
  var anchorText = weightedText(plan.anchors);
  if (plan.windows.length === 0) return [anchorText];

  var texts = [];
  for (var w = 0; w < plan.windows.length; w++) {
    var window = plan.windows[w];
    var body = repeat(window.field.text.substring(window.start, window.end), window.field.weight);
    texts.push(anchorText ? anchorText + " " + body : body);
  }
  return texts;
}

// Build the generic {id, text, meta} documents the shared engine expects.
// project(item, position) must return { id, fields, meta }. One item may yield
// several documents; each carries the item's position so results collapse back.
function buildDocs(items, project) {
  var docs = [];
  for (var i = 0; i < (items || []).length; i++) {
    var projected = project(items[i], i);
    if (!projected) continue;
    var texts = segmentTexts(projected.fields);
    for (var s = 0; s < texts.length; s++) {
      docs.push({
        id: projected.id,
        text: texts[s],
        meta: { position: i, segment: s, id: projected.id, meta: projected.meta },
      });
    }
  }
  return docs;
}

// Rank with the shared engine. Returns [{ id, score, meta }] highest first, one
// entry per original item. A query that tokenizes to nothing scores nothing,
// rather than silently falling back to a different matching strategy.
//
// Aggregation across an item's segments is the maximum segment score: a record
// is as relevant as its best-matching part, so a long record is neither
// penalised for its unmatched bulk nor rewarded for repeating a term across
// windows that overlap.
function rank(items, query, project, maxResults) {
  var docs = buildDocs(items, project);
  if (docs.length === 0) return [];
  var index = sessionSearch.buildIndex(docs);
  var scored = sessionSearch.searchIndex(index, query, docs.length);

  var bestByPosition = new Map();
  for (var i = 0; i < scored.length; i++) {
    var envelope = scored[i].meta;
    if (!envelope) continue;
    var existing = bestByPosition.get(envelope.position);
    if (existing && existing.score >= scored[i].score) continue;
    bestByPosition.set(envelope.position, { id: envelope.id, score: scored[i].score, meta: envelope.meta });
  }

  var collapsed = [];
  bestByPosition.forEach(function (entry) { collapsed.push(entry); });
  // Stable, deterministic ordering before the caller applies its own
  // recency and reference tie-breaks.
  collapsed.sort(function (a, b) {
    return b.score - a.score || compareIds(a.id, b.id);
  });
  var limit = maxResults || collapsed.length;
  return collapsed.slice(0, limit);
}

// Locale-independent lexical order over UTF-16 code units, for the opaque ids
// and refs that Knowledge uses as its deterministic tie-break.
//
// Those identifiers are base64url, so they contain both "-" (0x2D) and "_"
// (0x5F). String.prototype.localeCompare applies collation rules that order
// those two the other way round from code-unit order, and the result depends on
// the ICU data the runtime happens to carry, so it cannot express a
// deterministic order that holds across machines. This matches the default
// Array.prototype.sort ordering exactly.
function compareIds(a, b) {
  var left = String(a);
  var right = String(b);
  return left < right ? -1 : left > right ? 1 : 0;
}

// A snippet centred on the first query term that appears in the text, so the
// excerpt shows why the document matched rather than just its opening. Works
// across the whole record, not only its first segment.
function snippet(text, query, maxChars) {
  if (typeof text !== "string" || !text) return "";
  var limit = maxChars || MAX_SNIPPET_CHARS;
  var haystack = text.toLowerCase();
  var tokens = sessionSearch.tokenize(query || "");
  var best = -1;
  for (var i = 0; i < tokens.length; i++) {
    var at = haystack.indexOf(tokens[i]);
    if (at !== -1 && (best === -1 || at < best)) best = at;
  }
  if (best === -1) {
    var raw = (query || "").trim().toLowerCase();
    if (raw) best = haystack.indexOf(raw);
  }
  var start = best === -1 ? 0 : Math.max(0, best - SNIPPET_LEAD_CHARS);
  return text.substring(start, start + limit);
}

module.exports = {
  MAX_SNIPPET_CHARS: MAX_SNIPPET_CHARS,
  SEGMENT_CHARS: SEGMENT_CHARS,
  SEGMENT_OVERLAP: SEGMENT_OVERLAP,
  SEGMENT_STRIDE: SEGMENT_STRIDE,
  MAX_SEGMENTS_PER_ITEM: MAX_SEGMENTS_PER_ITEM,
  MAX_INDEXED_CHARS: MAX_INDEXED_CHARS,
  tokenize: sessionSearch.tokenize,
  weightedText: weightedText,
  planSegments: planSegments,
  coverage: coverage,
  segmentTexts: segmentTexts,
  buildDocs: buildDocs,
  compareIds: compareIds,
  rank: rank,
  snippet: snippet,
};