1 Dx

La nueva función de citas de Anthropic para Claude se hizo viral hace poco porque permite adjuntar referencias a las respuestas de tu IA de forma automática, aunque sólo está disponible para Claude. Si tu pipeline funciona con ChatGPT, un modelo local de código abierto o cualquier otra cosa, no estás dentro del enfoque oficial.

Por eso he preparado este artículo: para mostrarte cómo puedes desarrollar tu propio sistema de citas al estilo Anthropic, paso a paso, para cualquier LLM que desees. Almacenaremos fragmentos en una base de datos vectorial, los recuperaremos, los pasaremos al LLM con instrucciones sobre cómo producir etiquetas que hagan referencia a frases específicas, y luego analizaremos la respuesta final para mostrar una interfaz de usuario ordenada e interactiva para cada cita. Sí, es un poco lioso y, si pudiera elegir, usaría la función integrada de Anthropic. Pero si no puedes, aquí tienes una alternativa.

Nota: es probable que Anthropic utilice un enfoque de single-pass (como hacemos nosotros) para generar tanto la respuesta final como las citas en línea. Otro enfoque es el de two-pass: primero el modelo escribe una respuesta y luego le pedimos que etiquete cada fragmento con referencias. Esto puede ser más preciso, pero también más complejo y lento. Para muchos casos de uso, las citas en línea son suficientes.

Tabla de contenidos

1. La arquitectura de un vistazo

A continuación te mostramos cómo funciona nuestro sistema de citas «házlo tú mismo»:

image 28
  1. Consulta del usuario: Preguntamos, por ejemplo: «¿Cómo decide Paul Graham en qué trabajar?».
  2. Búsqueda en Vector DB: Incrustamos la consulta, buscamos en KDB.AI fragmentos de texto relevantes.
  3. Fragmentos: Los resultados más importantes se dividen por frases (mediante división ingenua o algún método avanzado) para permitir referencias precisas como «frases=2-4».
  4. Pregunta LLM: Ordenamos al modelo que produzca una respuesta que incluya etiquetas en línea (…) alrededor de frases específicas.
  5. Salida LLM: La salida única incluye tanto el texto final como las etiquetas de cita incrustadas.
  6. Analizador: Analizamos esas etiquetas, las volvemos a asignar a las frases del fragmento original y creamos metadatos (como el fragmento exacto que según el modelo pertenece al fragmento 0, frases 1-3).
  7. Interfaz de usuario (UI): Por último, mostramos un popover o tooltip interactivo en el navegador del usuario, permitiéndole ver el texto de referencia del chunk.

El resultado será la siguiente interfaz, con frases sobre las que se puede pasar el ratón, al estilo de Gemini:

image 30
Fuente de la imagen: autor

2. Código completo: De principio a fin

Si quieres probarlo tú mismo en Colab, echa un vistazo a este cuaderno.

Estaremos construyendo un enfoque de citación en línea de un solo paso similar a lo que Anthropic probablemente utiliza bajo el capó. Ten en cuenta que gran parte de la complejidad de este enfoque proviene de querer citar no sólo fragmentos, pero las frases de grano fino dentro de estos fragmentos. Es algo que intento hacer porque mostrarlas al usuario suele ser una buena idea. Pero sin este requisito, el código se vuelve sustancialmente más simple y tú puedes modificar fácilmente lo siguiente para simplemente devolver citas de fragmentos en su lugar.

2.1 Configuración y dependencias

Dependeremos de:

  • kdbai_client para almacenar chunks en la base de datos vectorial KDB.AI.
  • fastembed, una biblioteca para generar incrustaciones locales rápidamente.
  • llama-index para analizar el conjunto de datos de Paul Graham.
!pip install llama-index fastembed kdbai_client onnxruntime==1.19.2

import os
from getpass import getpass
import kdbai_client as kdbai
import time
from llama_index.core import Document, SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
import pandas as pd
from fastembed import TextEmbedding
import openai
import textwrap

2.2 Conexión a KDB.AI

Almacenamos todos los datos en KDB.AI: cada fragmento junto con su incrustación de 384 dimensiones. Esta configuración nos permite realizar búsquedas de similitud vectorial para identificar rápidamente los trozos más relevantes.

KDB.AI ofrece un excelente nivel gratuito con 4 GB de RAM. Para obtener tus claves API, sólo tienes que registrarte en KDB.AI.

KDBAI_ENDPOINT="KDBAI_ENDPOINT"
KDBAI_API_KEY="KDBAI_API_KEY"

