Procesamiento de archivos¶
Este documento cubre cómo se tratan los archivos en dos contextos: las subidas de archivos en el chat, que pertenecen a la persona que las hizo, y la ingesta de documentos RAG, que pertenece a una colección y depende de quién puede llegar a ella.
Subidas de archivos en el chat¶
Cuando alguien sube un archivo en la interfaz de chat, se ejecuta esta pipeline:
El flujo¶
flowchart TD
U["Upload<br/><code>POST /api/v1/files/upload</code>"] --> V["Validate<br/>MIME against the allowed list, size limit"]
V --> C["Classify<br/>image · pdf · docx · spreadsheet · text"]
C --> P["Parse<br/>extract text — images skip this"]
P --> S["Store<br/><code>media/{user_id}/</code>"]
S --> R["Record<br/>a <code>ChatFile</code> row"]
R --> L["Link<br/>attached to the message by <code>message_id</code>"]
L --> D["Display<br/>a card per attachment: name, excerpt, type, size"]
La respuesta de la subida lleva un preview — las tres primeras líneas del
texto extraído, acotadas a 240 caracteres — para que la tarjeta muestre qué hay
dentro del archivo y no solo cómo se llama. El navegador no puede derivarlo:
un PDF son bytes hasta que este servicio lo ha parseado, y el cliente solo tiene
un id y un nombre cuando la subida ha respondido. Es null para una imagen y
para un archivo que ningún parser pudo leer, y una tarjeta sin extracto muestra
su miniatura o solo su nombre.
El trabajo bloqueante sale del bucle de peticiones, a su propio pool¶
Parsear una subida — PyMuPDF sobre cada página, openpyxl sobre cada celda, una decodificación del archivo entero — y leer o escribir sus bytes son operaciones bloqueantes sin punto de suspensión. Por eso se ejecutan en un hilo y no en el bucle de peticiones; si no, una subida grande congelaría todas las demás peticiones y streams de agents del worker.
Se ejecutan en un pool dedicado y acotado (app/core/blocking.py,
dimensionado por FILE_IO_MAX_WORKERS), no en el executor por defecto compartido
de asyncio. Ese executor también carga con el hash de contraseñas de bcrypt y
con el DNS de hosts fijados, y una ráfaga de subidas no debe ocupar todos sus
workers y dejar el inicio de sesión y las peticiones salientes en cola detrás de
un backlog sin límite de búferes de subida
(#1108).
La escritura además es segura ante la cancelación. Un executor no puede
interrumpir un write_bytes en curso, así que una subida cancelada espera a que
la escritura termine y borra el archivo que creó — el llamante nunca recibe una
ruta de almacenamiento, así que de otro modo no podría ni registrar ni limpiar el
huérfano.
Toda la página es la zona de soltado¶
Un archivo arrastrado sobre el chat se acepta en cualquier punto de ella, no
sobre el campo de redacción. El campo de redacción era el único destino, lo que
convertía adjuntar algo en un juego de acertar en una franja de pocos
centímetros — y fallar no era inocuo: lo que el navegador hace por defecto con un
archivo soltado es abrirlo, así que la pestaña se iba de la conversación y de
lo que hubiera a medio escribir en ella. El mismo preventDefault que deja a la
página quedarse el archivo es el que impide que se lo quede el navegador, así que
escuchar en la ventana arregla las dos mitades a la vez.
Mientras un archivo está sobre la página, una capa la cubre: el fondo
desenfocado, una tarjeta punteada en el centro y el límite de tamaño por archivo
escrito en ella — un vídeo de 60 MB rechazado después del arrastre es un viaje
de ida y vuelta que nadie necesitaba hacer. Se monta por portal en el body en vez
de posicionarse desde el campo de redacción, porque fixed se mide contra el
ancestro transformado más cercano y un solo backdrop-blur en un wrapper de
arriba encogería la capa a una esquina sin decir nada.
Hay dos cosas que deliberadamente no hace. Un arrastre que lleva algo que no son archivos — texto seleccionado, un enlace, una de las propias filas arrastrables de la app — se deja completamente en paz, ni siquiera se previene. Y no se acepta nada mientras el campo de redacción está deshabilitado: una conversación archivada, un run esperando una aprobación. Que la capa no aparezca es lo que lo dice.
Un pegado largo es un archivo¶
Pegar más de 2000 caracteres en el campo de redacción sube el texto como
pasted-<date>.txt en lugar de insertarlo. El textarea se queda intacto, así que
la pregunta se escribe al lado de aquello sobre lo que trata, y la transcripción
guarda un adjunto en vez de una burbuja enorme.
El umbral es todo el diseño. Quien pega un párrafo y pulsa enter quería que eso fuera el mensaje, así que queda por encima de cualquier cosa que una persona pegaría como pregunta — unas 350 palabras — y por debajo de cualquier documento. Por debajo de él no cambió nada: el texto cae en el textarea como siempre.
A partir de ahí es un adjunto text/plain normal y todo lo de abajo le aplica
sin cambios, que es la gracia: un agent con un workspace recibe el pegado como un
archivo que puede abrir, y uno sin workspace recibe el texto en su prompt.
Tipos de archivo admitidos¶
| Categoría | Tipos MIME | Extensiones | Procesamiento |
|---|---|---|---|
| Imágenes | image/jpeg, image/png, image/webp, image/gif | .jpg, .png, .webp, .gif | Se guardan tal cual. Se envían al LLM como BinaryContent para análisis de visión. |
| application/pdf | Texto extraído con el parser de PDF configurado. Se añade al prompt como contexto. | ||
| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document | .docx | Párrafos extraídos con python-docx. Se añaden al prompt como contexto. |
| Hoja de cálculo | …spreadsheetml.sheet, …ms-excel.sheet.macroEnabled.12 | .xlsx, .xlsm | Cada hoja se lee con openpyxl, se nombra y sus filas se separan por tabuladores. Se añade al prompt como contexto. .xls se rechaza — es otro formato y necesita otro lector. |
| Texto | text/plain, text/markdown | .txt, .md | Se decodifica directamente como UTF-8. Se añade al prompt como contexto. |
Adónde va un adjunto depende del agent¶
La columna "se añade al prompt" de arriba es lo que le pasa a un agent sin workspace, y es el archivo entero, en cada turno. Un informe de doscientas páginas cuesta todo su peso en tokens cuando el usuario hace la primera pregunta y otra vez cuando pregunta "¿y qué tal marzo?"; un CSV de cincuenta megabytes no se puede adjuntar siquiera.
Un agent con la capability sandbox
recibe el archivo en lugar del texto:
| Adjunto | Sin workspace | Con un workspace |
|---|---|---|
| text, csv, md, json | texto parseado pegado en línea | escrito en uploads/, el mensaje lleva una referencia y las 20 primeras líneas |
| pdf, docx, hoja de cálculo | texto parseado pegado en línea | escrito en uploads/, con el texto extraído al lado salvo que el runtime pueda leerlo; referencia y 20 primeras líneas |
| imagen | BinaryContent |
BinaryContent y escrita; la referencia nombra la ruta |
El texto extraído lo acompaña solo cuando nada puede leer el original. Antes
se escribía un .txt del parseo junto a cada PDF, .docx y hoja de cálculo,
con el razonamiento de que un shell no tiene librería para ninguno de ellos —
read_file sobre un .xlsx devuelve galimatías, y run_python no tiene sistema
de archivos. En un runtime que lleva lit ese razonamiento está caduco: lit
parse q3.xlsx -o q3.md es un solo comando, con OCR para un escaneo y LibreOffice
para los formatos heredados (sandbox.md), así que el archivo hermano es una
segunda copia del contenido en disco para ahorrar una llamada de herramienta.
Se sigue escribiendo en todos los demás casos, y el predicado no es el tipo de
backend: un workspace state son archivos sin ningún shell, una sandbox de
Daytona y el runtime propio de un deployment llevan lo que lleve su imagen, y
ninguno tiene lit. La condición es si este deployment le describió el runtime
al modelo — la misma información que el run añade a sus instrucciones. Si se le
dijo que tiene lit, no hay hermano; en cualquier otro caso, el texto va al lado
del archivo.
El parseo sigue ocurriendo en el servidor de todos modos, porque el texto es lo que recibe un agent sin workspace y de donde salen las 20 líneas del mensaje. Aceptar la subida sin parsear llegaría a un agent con workspace como bytes ilegibles y a uno sin workspace como nada en absoluto.
Una escritura rechazada se dice una vez, sobre el workspace. Un run cuyo
workspace no admite un archivo es un run cuyo shell y herramientas de archivo
también fallarán, y una línea por archivo no puede decir eso: un turno leyó cada
fallo como un problema con el comando que acababa de escribir y siguió
intentándolo — ls, luego un curl de una URI data:, luego tres soluciones
propuestas a la persona, a lo largo de dos turnos. Ahora una sola frase dice que
el workspace no está disponible y que otro intento fallará igual.
La referencia es lo que el modelo lee de verdad:
Attached file: raport.csv (/uploads/3f2a1b9c-raport.csv, 2.4 MB, text)
First 20 lines:
month,total
jan,10
...
Suficiente para distinguir una exportación de ventas de un log y para ver los nombres de las columnas — que es lo que el modelo necesita para decidir si leer el resto vale una llamada de herramienta. El archivo ha dejado de ser contexto y se ha convertido en datos.
Cuatro cosas de esto son deliberadas:
- Las imágenes van por los dos caminos. El modelo todavía tiene que ver la
imagen — para eso sirve un modelo multimodal, y una cadena con una ruta no la
sustituye — y además tiene que poder redimensionarla o recortarla, lo que exige
bytes en un sistema de archivos. Por encima de
SANDBOX_INLINE_IMAGE_MAX_BYTESsolo se guarda el archivo, porque pasado ese punto pagar dos veces por los bytes deja de compensar. - Un PDF recibe las dos mitades. Los bytes son lo que una persona pidió que se le diera; el texto que esta plataforma ya extrajo es la mitad que un shell puede leer de verdad.
- El mismo archivo se escribe una vez. La ruta se deriva del id del
ChatFile, así que readjuntarlo en el turno cinco resuelve a la ruta que ya tiene — una subida cuesta una escritura, no una por turno durante el resto de la conversación. - No se confía en el nombre del archivo.
../../etc/passwdse convierte enetc_passwd; dos archivos llamadosreport.csvno pueden sobrescribirse.
Un archivo que no se puede almacenar — un workspace lleno — cae de vuelta al camino en línea en lugar de desaparecer, y uno que el almacén de archivos no puede cargar se omite en vez de hacer fallar el turno: la persona hizo una pregunta, y responder sin el adjunto es mejor que no responder.
El enrutado ocurre en app/services/attachments.py, llamado desde el runner del
chat y no desde cada superficie. Tiene que estar ahí: adónde va un archivo
depende de si el agent tiene workspace, y eso lo decide prepare, que no se ha
ejecutado cuando una superficie está montando su prompt.
Parseo de PDF (chat)¶
Los adjuntos del chat se leen con PyMuPDF, y eso no es configurable. Un adjunto no pertenece a ninguna colección, así que no hay configuración guardada de la que leer una elección de parser.
La variable CHAT_PDF_PARSER que antes elegía entre tres parsers ya no existe.
Las dos alternativas estaban envueltas en except Exception: return
self._parse_pdf_pymupdf(data), así que un deployment que la pusiera en
llamaparse o liteparse llevaba usando PyMuPDF igualmente sin decir nada — y
la rama de LiteParse no podría haber funcionado en absoluto, porque llamaba a un
método parse_async que el binding no define.
Límites de tamaño¶
Dos techos, y el navegador tiene su propia copia de uno
Un adjunto de chat lo rechaza CHAT_MAX_UPLOAD_SIZE_MB (10 MB); un documento
de la base de conocimiento, MAX_UPLOAD_SIZE_MB (50 MB). El contenedor del
frontend lee el mismo CHAT_MAX_UPLOAD_SIZE_MB en tiempo de ejecución, así
que dale un solo valor a los dos contenedores: demasiado alto en el lado del
navegador y el campo de redacción acepta un archivo que la API rechaza,
demasiado bajo y rechaza uno que la API aceptaría.
- Tamaño máximo de un adjunto:
CHAT_MAX_UPLOAD_SIZE_MB(por defecto: 10 MB). Este es el límite propio de esta sección — un adjunto de chat lo rechaza este número, no elMAX_UPLOAD_SIZE_MBmás grande de la base de conocimiento, y son dos ajustes separados porque un adjunto para un agent sin workspace se pega entero en el prompt mientras que un documento de la base de conocimiento se fragmenta y se convierte en embeddings. - Un documento de la base de conocimiento lo acota
MAX_UPLOAD_SIZE_MB(por defecto: 50 MB). - El cuerpo entero de la petición está acotado por encima de ambos, en el mayor de los dos más un margen para multipart, así que subir cualquiera de los dos techos sube ese con él.
- El límite se aplica en el servidor tras leer el contenido del archivo. La
comprobación propia del navegador lee el mismo
CHAT_MAX_UPLOAD_SIZE_MBdel entorno del contenedor del frontend, así que a los dos contenedores hay que darles un solo valor: demasiado alto y el campo de redacción acepta un archivo que la API rechaza, demasiado bajo y rechaza uno que la API aceptaría.
Almacenamiento¶
FileStorageService guarda los archivos en el directorio media/:
El modelo ChatFile¶
El modelo de base de datos ChatFile registra los archivos subidos:
| Campo | Tipo | Descripción |
|---|---|---|
id |
UUID | Clave primaria |
user_id |
UUID/FK | Propietario (se usa para el control de acceso) |
filename |
String | Nombre original del archivo |
mime_type |
String | Tipo MIME (p. ej. application/pdf) |
size |
Integer | Tamaño del archivo en bytes |
storage_path |
String | Ruta relativa en el almacenamiento |
file_type |
String | Tipo clasificado: image, pdf, docx, spreadsheet, text |
parsed_content |
Text | Texto extraído (NULL para imágenes) |
message_id |
UUID/FK | Mensaje enlazado (se fija al enviar el mensaje) |
created_at |
DateTime | Momento de la subida |
Propiedad y acceso¶
- Solo el propietario del archivo puede descargar sus archivos (
GET /files/{id}). - El método
FileUploadService.get_user_file()comparachat_file.user_idcon el ID del usuario que hace la petición. DevuelveNotFoundErrorsi no coinciden. - La propiedad es toda la regla, y nada la amplía. Ningún permiso, ningún rol
de organización y ningún grant llega al archivo de chat de otra persona por
esta API — a diferencia de una colección, que un grant sí puede abrir. La
comparación es contra
user_idy no hay una segunda rama que cubra un caso más amplio. - El paso de enlazado sigue la misma regla. Un mensaje adjunta únicamente los archivos sin enlazar del propio remitente: un id que nombra el archivo de otro usuario, o uno que ya está en un mensaje, se rechaza en lugar de aplicarse en silencio — así que un turno no puede ni mostrar el nombre de archivo de un desconocido ni arrancar un adjunto del mensaje del que ya cuelga.
Ingesta de documentos RAG¶
Cuando se ingieren documentos en la base de conocimiento RAG (por CLI o por API), una pipeline distinta se encarga del parseo, la fragmentación y los embeddings.
El flujo de ingesta¶
flowchart TD
I["Input<br/>a path (CLI) or an upload (API)"] --> P["Parse<br/><code>DocumentProcessor</code> picks a parser by type"]
P --> C["Chunk<br/>size, overlap and strategy are configurable"]
C --> E["Embed<br/>through the collection's provider"]
E --> S["Store<br/>vectors in <code>rag_<collection></code>"]
S --> T["Track<br/>a <code>RAGDocument</code> row carries the status"]
Por la API el orden es el contrario
La fila RAGDocument se escribe primero y los cuatro pasos intermedios
se ejecutan en una tarea en segundo plano con una sesión propia - por eso una
subida responde 202 {"status": "processing"} en vez de esperar.
Hay dos direcciones a las que puede llegar una subida —
POST /rag/collections/{name}/ingest y POST /kb/{kb_id}/documents — y las dos
responden 202 con el mismo RAGIngestResponse, con todos sus campos,
"document_id": null incluido. El id del documento en el almacén de vectores no
existe hasta que el worker lo ha indexado.
Una de las dos omitía la clave en lugar de enviarla nula, así que un cliente que normalizara la respuesta recibía una forma distinta de cada una (#560).
La tarea se inicia después de que la transacción de la petición haga commit
Se entrega con spawn_after_commit, no con spawn, y la inicia la
propia sesión una vez que la fila es duradera.
Despachada antes, buscaría el documento por id, no encontraría nada y se
detendría — dejando para siempre en processing la subida que ya había
confirmado
(#417).
Lo mismo vale para una sincronización: la fila SyncLog existe antes que su
flow. Consulta
Despachar trabajo en segundo plano desde una petición.
Cada flow construye su propio engine para el almacén de vectores, y lo descarta junto con el trabajo del flow.
Un solo bucle es dueño de los pools del proceso, y todo lo demás construye su
propio engine. El lifespan de la API los reclama al arrancar: atiende cada
petición y los descarta al apagarse, así que es el único bucle cuyas conexiones
pueden cachear. Fuera de ese bucle get_db_context se comporta como
get_worker_db_context — un engine con NullPool para la llamada, descartado al
final — porque se llega a él desde flows de Prefect (el informe, el refresco de
MCP, las tareas de invitación y de aprobación, los bucles de canal) y desde el
resolvedor de embeddings de un agent, cada uno en un bucle propio
(#1079).
La búsqueda de conocimiento de un agent sigue la misma regla para su almacén de
vectores: el almacén del proceso en el bucle propietario, y agent_vector_engine
— sin pool — en cualquier otro sitio. Es el único llamante de vectores que no
puede saber en qué bucle está, y su almacén de recuperación se cachea durante
toda la vida del proceso, así que un almacén con pool compartido entre dos bucles
de un worker le entrega al segundo una conexión que abrió el primero. Conservar
el pool para la API es lo que acota esto: NullPool abre una conexión por
checkout y no limita nada, mientras que el pool hace cola en
DB_POOL_SIZE + DB_MAX_OVERFLOW.
Formatos admitidos¶
.txt, .md y .docx los leen los parsers integrados de Python sea cual sea el
parser de la colección. Más allá de esos, el conjunto sigue al parser:
| Parser | Lee además | Necesita |
|---|---|---|
| PyMuPDF | .pdf |
nada |
| LiteParse | .pdf; imágenes (.png, .jpg, .tiff, .svg, …); formatos ofimáticos (.xlsx, .pptx, .odt, .csv, .rtf, …) |
LibreOffice solo para los formatos ofimáticos — las imágenes se convierten de forma nativa |
| LlamaParse | .pdf, .pptx, .xlsx, .csv, .rtf, .epub, .html, imágenes |
Una clave de LlamaParse en el vault de la organización, nombrada por la colección (llamaparse_secret_id). No hay clave de deployment |
El Dockerfile del backend instala LibreOffice y Tesseract, así que los formatos ofimáticos y el OCR funcionan de serie en un contenedor. Si ejecutas el backend fuera de Docker, una subida ofimática a una colección con LiteParse se rechaza con un mensaje que nombra LibreOffice en vez de fallar durante la conversión.
GET /api/v1/rag/supported-formats?parser=liteparse responde para un parser.
Estos conjuntos son lo que DocumentProcessor puede enrutar de verdad — fijados
por backend/tests/test_supported_formats.py, porque antes eran aspiracionales:
un .xlsx se aceptaba, se almacenaba, recibía una fila de documento y se
despachaba, y luego moría en un worker como "Unsupported file type".
Elección de parser (RAG)¶
Por colección, en /rag, y se puede sobrescribir por subida — no es una variable
de entorno. Se guarda en knowledge_bases.ingestion_config.
| Parser | Mejor para |
|---|---|
| PyMuPDF (por defecto) | Procesamiento local rápido, documentos con mucho texto; el único que extrae imágenes incrustadas para describirlas |
| LiteParse | Local, sin clave, consciente del diseño; lee formatos ofimáticos e imágenes; salida en markdown |
| LlamaParse | Diseños complejos y PDF escaneados; en la nube, facturado por página |
Opciones de LiteParse¶
| Ajuste | Por defecto | Notas |
|---|---|---|
liteparse_output_format |
markdown |
Reconstruye encabezados, tablas y listas — aquello por lo que corta la estrategia de fragmentación markdown. text conserva la rejilla espacial. |
auto_ocr |
true |
Ejecuta la comprobación barata de capa de texto de LiteParse por documento y aplica OCR solo a lo que lo necesita. El OCR domina el coste de un parseo. |
ocr_language |
eng |
Códigos de Tesseract — tres letras, unidos con + para varios (eng+pol). Un idioma sin su paquete instalado no lee nada; añade tesseract-ocr-<lang> al Dockerfile. |
liteparse_dpi |
150 |
Más alto lee escaneos tenues, más lento. |
max_pages |
1000 |
El ajuste que acota el coste de un documento; parse_timeout_seconds solo acota la espera. |
Configuración de la fragmentación¶
Por colección, junto al parser:
| Ajuste | Por defecto | Descripción |
|---|---|---|
chunk_size |
512 |
Caracteres máximos por fragmento |
chunk_overlap |
50 |
Caracteres de solapamiento; tiene que ser menor que chunk_size |
chunking_strategy |
recursive |
Estrategia: recursive, markdown, fixed |
Comparación de estrategias:
| Estrategia | Mejor para |
|---|---|
recursive |
Texto general; corta por párrafo, luego por línea, luego por palabra, luego por carácter |
markdown |
Documentos markdown o estructurados; corta en los límites de encabezado y luego por tamaño dentro de cada sección |
fixed |
Fragmentos de tamaño uniforme; corta solo en fin de línea, así que una línea larga se emite entera |
Las tres vienen de app/services/rag/_splitters.py, que sustituyó a
langchain-text-splitters en #158
— los ocho paquetes que había detrás incluían langsmith, un segundo SDK de
telemetría alojada en una plataforma que se estandarizó sobre Logfire.
Hay tres cosas que conviene saber sobre ellas antes de tocar los números:
chunk_overlapes un techo, no una garantía. Un fragmento repite del anterior tanto como quepa por debajo dechunk_size, lo que a menudo es menos que el ajuste y a veces nada en absoluto.- Un trozo sin separador que le quede se emite entero, no cortado.
fixedcorta solo en fin de línea, así que una línea de 4 KB se convierte en un fragmento de 4 KB; el splitter registra un aviso en vez de entregarle al modelo de embeddings algo que va a rechazar. El aviso significa por encima dechunk_size— una línea de exactamentechunk_sizecaracteres está dentro del límite y pasa en silencio. markdownconserva el encabezado en el fragmento, y hasta #158 no aplicaba nichunk_sizenichunk_overlap— una sección de 50 KB entre dos##era un solo fragmento. Ahora ejecuta el splitter recursivo sobre cada sección, así que los dos ajustes significan en esta estrategia lo mismo que en las otras.
Los límites de los fragmentos son contra lo que casa una búsqueda, así que una
colección ingerida antes de ese cambio conserva los fragmentos con los que se
ingirió. Vuelve a subir un documento, o vuelve a ejecutar
uv run agenticos cmd rag-ingest, para refragmentarlo.
Cuántos fragmentos tiene un documento decide cuánto tarda en guardarse, pero ya no cuántos viajes de ida y vuelta cuesta.
insert_document los escribe de 200 filas por sentencia (executemany, que
asyncpg encadena en pipeline). Antes emitía un INSERT por fragmento en un bucle
de Python dentro de una transacción abierta — así que un PDF de 200 páginas con
el chunk_size por defecto eran de mil a tres mil viajes secuenciales: de cinco
a quince segundos contra un Postgres gestionado a 3-5 ms, antes de pagar un solo
embedding (#950).
Se hace por lotes y no en una sola sentencia para todo el documento porque la lista de parámetros se mantiene en memoria y cada fila lleva su embedding renderizado como texto — con 3072 dimensiones, decenas de kilobytes por fila.
Una sobrescritura se comprueba contra el par ya combinado, no contra su propio valor.
Un campo ingestion por subida lleva solo lo que cambia, así que un
chunk_overlap: 4096 enviado a una colección que fragmenta a 512 son dos números
legales por separado y una configuración que repite casi todo lo que avanza.
La combinación vuelve a validar, y la subida se rechaza con un 400 que nombra
los dos ajustes en details.fields — antes de almacenar el archivo y antes de
que exista una fila de documento, así que no hay nada que reintentar ni limpiar.
Respondía 500 con un details vacío hasta
#874: la combinación lanzaba
un error de Pydantic en crudo, que no llega a ningún handler. El mismo par
enviado como configuración propia de una colección siempre se rechazaba con un
422, porque ahí es un campo de un cuerpo JSON y FastAPI lo valida antes de entrar
en la ruta.
Los dos rechazos nombran los mismos campos, ingestion_config para la regla del
par e ingestion_config.chunk_size para un ajuste suelto — así que el formulario
marca un solo sitio sea cual sea el punto de entrada que rechazó. (La regla del
par nombra el objeto porque Pydantic no atribuye un
model_validator(mode="after") a ninguno de los dos campos de los que trata.) El
400 nombraba sus campos bajo details.errors hasta
#882, en el formato de error
propio de Pydantic, que nada del frontend leía: la frase llegaba a un toast y
nunca se resaltaba ningún input.
Embeddings — el modelo, qué endpoint responde y qué clave paga¶
Los tres se deciden por colección, no por deployment, en
app/services/embedding_resolution.py sobre el catálogo de
app/core/catalog/embedding_providers.json:
| Modelo y anchura | Se registran en la base de conocimiento al crearla (embedding_model, embedding_dim) y no cambian nunca después — PgVectorStore escribe embedding vector(N) una sola vez, así que un segundo modelo o no se puede escribir o se compara en silencio con vectores de otro espacio. Una colección nueva elige uno de los modelos que sirve su provider; no hay valor por defecto del deployment. |
| Provider | Qué endpoint compatible con OpenAI sirve ese modelo (embedding_provider). Es modificable, a diferencia del modelo: el mismo modelo con la misma anchura produce vectores del mismo espacio se sirva desde donde se sirva, así que PATCH /kb/{id} mueve una colección entre providers y deja válido todo lo ya indexado. |
| Credencial | La clave del vault elegida en la colección (embedding_secret_id), que es lo que se le factura a la organización y que tiene que ser una clave de ese provider. No hay clave de embeddings a nivel de deployment: una colección nueva personal o de organización tiene que nombrar una, y una colección sin clave utilizable rechaza indexar y buscar hasta que tenga una. El provider ollama es sin clave — un Ollama en la red del propio deployment — así que una colección en él no nombra ninguna clave y se rechaza si lo intenta; nombra en su lugar un servicio local (embedding_endpoint_id), una fila bajo Knowledge → Integrations que lleva la dirección, la propia de la organización o una de todo el deployment que haya registrado el administrador de la aplicación. Una colección app-scoped no pertenece a ninguna organización y por tanto no tiene vault del que nombrar una clave; solo puede generar embeddings a través de un provider sin clave en un servicio de todo el deployment, y elegir uno con clave se rechaza donde se eligió el provider. |
Qué base de conocimiento resuelve un nombre de colección es cuestión de tenants.
collection_name está indexado pero no es único — dos organizaciones pueden
llamar igual a una colección y compartir tabla — así que la resolución se acota a
la organización del embedding: la del flow que ingiere, la del agent que busca.
Quedarse con la primera fila que ordene la base de datos resolvería el modelo de
otro tenant y abriría su clave del vault: se le factura, y el texto de esta
organización pasa por ella
(#913). Un nombre compartido
resuelve así la configuración de cada una, cayendo a una colección de ámbito app,
nunca a la de un tercero.
Un nombre de colección es un espacio de embeddings
Resolver por organización solo es seguro porque todas las filas con un mismo nombre de colección coinciden en cómo generan embeddings. Una base de conocimiento creada contra un nombre que ya existe adopta el modelo, la anchura, el provider y la clave del vault de esa colección, y una elección explícita que discrepe se rechaza en vez de sobrescribirse calladamente. Sin eso, una misma tabla física podría contener dos espacios de embeddings: pgvector rechaza de plano la comparación cuando las anchuras difieren, y cuando coinciden por casualidad clasifica los vectores de un modelo contra los de otro y responde con disparates verosímiles.
El provider estaba antes escrito a fuego: cada petición iba a openrouter.ai, de
modo que una organización con una clave de OpenAI no podía usarla, una clave
movida a otra cuenta obligaba a recrear la colección y a reingerir cada
documento, y nada impedía que una colección enviara la credencial de un proveedor
a la dirección de otro. El catálogo es además con lo que responde
GET /rag/embedding-models, así que el formulario de creación ofrece los modelos
que un provider puede servir de verdad — antes ofrecía todos los modelos de los
que este build conocía la anchura, tres de ellos pesos de sentence-transformers
que aquí nada puede llamar.
La clave se valida al crear. Una clave que tiene otra organización, una de propósito equivocado o una que quien elige no puede ver se rechaza ahí, donde la persona que elige puede arreglarlo.
Eso último es por lo que vincular una clave necesita secrets:view sobre la
clave y no solo collections:edit sobre la colección: vincular una clave es
prestarla, ya que los embeddings de la colección se la facturan a todo el que
pueda escribir en ella.
El selector solo ofreció siempre claves que quien elige puede ver — pero la API acepta un id, y un id es adivinable. Hasta #912 un Member podía vincular la clave privada de otro miembro aportando su UUID.
Una clave que no pueden ver se rechaza como una que el vault no tiene, para que el rechazo no pueda enumerar los secretos privados de otra persona.
En el momento de generar embeddings el resolvedor no rechaza nada: una clave elegida que desde entonces se ha borrado, que no se puede abrir o que no contiene una clave de API resuelve a ninguna clave, porque qué clave paga nunca debe decidir si se puede leer la fila de la colección. El cliente de embeddings rechaza entonces la indexación o la búsqueda con un mensaje que nombra la colección, su provider y cuál de esos casos ocurrió — no hay clave de todo el deployment a la que caer, así que el rechazo nunca aconseja una variable.
Esa degradación se anuncia en vez de darse por supuesta. La resolución lleva en cuál de las cinco fuentes cayó, y la ingesta escribe cada caso degradado en el log del run de Prefect, incluida una colección que sencillamente no nombra ninguna clave. Antes de la incidencia #306 el worker de ingesta era el único llamante que nunca preguntaba al resolvedor, así que cada documento subido se embebía con el modelo del deployment y una clave de todo el deployment eligiera lo que eligiera su colección; esa clave ya no existe.
Almacenamiento de vectores¶
Los vectores se guardan en pgvector usando la base de datos PostgreSQL existente. No hacen falta servicios adicionales.
Una tabla por colección, creada en tiempo de ejecución.
El almacén emite CREATE TABLE IF NOT EXISTS rag_<collection> la primera vez que
se escribe en una colección, así que esas tablas existen en la base de datos y en
ningún otro sitio. Ningún modelo las declara y ninguna migración las crea, porque
un deployment tiene tantas como bases de conocimiento haya hecho alguien.
Alembic no es su dueño, y alembic/env.py lo dice a través de include_name. Sin
eso, make db-check leía cada una como una tabla que los modelos habían
eliminado, y fallaba en cualquier base de datos que hubiera ingerido un documento
alguna vez.
El predicado vive en app/db/vector_tables.py, y es más estrecho que el prefijo a
propósito: rag_documents sí es una tabla de modelo, y excluirla habría apagado
la verificación precisamente para la única tabla a través de la cual escribe la
ingesta.
El almacén responde a la misma pregunta con el mismo predicado:
list_collections, que es lo que imprime rag-collections, informa de una tabla
rag_ solo cuando ningún modelo la declara. Casar únicamente por el prefijo hacía
que informara de rag_documents como una colección llamada documents — una que
nadie creó, cuyo "recuento de vectores" era el número de documentos ingeridos y a
la que cualquier llamante podía pedir luego que buscara.
Cómo puede llamarse una colección¶
El nombre de una colección es una cadena que elige un llamante y con la que el
almacén construye identificadores, así que una sola función decide si es
usable — validate_collection_name, en app/db/vector_tables.py. Cuatro
rechazos, cada uno un 400:
| Rechazado | Porque |
|---|---|
No es un identificador desnudo — foo-bar, 2024_reports, cualquier cosa con un espacio o una comilla |
El almacén interpola el nombre en el DDL sin entrecomillar. Un dígito inicial solo parece seguro: el prefijo rag_ aporta la letra que le falta al nombre. |
Cualquier mayúscula — Handbook |
Postgres pliega un identificador sin comillas, así que Handbook y handbook son una sola tabla. Nada por encima de la base de datos puede verlo: en todos los demás sitios los nombres se comparan como cadenas enteras, así que los dos son dos filas que la plataforma cree que son dos colecciones. Se rechaza en vez de pasarlo a minúsculas — guardar un nombre que el llamante no escribió es exactamente la reinterpretación que esta regla existe para evitar. |
| Más de 45 caracteres | Postgres se queda con 63 bytes de un identificador y trunca el resto en silencio. rag_<name> cabe con 59, pero rag_<name>_embedding_idx no, y la cota es el identificador más largo, no el más corto. |
all |
Reservado. |
Una tabla de la que son dueños los modelos — documents |
Véase abajo. |
Dos de estos son el mismo fallo alcanzado por caminos distintos, y los dos merecen una frase. La cota de longitud es la que se lee como pedantería y no lo es.
Dos colecciones que coinciden hasta el punto de truncado son un solo objeto:
- Una tabla, si el nombre era demasiado largo — así que el
DROPde cualquiera de las dos organizaciones destruye los vectores de la otra, y cada búsqueda cruza entre ambas. - Un índice, si solo lo era el nombre del índice, que es más silencioso:
CREATE INDEX IF NOT EXISTSencuentra el índice de la primera colección ya ahí y no construye nada, dejando la segunda sin indexar con la anchura con la que se construyó la primera.
Nada por encima de la base de datos puede ver ninguno de los dos, porque el nombre de una colección se compara como cadena entera en todos los demás sitios.
Por eso mismo importan también las mayúsculas: una grafía es un camino más
corto a la misma tabla compartida, y rechazar las mayúsculas cierra un segundo
camino con él. _collection_exists comparaba rag_Handbook contra
information_schema.tables, que guarda el nombre plegado, así que nunca casaba y
search, get_documents y get_document_chunks respondían vacío para
cualquier colección con una mayúscula.
Ese camino se ha eliminado en vez de arreglarse: un nombre así ahora se rechaza donde se construye el nombre de la tabla, antes de que nada pueda preguntar.
Una colección no puede llamarse como una tabla de la que son dueños los
modelos, que es el predicado de tablas en tiempo de ejecución leído de una
tercera manera — preguntado sobre un nombre antes de que su tabla exista. Se
rechaza tanto en la API como en el propio almacén, porque rag-drop <name> llega
al almacén sin ninguna ruta por medio. El nombre que hizo necesario esto es
documents: con el prefijo, es la tabla de seguimiento, así que borrar una
colección así apuntaba un DROP TABLE IF EXISTS al historial de ingesta de todas
las organizaciones. El rechazo se deriva en vez de listarse, así que una tabla de
modelo con prefijo rag_ añadida más tarde queda cubierta, y una colección
llamada documents_archive — que una exclusión literal se habría llevado por
delante — no se ve afectada.
Y el nombre tiene que estar libre.
El espacio de nombres de vectores es global del deployment: dos bases de
conocimiento con un mismo nombre de colección comparten una tabla. Así que un
nombre ya ocupado fuera del alcance del llamante se rechaza con un 409 —
CollectionAccessService.claim, al que llaman tanto POST /kb como
POST /rag/collections/{name}.
Antes solo lo hacía uno de ellos. POST /kb escribía el collection_name que le
mandaran, así que un miembro con collections:edit podía apuntar una base de
conocimiento a la tabla de vectores de otra organización y luego leerla y
escribirla a través de cada barrera — porque una colección se resuelve a través
de la base de conocimiento que el llamante sí puede leer, y ahora una de ellas
es suya.
Un nombre que el llamante no aporta se deriva del nombre visible más seis caracteres hexadecimales aleatorios, y se reclama por el mismo camino en lugar de darlo por bueno por ser aleatorio.
Y un nombre a medio desmontar tampoco está libre. Borrar una colección elimina
sus filas de base de conocimiento dentro de la petición, pero elimina la tabla
física de vectores rag_<name> solo después de que la petición haga commit,
entregándoselo a un worker duradero — así que un rollback conserva la tabla junto
a las filas que restaura, y un proceso que muere a mitad de la limpieza no la deja
huérfana. Entre ese commit y el borrado el nombre no tiene fila pero su tabla
todavía guarda los fragmentos del antiguo tenant, así que claim rechaza además
un nombre reservado en collection_teardowns — una fila confirmada junto con el
borrado y limpiada una vez que la tabla ya no está. Sin ella, una reclamación en
esa ventana haría que CREATE TABLE IF NOT EXISTS adoptara la tabla superviviente
y leyera los datos de otro tenant (#1362). Una subida a un nombre reservado se
rechaza por lo mismo: RAGDocumentService.dispatch_upload comprueba la reserva
antes de crear la colección, así que una ingesta colada en la ventana no puede
recrear la tabla que el borrado está a punto de destruir y perder en ella sus
propios fragmentos (#1364). Los caminos de ingesta del worker — una
sincronización, un reintento — también la comprueban, en la barrera still_wanted
justo antes de la escritura de vectores, así que una sincronización hacia un
predeterminado vaciado (cuya fila el vaciado conserva) se detiene en vez de
repoblar una tabla que se está borrando (#1382). Las dos son comprobaciones de
reserva de mejor esfuerzo y no una serialización con bloqueo: sostener el bloqueo
de desmontaje durante una escritura provocaría un interbloqueo contra la purga de
una organización, que bloquea antes la fila de organizations, así que cerrar la
última ventana estrecha queda para #1382.
Una reserva cuyo borrado nunca llegó a ejecutarse — perdida por una caída entre
el commit y el despacho, o por un borrado que falla definitivamente — bloquearía
su nombre para siempre, ya que nada más lo reintenta. Un barrido horario
(teardown-reservation-sweep) recoge esas: para cualquier reserva de más de una
hora reintenta el borrado y libera el nombre, para que un nombre no se pierda
para siempre porque se perdiera una ejecución de un worker (#1364).
Una base predeterminada se vacía, no se borra. Eliminar una colección predeterminada conserva su fila de base de conocimiento — la organización mantiene un predeterminado usable — pero su tabla de vectores se elimina igualmente, así que los documentos borrados con ella dejan de ser buscables en vez de quedarse en una tabla que nada lista (#1361). Una búsqueda lee la tabla ausente como vacía y la siguiente subida la recrea. La tabla se salva únicamente cuando una base hermana sigue teniendo el mismo nombre, ya que el espacio de nombres de vectores no es único por tenant y eliminarla se llevaría también sus fragmentos (#913).
documents era además la colección predeterminada, así que el inicio rápido
de la CLI apuntaba a la tabla de seguimiento; ahora el predeterminado es
default. Una base de conocimiento creada con el nombre antiguo antes de este
cambio sigue existiendo y se puede seguir borrando, pero no se puede ingerir nada
en ella — bórrala y crea una con otro nombre. Al hacerlo no se pierde nada: una
ingesta en esa colección nunca ha funcionado, porque construir el índice de
vectores sobre una tabla sin columna embedding falla.
Quién puede llegar a una colección¶
Las colecciones no son globales, y nadie dentro de una organización es "admin" a estos efectos — aquí no hay roles en una ruta, solo permisos (permisos).
Una colección tiene dos nombres. Uno es la tabla de vectores donde viven los
fragmentos, una cadena que cualquier llamante puede escribir en una URL; el otro,
la fila de knowledge_bases que la posee, y solo esa fila conoce una
organización. La fila es la autoridad: cada ruta /rag y /kb resuelve el
nombre a través de ella, en app/services/collection_access.py, antes de tocar un
vector, un documento o una fuente de sincronización. El listado y las rutas por
recurso leen la regla de ese único sitio, porque fueron dos copias de ella —
/rag/collections filtrando por organización mientras /rag/collections/{name}/info
no lo hacía — las que una vez dejaron a un tenant leer los de otro.
Tres ámbitos en la fila, y no hay un cuarto:
| Ámbito | Puede leer | Puede escribir |
|---|---|---|
personal |
su propietario | su propietario |
org |
collections:view que alcance la fila |
collections:edit que alcance la fila |
app |
cualquiera en el deployment | el superadmin del deployment (is_app_admin) |
"Alcanzar la fila" es resolve_access, la misma decisión que toma cada recurso
compartible: el ámbito del llamante para ese permiso, ampliado por cualquier grant
explícito sobre esa colección concreta. Un grant amplía lo que el rol permite y
nunca lo estrecha, así que un Viewer con un grant explícito de edit puede
gestionar esa colección — el caso que una barrera de rol rechazaría antes de
mirar siquiera. Por eso las rutas por recurso no llevan require(...) y le pasan
la decisión al servicio.
Lo que eso da, por operación:
Búsqueda — POST /rag/search, y la herramienta de recuperación del agent |
collections:view. Cada colección nombrada se resuelve antes de leer el primer vector, y una a la que el llamante no llega rechaza la búsqueda entera en vez de quedar excluida de ella sin más |
| Lectura — listar colecciones y documentos, estadísticas de una colección, el texto parseado de un documento o su archivo original, logs de sincronización y de ingesta | collections:view, y cada respuesta contiene solo las colecciones a las que ese llamante llega |
| Escritura — crear y borrar una colección, subir, ingerir, reintentar, borrar un documento, configurar o cancelar una fuente de sincronización | collections:edit |
POST /rag/sync/local |
La única excepción, y conserva is_app_admin: su path nombra un directorio del servidor y no algo que posea un tenant, así que abrirlo a collections:edit le daría a cada miembro la lectura de archivos arbitrarios del servidor, ingeridos en una colección que luego puede buscar |
Un rechazo se informa como "Collection not found", con el mismo mensaje y los
mismos detalles que produce una colección ausente. Cualquier otra cosa convierte
la API en un oráculo: estos nombres se derivan de cómo llama la gente a sus bases
de conocimiento, así que confirmar que acme_handbook_d1fac1 existe en algún
sitio ya es información.
Dentro de una colección no hay aislamiento por documento. El acceso se decide en la colección, así que llegar a una es llegar a todos sus documentos — que es lo que hay que sopesar al decidir qué se ingiere dónde.
Seguimiento de documentos¶
Los documentos ingeridos se registran en la base de datos SQL con el modelo
RAGDocument:
| Campo | Descripción |
|---|---|
collection_name |
Colección de destino |
filename |
Nombre original del archivo |
filesize |
Tamaño del archivo en bytes |
filetype |
Extensión del archivo (sin punto) |
status |
processing, done o error — los miembros de DocumentStatus, y los únicos tres valores que guarda la columna. El recuento de indexados de una colección filtra por done; filtraba por un cuarto valor que nada ha escrito nunca hasta #148, así que cada base de conocimiento informaba de indexed_count: 0 por muchos documentos que hubieran terminado |
error_message |
Qué falló, si status es error — véase abajo |
vector_document_id |
ID en el almacén de vectores |
chunk_count |
Número de fragmentos creados. Se registra desde #147; un documento ingerido antes tiene 0 y la tarjeta de su colección informa de menos de la cuenta hasta que se reingiera |
storage_path |
Ruta al archivo original (para reingesta o descarga) |
created_at |
Hora de inicio de la ingesta |
completed_at |
Hora de finalización de la ingesta |
Una sustitución retira la fila a la que sustituye. Cada camino de ingesta — la
subida, la CLI, una ejecución de sincronización — escribe una fila de seguimiento
nueva, mientras que una ingesta con replace=true borra el documento vectorial
al que sustituye e inserta otro. Así que la fila más antigua se queda describiendo
vectores que nadie tiene: su chunk_count se sigue sumando a los totales de la
colección, y su vista de contenido parseado no tiene nada que leer. Por eso
completar una ingesta borra las filas de seguimiento que apuntan al documento
vectorial al que sustituyó, junto con sus copias guardadas del archivo. Sin eso,
un directorio sincronizado cada noche informaba de una colección que crecía su
propio tamaño cada noche.
Un documento sincronizado no conserva original, y lo dice. El camino de subida
guarda una copia bajo rag/{collection} y una sincronización no: los bytes de un
archivo sincronizado viven en el sistema del que vino, y replicar cada uno de
ellos en el disco de este deployment para que funcione un botón es un coste por
corpus en vez de por fallo. Así que storage_path está vacío en estos y has_file
es falso, que es lo que tiene que leer una superficie que ofrezca una descarga.
Volver a ejecutar la sincronización es el reintento — desde
#990 omite todo lo que no ha
cambiado y vuelve a traer exactamente lo que no tiene documento, así que reintentar
cuatro fallos de cuarenta cuesta cuatro transferencias y no cuarenta.
Cada camino abre la fila antes de indexar el archivo.
Escrita después, una fila cuya escritura fallaba — un parpadeo de la base de
datos, un nombre más largo que la columna — dejaba el documento vectorial
almacenado y sin seguimiento. La siguiente ejecución new_only casaba entonces su
hash y omitía el archivo antes de llegar a la escritura, así que seguía siendo
buscable, invisible e imborrable para siempre.
El peor caso de este orden es una fila que dice processing junto a un documento
que terminó, lo cual es visible y se puede borrar.
La sincronización por conector dejó de escribir después en #992, y la de directorio local en #997 — que además le dio fila y motivo a un archivo sincronizado localmente que no se puede parsear. No tenía ninguna de las dos cosas, así que un log de sincronización que decía que cuatro de cuarenta habían fallado no nombraba ninguno.
Una fila sincronizada dice qué archivo sigue, en source_path:
gdrive://<id>, s3://bucket/key, o una ruta absoluta para una sincronización
local o de la CLI. Eso es lo que retira un intento anterior sobre el mismo
archivo — un parseo fallido no escribe vectores, así que la retirada de
complete_ingestion no tiene nada con lo que casar y antes sobrevivían las dos
filas, una más por cada fallo, contando cada una para el document_count de la
colección (#996).
Una subida no guarda dirección, y por eso no retira nada. Su único nombre es un
nombre base, que no es una dirección: dos personas pueden subir dos report.pdf
distintos y, con replace=false, querer que existan los dos. Retirar por ese
nombre borraría la fila fallida del primero — su diagnóstico, su reintento y su
archivo guardado — para un llamante que no pidió nada de eso. Una dirección NULL
no casa con ninguna comparación, que es la respuesta que se quiere y no una que
haya que esquivar, y es lo que tiene cada fila escrita antes de existir la columna.
Tres cosas deciden qué puede llevarse una retirada, y cada una de ellas se equivocó primero:
- Por dirección, nunca por nombre de archivo. Esa es la colisión que
#990 quitó del lado de los
vectores, alcanzada desde el otro extremo:
a/readme.mdyb/readme.mden un mismo bucket comparten nombre base, así que casar por nombre borra la fila del otro archivo. ERROR, no "no tiene id de vector". Son conjuntos distintos, y tratarlos como uno solo es una carrera: una filaPROCESSINGpertenece a un intento aún en curso, y de dos ingestas solapadas de un mismo origen la segunda borraría la fila viva de la primera — tras lo cual la primera termina, sustituye los vectores y no encuentra fila que completar.- Una sustitución fallida no es una ingesta fallida.
ingest_fileinserta el documento nuevo antes de borrar aquel al que sustituye, así que un borrado que lanzaba una excepción devolvía un error mientras los vectores estaban ahí — una filaERRORsin id de vector, que el siguiente intento retiraría dejándolos huérfanos. Que la inserción haya funcionado es toda la respuesta: el documento antiguo que queda se registra en el log, y un duplicado que alguien puede ver y borrar no es un fallo del que informar.
La sincronización por conector no escribía fila alguna hasta
#992 — la frase de arriba
valía solo para la subida, la CLI y la sincronización local. Un documento de una
carpeta de Drive era buscable e invisible: ausente de la pestaña Documents de la
base de conocimiento (GET /kb/{kb_id}/documents lee get_for_kb), ausente del
propio document_count de la colección, inalcanzable por un borrado, y un fallo
era un número en el log de sincronización sin motivo por archivo en ningún sitio.
Las ingestas fallidas se pueden reintentar con POST /rag/documents/{id}/retry.
Vuelve a leer storage_path — la copia que la subida guardó exactamente para esto
— y despacha el parseo otra vez, sustituyendo lo que indexara el intento fallido.
Un documento que no falló, o que no tiene archivo guardado — uno anterior a que
las subidas guardaran el suyo, o uno que ingirió una sincronización — se rechaza
con un 400 en vez de pasar a processing
(#441).
Qué dice una ingesta fallida¶
error_message es una columna almacenada, que se muestra en la página de
documentos y en el historial de sincronización de una fuente a todo el que pueda
ver la colección. Así que lleva un resumen y no lo que dijera el cliente que
falló:
The document could not be indexed (AuthenticationError) - check the
collection's embedding credential, then retry the upload. The worker log has
the full error.
Tres partes, y cada una está por un motivo. La etapa — parsear, indexar, registrar el resultado o una sincronización entera — es lo único que el lector no puede deducir después, y separa un archivo que el parser de esta colección no lee de una credencial que el provider rechazó. El tipo de la excepción se conserva porque un nombre de clase es un símbolo: dice que la credencial fue rechazada o que el sistema de arriba agotó el tiempo sin nombrar al host que lo dijo. El consejo es lo que el lector puede hacer de verdad.
Un mismo fallo lo informan hasta tres handlers — la etapa que lo lanzó, la
comprobación de que un fallo devuelto no es done y la red de seguridad del flow
— y el primero que lo registra se queda con la fila, porque es el más interno
y el más específico. Un reintento limpia el mensaje, así que el siguiente intento
registra el suyo.
Un rechazo que esta plataforma lanzó ella misma se pasa entero, en cambio, porque su mensaje está escrito aquí y es lo más útil que se puede mostrar: "No embedding credential is configured for this collection", "Organization monthly budget exhausted: $40.15 spent of $40.00 limit".
Lo que no se guarda es el texto del cliente que falló. Un SDK de provider,
httpx, boto3 y el cliente de Google Drive ponen la petición que hacían en su
mensaje de excepción, a menudo un endpoint, un host interno, un bucket o una URL
con una clave en su query string — y, a diferencia de un cuerpo de error HTTP,
una columna la relee semanas después quien abra el documento fallido. No se
pierde: todas estas llamadas lo registran con logger.exception, así que el log
del worker tiene el mensaje y la traza, y un flow de Prefect que relanza los
tiene en su run. app/services/rag/failures.py es donde se separan ambos.
El log es un público más pequeño que la columna, no uno seguro — trata el log de un worker como algo que solo leen los operadores, y consulta #440 para ver por qué el filtro de redacción que trae este deployment no lo limpia hoy por hoy.
Operaciones de sincronización¶
Las operaciones de sincronización se registran con el modelo SyncLog, que anota
la fuente, el modo, el total de archivos, los recuentos de
ingeridos/actualizados/omitidos/fallidos y los tiempos. El historial se consulta
con GET /rag/sync/logs.
A qué documento guardado corresponde un archivo es una sola pregunta, y una pregunta indexada.
IngestionService.existing_document se la pasa a find_existing_document del
almacén, que busca el documento por una clave de metadatos cada vez —
source_path, luego un filename que el documento no haya direccionado bajo otra
ruta, luego content_hash — en ese orden de precedencia, parando en el primer
acierto.
Responde con el id del documento y con su content_hash guardado, y los dos
vuelven juntos a propósito: son hechos sobre un documento. Calculados por
búsquedas separadas con reglas distintas podrían discrepar, así que una
sincronización comparaba el hash de un archivo vivo con el de otro documento y o
bien reembebía cada noche un archivo sin cambios o bien omitía como actual uno que
sí había cambiado (#548).
PgVectorStore sirve cada búsqueda desde un índice hash sobre esa clave de
metadatos. Hash y no btree, porque las búsquedas son solo de igualdad y un
source_path no tiene cota — un btree fallaría su límite de tamaño de fila y se
llevaría la ingesta por delante.
Los índices se construyen con la tabla de tiempo de ejecución y se rellenan en las
colecciones más antiguas con la migración 0058_backfill_rag_lookup_indexes. Eso
convierte la comprobación en un puñado de sentencias indexadas, en vez de la
lectura de la tabla rag_<collection> entera en la memoria del worker que era
antes — una vez por documento ingerido, en una colección que podía tener cientos
de miles de fragmentos
(#1102, la mitad de ingesta
de #27; la otra mitad paginó
el listado de documentos seguidos).
Queda un respaldo en la clase base que responde leyendo el listado, para un almacén que no tenga índice en el que apoyarse.
new_only omite un archivo cuyo hash guardado coincide, update_only omite uno
que no ha cambiado e ignora uno que es nuevo, y full sustituye todo lo que casa.
Un almacén que no puede responder al listado se trata como "sin coincidencia" y no
como una coincidencia: una consulta fallida no es prueba de que un documento esté
ausente, pero actuar como si un documento estuviera presente borraría uno.
Los dos flows, y tienen que estar de acuerdo. Una sola columna sync_mode
alimenta a un directorio local y a un conector por igual, así que un modo que
signifique una cosa para cada uno es el defecto haga lo que haga cualquiera de los
dos por su cuenta.
Una sincronización por conector no implementaba nada de esto hasta
#990. sync_mode solo
llegaba al argumento replace de ingest_file, e ingest_file nunca omite — así
que con el new_only por defecto el documento anterior ni se encontraba ni se
borraba y se insertaba una segunda copia en cada ejecución.
Una semana de sincronizaciones nocturnas eran siete copias de cada fragmento,
clasificadas unas contra otras en cada búsqueda y pagada cada una en embeddings.
El contador de skipped de al lado se inicializaba y nunca se incrementaba, que es
un log de sincronización informando con toda verdad de skipped=0 cada noche.
Dónde se decide difiere entre ambos, porque los bytes de un archivo remoto
cuestan traerlos. update_only no necesita bytes para omitir un archivo que
nunca ha visto, así que esa respuesta se da antes de la descarga; un hash sí, así
que un archivo sin cambios se reconoce después de una descarga y antes del
embedding, que es la mitad cara. Un documento guardado que no lleva
content_hash se reingiere en vez de darse por actual: omitir un archivo que
puede haber cambiado es la respuesta que nada corrige después. Un archivo
sustituido se cuenta como actualización y no como ingesta, leído de
replaced_document_id y no de la frase que devuelve el resultado.
Dos cosas sobre el emparejado, y las dos deciden si un documento sobrevive.
El penúltimo recurso de existing_document es un emparejado por nombre de
archivo, y existe para que un archivo subido por el navegador y sincronizado más
tarde desde la carpeta de la que vino se sustituya en vez de duplicarse — una
subida guarda su nombre de archivo como su source_path, así que los dos coinciden
y sigue siendo alcanzable por nombre.
Un documento que nombra una dirección distinta no es candidato para eso. En un
bucket con a/readme.md junto a b/readme.md, la segunda clave encontraba por
nombre el documento de la primera, así que un contenido igual lo omitía y un
contenido distinto sustituía al primero — de cualquiera de las dos formas una
primera sincronización no podía quedarse con los dos, y no decía nada.
La misma colisión se daba con dos archivos locales de un mismo nombre en directorios distintos.
Y una sustitución inserta antes de borrar. insert_document es donde se
calculan los embeddings, así que un provider que rechazara entre las dos sentencias
dejaba antes a la colección sin ninguno de los dos documentos — de forma
permanente, porque una ingesta fallida se devuelve en vez de lanzarse y nada la
reintenta. Tener los dos durante lo que dura una inserción es un estado que una
búsqueda sobrevive; no tener ninguno, no.
El historial propio de una fuente está en
GET /kb/{kb_id}/sync-sources/{source_id}/logs. La fuente se resuelve primero
contra esa base de conocimiento, así que una fuente que pertenece a otra base
responde 404 en vez de una lista vacía — por lo demás las dos pintan la misma
pantalla, y una de ellas es una petición que debería haber fallado. Sus ejecuciones
se leen por id de fuente, que es lo que mantiene a limit y total describiendo
el mismo conjunto de filas: una fuente reapuntada a otra base conserva sus
ejecuciones anteriores bajo el nombre de colección que tenía entonces, y antes se
caían de la página después de que limit ya la hubiera recortado.
Qué no le corresponde decidir a una fuente de sincronización¶
Quien pueda dejar un archivo en una carpeta compartida elige la cadena que gestionará la siguiente sincronización
Dos de esas cadenas se tomaban antes al pie de la letra: un nombre de archivo
que era una ruta (../../../../home/app/.ssh/authorized_keys es un nombre
legal en Drive), y un id de carpeta que llegaba al lenguaje de consulta de
Drive. remote_names.py rechaza los dos, y BaseSyncConnector - no un
conector - decide dónde aterriza un byte, así que un conector añadido después
hereda el rechazo en vez de tener que acordarse de él.
El contenido de una fuente no es del deployment para fiarse de él, y en una carpeta de Drive compartida fuera de la organización ni siquiera es del tenant: compartir es para lo que sirve compartir una carpeta.
Un nombre de archivo es una etiqueta, no un componente de ruta
../../../../home/app/.ssh/authorized_keys es un nombre de archivo legal en
Drive, y el conector escribía dest_dir / file.name tal cual — fuera del
directorio temporal que había hecho el worker, allá donde su uid pudiera
escribir, y luego ingería desde ahí.
El nombre se reduce ahora a su último componente, y el resultado se resuelve y se
confirma como hijo del directorio de sincronización. Así que .., sus
codificaciones, sus imitaciones y un enlace simbólico ya presente en el directorio
son una sola pregunta en vez de una lista de grafías que hay que mantener al
día.
Un nombre que no es componente alguno — .., ., / — se rechaza. Cualquier otra
cosa aterriza dentro como un archivo.
El destino es la respuesta de BaseSyncConnector, no la de un conector. A una
implementación se le entrega una ruta y escribe en ella (_fetch), que es lo que
hace que un conector añadido después herede el rechazo en vez de tener que
acordarse de él.
Un id de carpeta llega a un lenguaje de consulta. La consulta de Drive envuelve
un id de padre en comillas simples, así que x' in parents or name contains 'salary
es una consulta bien formada y más amplia. Un id de carpeta se comprueba ahora
contra lo que Google puede emitir — letras, dígitos, - y _ — allí donde se
construye la consulta, que es el único embudo por el que pasan tanto la carpeta
configurada como el id de cada subcarpeta. validate_config hace la misma
pregunta, así que un valor hostil lo responde la ruta que lo aceptó y no un log de
sincronización una hora después.
Una fuente de Google Drive funciona con su propia credencial o no funciona. El
conector caía antes a GOOGLE_DRIVE_CREDENTIALS_FILE siempre que faltaba
service_account_json, lo que significaba que el id de carpeta de un tenant
elegía qué se listaba bajo la cuenta de servicio del operador y todo lo que se
hubiera compartido con esa cuenta. Esa caída ya no existe; el ajuste sirve ahora
únicamente al comando de CLI rag-sync-gdrive, que un operador ejecuta desde su
propio shell.
La credencial es un secreto del vault, no un campo de configuración¶
Una credencial nunca va en el CONFIG_MODEL de un conector
sync_sources.config dice cómo encontrar los documentos. Lo que autentica es
un secreto del vault que la fuente nombra en secret_id - y no hay caída de
ámbito de deployment, porque una caída significa que el id de carpeta de un
tenant elige qué se lee bajo la identidad del operador.
Lo que la fuente nombra en secret_id es un gcp_service_account para Drive o un
par aws_credentials para S3, declarado por el conector como SECRET_KIND y
ofrecido al asistente como secret_kind en el listado de conectores.
Antes estaba en config, cifrado por app/core/crypto.py — una sola clave Fernet
de ámbito de deployment sobre la credencial de cada tenant, que es justo la
debilidad que el vault existe para eliminar, y el único sitio donde el "no hay un
segundo mecanismo" de CLAUDE.md no era cierto. Ese módulo ya no existe
(#937). De ahí salen tres
cosas:
- Una credencial se añade una vez y se referencia. Cinco bases de conocimiento alimentadas desde una carpeta de Drive significaban antes el mismo JSON pegado cinco veces, rotado cinco veces y revocado en cinco sitios. Clonar una integración ahora copia la referencia.
- El asistente ofrece lo que tiene la organización, filtrado al tipo que el
conector necesita, y enlaza al Vault cuando no hay ninguno —
InlineSecretno se usa aquí porque solo gestionaapi_key, y una cuenta de servicio es un formulario de varios campos cuyo sitio honesto es el Vault. - El servicio rechaza una configuración que lleva una credencial. Enviar los nombres de campo antiguos se responde con "a credential does not go in a source's configuration", en vez de descartarlos para que la fuente se guarde y luego no pueda autenticarse.
Leerla ocurre donde hay una sesión y un tenant: el worker abre el secreto para la organización de la propia fuente y se lo entrega al conector junto a la configuración. Un conector no puede llegar al vault por sí mismo, y una fuente cuyo secreto se ha borrado no sincroniza más — los conectores no tienen caída de ámbito de deployment y no deben adquirir una.
Quién acaba pudiendo leer lo que una fuente ingirió¶
La colección es la frontera de permisos, y el alcance de una fuente son los permisos de su credencial estrechados por su propia configuración. Una fuente de sincronización ingiere exactamente en una colección, el acceso se decide en la colección (véase Quién puede llegar a una colección) y dentro de una no hay aislamiento por documento — así que todo lo que esa fuente lee pasa a ser legible por todo el que pueda leer esa colección.
Las dos mitades de ese alcance no son igual de fiables, que es la parte que conviene saber.
Una fuente de Drive está acotada por su folder_id y una de S3 por su bucket y
su prefix, así que una credencial amplia apuntada a una carpeta ingiere una
carpeta.
Pero config es un campo de la fila, editable por cualquiera que tenga
collections:edit sobre esa colección.
La configuración estrecha el alcance y no se puede confiar en que lo mantenga estrecho
Los permisos de la propia credencial son un techo que nada en este producto puede subir.
Un token de Confluence válido para toda la instancia, en una fuente que alguien
reapunta luego a un espacio más amplio, publica la instancia entera a cada
miembro que tenga collections:view. El mismo token acotado a un espacio no
puede, diga lo que diga la configuración.
Esa es una decisión que alguien tiene que tomar, y la respuesta de la plataforma es hacerla explícita en vez de ingeniosa. La alternativa — replicar las ACL de cada fuente en el almacén y filtrar en la recuperación — no está en la hoja de ruta, y los motivos merecen decirse para que no se vuelva a proponer como una victoria obvia:
- No hay un mapa de identidades. Una ACL de SharePoint nombra principales de
Entra, una de Confluence nombra cuentas de Atlassian, y ninguna es una fila de
organization_members. Adivinar la correspondencia por dirección de correo es como una plataforma le da al documento correcto acceso a la persona equivocada. - Una ACL es un blanco móvil. Un permiso cambiado en la fuente es invisible aquí hasta la siguiente sincronización, así que una ACL replicada es autorización caducada — peor que ninguna, porque parece una respuesta.
- Un crawler no tiene ACL en absoluto, y la de un repositorio de git es la de la plataforma que lo aloja y no la del documento. Un modelo que solo funciona para dos de los conectores candidatos no es el modelo.
Así que la regla para quien crea una fuente, y lo que un paso del asistente tiene
que decir: acota la credencial, no solo la configuración. Una cuenta de
servicio compartida en una sola carpeta, una app de Entra consentida para un sitio
y no para un tenant, un token de Confluence limitado a un espacio — esa es la mitad
del alcance que una edición de la fuente no puede ampliar. Apuntar una credencial
amplia a una colección personal estrecha los lectores pero no lo que se ingirió;
una credencial estrecha en una colección org es la forma a la que hay que
apuntar.
Quién lo decidió queda registrado. Crear, clonar, reapuntar y borrar una fuente
escriben cada uno una entrada de auditoría - sync_source.created, .updated,
.deleted - que nombra al actor, el conector, la colección y el id del secreto,
nunca el documento de configuración. Una actualización que mueve la fuente a otra
colección registra también la que dejó, porque si no un cambio de nombre y un
cambio de audiencia son la misma entrada. Un clon se registra como una creación que
nombra la fila de la que salió: apunta a otra colección una credencial que alguien
ya había acotado, así que su audiencia cambia mientras que nada de la credencial lo
hace (#983).
Y se dice antes del hecho, no solo después.
El último paso del asistente — el que decide la colección — nombra la credencial y la audiencia juntas, porque el par es la decisión:
"<credential> can read whatever it has been granted, and everything it ingests becomes searchable in <collection> by …"
Un conector que no autentica con nada no tiene credencial que nombrar, y la frase
no se la inventa. Tampoco nombra una cuyo lector no tenga secrets:view.
Cada ámbito termina esa frase de forma distinta — personal es su propietario,
org es todo el que pueda ver la colección, app es cualquiera en el deployment —
y una integración archivada bajo ninguna base de conocimiento dice que todavía
nada puede buscarla.
La frase no espera al selector de colección, que solo aparece cuando hay más de una colección entre las que elegir. El caso desde el que se archivó esto es una base de conocimiento que ofrece exactamente una, donde no hay nada que elegir y la consecuencia es la misma (#982).
Clonar también lo dice, y por el motivo de arriba: es la única forma de cambiar la
audiencia de una fuente desde la propia UI de este producto. Reapuntar una
existente es un PATCH sobre collection_name, que hoy no envía ninguna pantalla
- no hay un editor de fuentes - así que se llega a ello por la API y por la CLI,
donde la entrada de auditoría de arriba es lo que lo registra.
Qué debe traer un conector nuevo¶
Un conector es list_files + _fetch + un CONFIG_MODEL, y las llamadas a la
API son la parte barata. CONFIG_MODEL es un modelo de Pydantic de los campos de
configuración; el listado publica su model_json_schema() como config_schema,
así que el asistente dibuja el formulario con SchemaForm - la misma forma que
publica una capability
(#1093).
Un almacén de objetos es menos que eso: S3, Azure Blob y GCS son un conector
con tres clientes, así que ObjectStoreConnector sostiene el bucle de listado, la
dirección <scheme>://<container>/<key> y el salto de los marcadores de
directorio, y una subclase aporta un cliente, un SCHEME y qué campo del
CONFIG_MODEL nombra el contenedor - bucket para S3 y GCS, container para
Azure. S3Connector es esa subclase
(#988); sus dos hooks son
bloqueantes a propósito, porque los tres SDK lo son, y la clase compartida los
ejecuta en un hilo de worker.
Hay tres cosas que no son baratas, y un conector sin ellas es una factura o una sorpresa en vez de una funcionalidad:
- Una señal de cambio. El camino de sincronización compara una desde
#990, y lo que compara es un
content_hashde los bytes — lo que significa que descarga un archivo para averiguar que no había cambiado. Eso ahorra el embedding y no la transferencia. Un conector que sepa responder a "¿ha cambiado?" sin los bytes debería decirlo en su docstring — un tokendeltade Graph, elversion.numberde una página, un sha de commit, unETagde HTTP — porque una señal que el flow pueda leer antes de la descarga es la diferencia entre una sincronización nocturna que cuesta un listado y una que cuesta la carpeta entera.content_hashes el recurso de reserva para cuando el sistema remoto no ofrece ninguna. - Una credencial acotada en la fuente. Véase la sección anterior. El
SECRET_KINDde un conector dice qué forma tiene la credencial; nada en la plataforma puede decir con cuánta amplitud se emitió, y por eso la orientación va donde se crea la fuente. - Un recuento de archivos que alguien haya pensado. Leer el listado de documentos de una colección sigue siendo un escaneo completo (#27), así que un conector que traiga miles de archivos convierte esa paginación en urgente y no en un detalle.
Un conector de sincronización no es un servidor MCP. MCP es cómo un agent llega a un producto en vivo, a mitad de run; una fuente de sincronización es una extracción masiva programada con detección de cambios cuya salida son fragmentos en pgvector. Notion-como-herramienta es un servidor MCP; Notion-como-corpus es un conector. Varios candidatos son honestamente las dos cosas, y la pregunta que hay que responder antes de escribir uno es qué mitad se está construyendo — véase mcp.
Qué conectores se están construyendo, y en qué orden, se decide en
#938: un crawler web
(#984), SharePoint y OneDrive
(#985), Confluence
(#986), la documentación de un
repositorio de git
(#987), y luego Azure Blob y
GCS, cuya condición está cumplida: S3Connector es una subclase de
ObjectStoreConnector, así que cada uno de esos es un cliente y un
CONNECTOR_TYPE en vez de una segunda copia del bucle de listado
(#988). Notion, Slack y los
archivos de correo se han decidido en contra por ahora, cada uno por un motivo
registrado ahí — los dos últimos porque una conversación se recupera mal y las
integraciones de canal ya ponen un agent dentro de Slack.
El rechazo de un conector nombra el campo del que trata¶
validate_config responde con un ConfigRefusal — una frase, y el campo del que
trata esa frase — o con None cuando la configuración es aceptable. El conector
nombra su propio campo de CONFIG_MODEL; SyncSourceService lo enraíza contra el
documento que envió el asistente (folder_id → config.folder_id) y lo lanza con
refused_field, así que llega al navegador como details["fields"] en la única
forma que lee un formulario (app/core/field_errors.py) y el paso de configuración
marca el input que el conector rechazó.
Antes respondía (bool, str | None), y un flag con una frase no puede decir cuál
de cuatro inputs estaba mal. La comprobación del id de carpeta de arriba lo sabía,
el lector no: el asistente mostraba una línea de prosa bajo cuatro cajas.
Nombrar un campo es opcional, y deliberadamente. Un conector puede rechazar una
configuración sin culpar a una parte de ella — una conectividad que falla, dos
credenciales que no pertenecen a la misma cuenta — y ConfigRefusal(message=...)
sin campo es ahí la respuesta honesta. Inventarse un nombre de campo mandaría a
alguien a editar un valor que sí se aceptó. checked_drive_folder_id no nombra
ninguno por el mismo motivo: responde a tres destinos y solo a uno de ellos se le
envió un formulario que marcar.
Descripción de imágenes¶
Al procesar documentos con imágenes, el sistema puede describirlas opcionalmente con la visión de un LLM. Es un ajuste por colección: actívalo en la configuración de ingesta de la base de conocimiento y elige un perfil de modelo con visión. El selector es el del builder de agents: un provider, un modelo y su clave se definen sin salir del diálogo — un deployment sin perfiles de modelo no es un callejón sin salida. Lo que no ofrece es borrar un perfil: eso va donde se gestionan los modelos de una organización, porque cada agent apuntado a uno lo pierde. Las descripciones generadas se incluyen en el texto del documento, para mejor búsqueda semántica.
Desde un canal¶
Un archivo enviado a un bot de Slack, Telegram o Mattermost entra por aquí, no al
lado de aquí. El adaptador lo trae con la credencial del propio bot, pasa por la
misma validación que una subida desde el navegador y se convierte en la misma fila
ChatFile — así que el enrutado de arriba aplica sin cambios y un canal no puede
convertirse en el camino permisivo.
Lo único que difiere es el aspecto de un rechazo: no hay formulario en el que mostrar un error, así que un archivo demasiado grande o de un tipo no admitido se nombra en la respuesta del bot. Consulta Canales.
Recapitulación¶
- Una subida responde 202 y se indexa en el worker, entregada con
spawn_after_commitpara que la fila sea duradera antes de que nada la busque. - El parseo y la E/S de bytes se ejecutan en un pool dedicado y acotado, nunca en el executor compartido que también carga con el hash de contraseñas.
- Una tabla por colección, creada en tiempo de ejecución, sin dueño en Alembic — y el nombre tiene que estar libre, porque el espacio de nombres de vectores es global del deployment.
- Cada flow del worker construye y descarta su propio engine. Un error de conexión a mitad de un lote grande tiene esta forma.
- Una credencial es un techo; la configuración no. Vincular un token amplio a una colección publica todo lo que ese token alcance a todo el que pueda ver la colección.