os.environ["OPENAI_API_KEY"] = "OPENAI_API_KEY"
fastembed = TextEmbedding()
KDBAI_TABLE_NAME = "paul_graham"
session = kdbai.Session(endpoint=KDBAI_ENDPOINT, api_key=KDBAI_API_KEY)
database = session.database("default")
# Drop table if exists
try:
database.table(KDBAI_TABLE_NAME).drop()
except kdbai.KDBAIException:
pass
schema = [
dict(name="text", type="bytes"),
dict(name="embedding", type="float32s")
]
indexes = [dict(name="flat_index", column="embedding", type="flat", params=dict(metric="L2", dims=384))]
table = database.create_table(KDBAI_TABLE_NAME, schema=schema, indexes=indexes)

2.3 Preparación de datos: Ensayos de Paul Graham

Obtenemos los ensayos de Paul Graham y los analizamos en fragmentos de unos 500 tokens, con un solapamiento de 100 tokens para preservar el contexto:

!mkdir -p ./data
!llamaindex-cli download-llamadataset PaulGrahamEssayDataset --download-dir ./data

node_parser = SentenceSplitter(chunk_size=500, chunk_overlap=100)
essays = SimpleDirectoryReader(input_dir="./data/source_files").load_data()
docs = node_parser.get_nodes_from_documents(essays)
len(docs)

Incrustamos cada trozo con un modelo local:

embedding_model = TextEmbedding()
documents = [doc.text for doc in docs]
embeddings = list(embedding_model.embed(documents))

records_to_insert_with_embeddings = pd.DataFrame({
"text": [d.encode('utf-8') for d in documents],
"embedding": embeddings
})

table.insert(records_to_insert_with_embeddings)

2.4 Implementación de RAG

Nuestros datos están ahora en nuestra tabla, y podemos consultarlos:

query = "How does Paul Graham decide what to work on?"
query_embedding = list(embedding_model.embed([query]))[0].tolist()

search_results = table.search({"flat_index": [query_embedding]}, n=10)
search_results_df = search_results[0]
df = pd.DataFrame(search_results_df)
df.head(5)
image 31
Imagen: autor

Tenemos los 10 puntos más relevantes para nuestra consulta. A continuación, los introduciremos en el LLM.

2.5 El código de citación

Aquí está la parte que hace el trabajo pesado. Gran parte de este código no es para la generación de citas en sí, sino para mostrar de forma significativa el resultado, lo cual es tedioso en Python.

En primer lugar, tenemos que importar algunas bibliotecas más.

#!/usr/bin/env python3

import os
import re
import json
import openai
import pandas as pd
from typing import List, Dict, Any
from IPython.display import display, HTML

Paso 1: Preparar los datos (dividir el texto en frases)

Antes de llamar al LLM, necesitamos una forma de referenciar frases individuales dentro de los trozos de texto recuperados. Esta función divide un fragmento de texto en frases y asigna metadatos como los desplazamientos de los caracteres iniciales y finales.


################################################################################
# STEP 1: PREPARE DATA
################################################################################

def parse_chunk_into_sentences(chunk_text: str) -> List[Dict[str, Any]]:
"""
Splits 'chunk_text' into naive 'sentences' with start/end offsets.
Returns a list of dicts like:
{
"sentence_id": int,
"text": str,
"start_char": int,
"end_char": int
}
"""
# We'll do a simple regex to split on '.' while capturing the period if it appears
# Then we re-join it. A robust approach might use spacy or NLTK, but for demonstration:
import re
raw_parts = re.split(r'(\.)', chunk_text)

# We'll combine text + punctuation
combined = []
for i in range(0, len(raw_parts), 2):
text_part = raw_parts[i].strip()
punct = ""
if i+1 < len(raw_parts):
punct = raw_parts[i+1]
if text_part or punct:
combined_text = (text_part + punct).strip()
if combined_text:
combined.append(combined_text)

sentences = []
offset = 0
for s_id, s_txt in enumerate(combined, start=1):
start_char = offset
end_char = start_char + len(s_txt)
sentences.append({
"sentence_id": s_id,
"text": s_txt,
"start_char": start_char,
"end_char": end_char
})
offset = end_char + 1 # assume space or newline after each
return sentences

Lo dividimos en frases para que el LLM no sólo pueda citar trozos específicos, sino que devuelva las frases exactas del trozo que son relevantes.

Paso 2: Llamar a OpenAI para generar una respuesta con citas

Ahora que podemos hacer referencia a frases individuales, vamos a consultar al LLM y darle instrucciones para que genere citas en línea.

################################################################################
# STEP 2: CALL OPENAI WITH A ROBUST SYSTEM PROMPT
################################################################################

def call_openai_with_citations(chunks: List[str], user_query: str) -> str:
"""
Asks the LLM to produce a single continuous answer,
referencing chunk_id + sentences range as:
<CIT chunk_id='N' sentences='X-Y'>...some snippet...</CIT>.
"""

# If you want, set your API key in code or rely on environment variable
# openai.api_key = "sk-..."
if not openai.api_key and "OPENAI_API_KEY" in os.environ:
openai.api_key = os.environ["OPENAI_API_KEY"]

# We'll craft a robust system prompt with examples
system_prompt = (
"You have a collection of chunks from a single document, each chunk may have multiple sentences.\n"
"Please write a single continuous answer to the user's question.\n"
"When you reference or rely on a specific portion of a chunk, cite it as:\n"
" <CIT chunk_id='N' sentences='X-Y'>the snippet of your final answer</CIT>\n"
"Where:\n"
" - N is the chunk index.\n"
" - X-Y is the range of sentence numbers within that chunk. Example: 'sentences=2-4'.\n"
" - The text inside <CIT> is part of your answer, not the original chunk text.\n"
" - Keep your answer minimal in whitespace. Do not add extra spaces or line breaks.\n"
" - Only add <CIT> tags around the key phrases of your answer that rely on some chunk.\n"
" E.g. 'He stated <CIT chunk_id='3' sentences='1-2'>it was crucial to experiment early</CIT>.'\n\n"
"Remember: The text inside <CIT> is your final answer's snippet, not the chunk text itself.\n"
"The user question is below."
)

# We just show the user the chunk texts:
chunks_info = "\n\n".join(
f"[Chunk {i}] {chunk}" for i, chunk in enumerate(chunks)
)

# We create the conversation
messages = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": f"{chunks_info}\n\nQuestion: {user_query}\n"
}
]

response = openai.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0.3,
max_tokens=1024
)
return response.choices[0].message.content

Esta función envía una consulta a OpenAI, indicándole que genere una respuesta que incluya citas en línea. La consulta ordena explícitamente al modelo que utilice etiquetas para marcar las referencias, asegurándose de que cada cita incluya tanto el chunk_id correspondiente como el rango de frases específico (sentences=X-Y). Por ejemplo, OpenAI podría devolver una respuesta como:

Paul Graham sugiere que <CIT chunk_id=’2′ sentences=’1–2′>la elección del trabajo debería basarse en la curiosidad</CIT>.

Este enfoque garantiza que la respuesta final sea autocontenida y esté debidamente anotada, lo que permite atribuir la información con precisión.

Paso 3: Analizar la respuesta LLM para extraer citas

Una vez que OpenAI devuelve una respuesta, tenemos que analizar las etiquetas de las citas y extraer los datos estructurados.

################################################################################
# STEP 3: PARSE THE LLM RESPONSE
################################################################################

def parse_response_with_sentence_range(response_text: str) -> Dict[str, Any]:
"""
Produce a single block with:
{
"type": "text",
"text": <the final answer minus CIT tags but with snippet inline>,
"citations": [
{
"chunk_id": int,
"sentences_range": "X-Y",
"answer_snippet": snippet,
"answer_snippet_start": int,
"answer_snippet_end": int
},
...
]
}
"""
pattern = re.compile(
r'(.*?)<CIT\s+chunk_id=[\'"](\d+)[\'"]\s+sentences=[\'"](\d+-\d+)[\'"]>(.*?)(?:</CIT>|(?=<CIT)|$)',
re.DOTALL
)
final_text = ""
citations = []
idx = 0

while True:
match = pattern.search(response_text, idx)
if not match:
# leftover
leftover = response_text[idx:]
final_text += leftover
break

text_before = match.group(1)
chunk_id_str = match.group(2)
sent_range = match.group(3)
snippet = match.group(4)

final_text += text_before

start_in_answer = len(final_text)
final_text += snippet
end_in_answer = len(final_text)

citations.append({
"chunk_id": int(chunk_id_str),
"sentences_range": sent_range,
"answer_snippet": snippet,
"answer_snippet_start": start_in_answer,
"answer_snippet_end": end_in_answer
})

idx = match.end()

return {
"type": "text",
"text": final_text,
"citations": citations
}

Esta función extrae y estructura las citas de la respuesta LLM identificando las etiquetas mediante un patrón regex. Elimina estas etiquetas del texto final mientras almacena metadatos como chunk_id, rango de frases y posición del fragmento por separado. El resultado es un diccionario con la respuesta depurada y una lista de citas, lo que permite una asignación precisa de las referencias para una visualización sencilla.

Paso 4: Comparación de las frases citadas y búsqueda de rangos de caracteres

Una vez extraídas las citas de la respuesta del LLM, debemos cotejarlas con los fragmentos de texto originales. Este paso garantiza que cada referencia de la respuesta se corresponda exactamente con su fuente. La siguiente función busca el chunk_id citado y el intervalo de frases, recupera las frases relevantes de nuestro texto indexado y registra sus desplazamientos exactos entre caracteres. Esto nos permite mostrar referencias precisas sin incluir información irrelevante.

################################################################################
# STEP 4: MATCH CITED SENTENCES + FIND CHAR RANGES IN CHUNK
################################################################################

def gather_sentence_data_for_citations(block: Dict[str, Any], sentence_map: Dict[int, List[Dict[str, Any]]]) -> Dict[str, Any]:
"""
For each citation, parse the chunk_id + sentences='X-Y'.
Gather the text of those sentences from 'sentence_map[chunk_id]'
and record their combined text plus start/end offsets in the chunk.
"""
for c in block["citations"]:
c_id = c["chunk_id"]
sent_range = c["sentences_range"]
try:
start_sent, end_sent = map(int, sent_range.split("-"))
except:
start_sent, end_sent = 1, 1

# get the sentence list for that chunk
sents_for_chunk = sentence_map.get(c_id, [])
# filter the range
relevant_sents = [s for s in sents_for_chunk if start_sent <= s["sentence_id"] <= end_sent]

if relevant_sents:
combined_text = " ".join(s["text"] for s in relevant_sents)
chunk_start_char = relevant_sents[0]["start_char"]
chunk_end_char = relevant_sents[-1]["end_char"]
else:
combined_text = ""
chunk_start_char = -1
chunk_end_char = -1

c["chunk_sentences_text"] = combined_text
c["chunk_sentences_start"] = chunk_start_char
c["chunk_sentences_end"] = chunk_end_char

return block

################################################################################
# STEP 5: BUILD HTML FOR DISPLAY
################################################################################

def build_html_for_block(block: Dict[str, Any]) -> str:
"""
Build an HTML string that underlines each snippet in the final answer
and shows a tooltip with 'chunk_sentences_text' plus start/end offsets.
"""
css = """
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
line-height: 1.6;
}
.tooltip {
position: relative;
text-decoration: underline dotted;
cursor: help;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 400px;
background: #f9f9f9;
color: #333;
text-align: left;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
position: absolute;
z-index: 1;
top: 125%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
</style>
"""

full_text = block["text"]
citations = sorted(block["citations"], key=lambda x: x["answer_snippet_start"])

html_parts = [f"<!DOCTYPE html><html><head><meta charset='UTF-8'>{css}</head><body>"]
cursor = 0

for cit in citations:
st = cit["answer_snippet_start"]
en = cit["answer_snippet_end"]

if st > cursor:
html_parts.append(full_text[cursor:st])

snippet_text = full_text[st:en]

# Build tooltip with chunk sentences
tooltip_html = f"""
<span class="tooltip">
{snippet_text}
<span class="tooltiptext">
<strong>Chunk ID:</strong> {cit["chunk_id"]}<br>
<strong>Sentence Range:</strong> {cit["sentences_range"]}<br>
<strong>Chunk Sentences Offset:</strong> {cit["chunk_sentences_start"]}-{cit["chunk_sentences_end"]}<br>
<strong>Chunk Sentences Text:</strong> {cit["chunk_sentences_text"]}
</span>
</span>
"""
html_parts.append(tooltip_html)
cursor = en

if cursor < len(full_text):
html_parts.append(full_text[cursor:])

html_parts.append("</body></html>")
return "".join(html_parts)

def display_html_block(block: Dict[str, Any]):
from IPython.display import display, HTML
html_str = build_html_for_block(block)
display(HTML(html_str))

¿Por qué es importante este paso?

Al vincular cada cita con su frase exacta y sus caracteres, nos aseguramos de que las referencias que aparecen en la respuesta final son precisas y pertinentes en su contexto. Esto evita que las citas sean demasiado amplias o engañosas, lo que hace que los resultados sean más transparentes y fiables.
Ahora que las referencias están estructuradas correctamente, el siguiente paso es construir una representación visual que permita a los usuarios interactuar con las citas.

5. Ejecución de la pipeline

Pongamos todo junto. Vamos a ejecutar nuestra función principal que hace RAG con nuestra consulta y muestra el resultado.

################################################################################
# PUTTING IT ALL TOGETHER
################################################################################

def main(df, user_query: str):
"""
Full pipeline:
1) We'll parse each chunk into sentences.
2) We'll call openai with a robust system prompt for <CIT> usage.
3) We'll parse the LLM's response for chunk_id + sentences='X-Y'.
4) We'll gather the chunk sentences text, produce a final block with citations.
5) We'll build HTML and display in Colab.
"""

# 1) Prepare chunk data
# - We'll assume df has columns: chunk_id, text
# - We'll parse each chunk into sentence_map
sentence_map = {}
chunk_texts = []
max_chunk_id = df["chunk_id"].max()
for i, row in df.iterrows():
c_id = row["chunk_id"]
c_txt = row["text"]
# build the sentence parse
sents = parse_chunk_into_sentences(c_txt)
sentence_map[c_id] = sents
# We'll store chunk_texts in an array in chunk_id order
# If chunk_id is not sequential from 0..N, you might do a dict.
# But let's do the simplest approach.
# We'll expand chunk_texts if needed
if len(chunk_texts) <= c_id:
chunk_texts.extend([""]*(c_id - len(chunk_texts)+1))
chunk_texts[c_id] = c_txt

# 2) Call LLM
answer_text = call_openai_with_citations(chunk_texts, user_query)

# 3) Parse the response
block = parse_response_with_sentence_range(answer_text)

# 4) Enrich each citation with chunk sentences
block = gather_sentence_data_for_citations(block, sentence_map)

# 5) Display final result
print("----- JSON OUTPUT -----")
print(json.dumps({"content": [block]}, indent=2, ensure_ascii=False))

display_html_block(block)

Ahora tenemos:

  1. La respuesta final consolidada del LLM, menos las etiquetas <CIT> en el texto mostrado.
  2. Fragmentos subrayados (donde estaba <CIT> ) que muestran un tooltip con el texto exacto del fragmento.
LLM
Fuente de la imagen: autor

Como puedes ver, cuando pasamos el ratón sobre una frase, podemos ver el fragmento exacto que está citando, así como las frases exactas relevantes de ese fragmento para la respuesta RAG. Obtenemos una cantidad extrema de granularidad, lo que significa que podemos mostrar el texto fuente al usuario sin preocuparnos de que se muestre información irrelevante.

Aunque gran parte de este código es para mostrar la respuesta RAG con citas de una manera significativa, terminamos con JSON que se puede mostrar mucho más fácilmente en una aplicación web:

Anthropic
Fuente de la imagen: autor

3. ¿Por qué este enfoque en línea de una sola pasada?

Una alternativa común es el enfoque en dos pasos:

  1. Pedir al modelo que produzca la mejor respuesta final, sin referencias.
  2. Volver a pasar esa respuesta final más los trozos superiores al modelo, preguntando «¿de dónde viene cada trozo?».

Ventajas: Posiblemente referencias más precisas.
Contras: El doble de llamadas LLM, análisis más complicado, y es posible que tenga que manejar solapamientos parciales.

Es probable que Anthropic utilice un enfoque de una sola pasada para las citas porque es más sencillo y, si su modelo está bien entrenado, las referencias pueden seguir siendo bastante precisas. Pero es posible que de vez en cuando se produzcan desajustes. Así es la vida en RAG.

4. Wrap Up

Hemos superado la principal limitación: El enfoque «no lo hagas tú mismo» de Anthropic es genial si confías en Claude. Pero si quieres replicarlo en GPT-4o o en cualquier otro modelo, puedes hacerlo absolutamente:

  1. Fragmentando el texto a nivel de frase (opcional)
  2. Decirle al LLM que etiquete cada fragmento de la respuesta final con etiquetas que hagan referencia al ID del fragmento + rango de la frase.
  3. Analizar el resultado y crear una interfaz de usuario interactiva.

Sí, este código es más complicado que alternar un solo parámetro en la API de Anthropic – y verás casos extremos. Pero funciona con cualquier LLM. Un día, tal vez OpenAI (o una biblioteca) lanzará citas oficiales para GPT. Hasta entonces, tienes un plano para construir el tuyo propio.

Si tienes preguntas o te enfrentas a retos interesantes, no dudes en ponerte en contacto con nosotros. Y si quieres recibir regularmente contenido de vanguardia sobre RAG/LLM, sígueme en LinkedIn.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *