Infino
Infino: recuperación por palabra clave, vectorial, híbrida y SQL sobre datos en almacenamiento de objetos, para agentes de IA.
Documentación
infino
infino es un motor de recuperación rápida que ejecuta SQL, búsqueda de texto completo y búsqueda vectorial sobre una única copia de tus datos en almacenamiento de objetos. Los datos permanecen en Parquet en S3 (o Azure, GCS o disco local) y puedes consultarlos a escala.
Por qué infino
- Velocidad por dólar — infino optimiza la velocidad por dólar, haciendo concesiones para lograr la economía del almacenamiento de objetos a velocidades de motor de búsqueda. En un índice de 1 millón de documentos, las consultas BM25 en caliente devuelven resultados en el rango de microsegundos — ver benchmarks.
- Consultas multimodales — consultas por palabra clave (BM25), vectoriales y SQL sobre las mismas filas, ofreciendo rutas de consulta flexibles para agentes.
- Nativo de almacenamiento de objetos — los datos viven en S3, Azure, GCS o disco local, con lecturas aisladas por instantánea y confirmaciones atómicas.
- Formato abierto, sin bloqueo — los datos de texto y numéricos se almacenan como Parquet conforme a la especificación, por lo que cualquier cosa que lea Parquet puede leer tus datos.
Contenido
- Instalación
- Inicio rápido
- Almacenamiento en la nube
- Arquitectura
- Uniones SQL entre tablas
- Búsqueda híbrida
- Estabilidad
- Desarrollo
- Rendimiento
- Pruebas
Instalación
Python
pip install infino
# Or with uv (https://docs.astral.sh/uv/):
uv pip install infino
Node.js
npm install @infino-ai/infino
Rust
cargo add infino
o en Cargo.toml:
[dependencies]
infino = "0.1"
La referencia completa de la API de Rust está en docs.rs/infino.
infino instala el asignador global mimalloc por defecto. Si incrustas infino en un proceso que ya establece un asignador global, desactívalo para evitar un segundo: infino = { version = "0.1", default-features = false }.
Inicio rápido
Python
import infino
import pyarrow as pa
# A knowledge base your agent retrieves over. "memory://" is in-process;
# use "./data" or "s3://bucket/prefix" to persist.
db = infino.connect("memory://")
# Tiny stand-in for your embedding model so this runs as-is — a 16-dim
# one-hot by topic. Real embeddings are dense and higher-dimensional.
def embed(topic): # 0 = billing, 1 = appearance
v = [0.0] * 16
v[topic] = 1.0
return v
schema = pa.schema([
pa.field("source", pa.large_utf8(), nullable=False),
pa.field("body", pa.large_utf8(), nullable=False),
pa.field("embedding", pa.list_(pa.float32(), 16), nullable=False),
])
docs = db.create_table(
"docs", schema,
infino.IndexSpec().fts("body").vector("embedding", 16, "cosine"),
)
docs.append([
{"source": "help-center", "body": "To cancel a subscription, open Settings then Billing.", "embedding": embed(0)},
{"source": "help-center", "body": "Refunds return to the original payment method.", "embedding": embed(0)},
{"source": "blog", "body": "Enable dark mode under Settings then Appearance.", "embedding": embed(1)},
])
# Retrieve context to ground the agent's next answer:
keyword = docs.bm25_search("body", "cancel subscription", 5) # BM25
semantic = docs.vector_search("embedding", embed(0), 5) # vector kNN
# vector kNN, restricted to rows whose body matches a keyword (pushdown filter):
filtered = docs.vector_search("embedding", embed(0), 5, filter_column="body", filter_query="billing")
billing = db.query_sql("SELECT body FROM docs WHERE source = 'help-center'") # SQL filter
Node.js
import { connect, IndexSpec } from "@infino-ai/infino";
// A knowledge base your agent retrieves over. "memory://" is in-process;
// use "./data" or "s3://bucket/prefix" to persist.
const db = connect("memory://");
// Tiny stand-in for your embedding model so this runs as-is — a 16-dim
// one-hot by topic. Real embeddings are dense and higher-dimensional.
const embed = (topic) => { const v = Array(16).fill(0.0); v[topic] = 1.0; return v; };
const docs = db.createTable(
"docs",
{ source: "large_utf8", body: "large_utf8", embedding: { vector: 16 } },
new IndexSpec().fts("body").vector("embedding", 16, "cosine"),
);
docs.append([
{ source: "help-center", body: "To cancel a subscription, open Settings then Billing.", embedding: embed(0) },
{ source: "help-center", body: "Refunds return to the original payment method.", embedding: embed(0) },
{ source: "blog", body: "Enable dark mode under Settings then Appearance.", embedding: embed(1) },
]);
// Retrieve context to ground the agent's next answer:
const keyword = docs.bm25Search("body", "cancel subscription", 5); // BM25
const semantic = docs.vectorSearch("embedding", embed(0), 5); // vector kNN
// vector kNN, restricted to rows whose body matches a keyword (pushdown filter):
const filtered = docs.vectorSearch("embedding", embed(0), 5, { filter: { column: "body", query: "billing" } });
const billing = db.querySql("SELECT body FROM docs WHERE source = 'help-center'"); // SQL filter
Rust
use std::sync::Arc;
use infino::arrow_array::{FixedSizeListArray, Float32Array, LargeStringArray, RecordBatch};
use infino::arrow_schema::{DataType, Field, Schema};
use infino::{connect, BoolMode, IndexSpec, Metric, VectorFilter, VectorSearchOptions};
// Tiny stand-in for your embedding model so this runs as-is — a 16-dim
// one-hot by topic. Real embeddings are dense and higher-dimensional.
fn embed(topic: usize) -> Vec<f32> {
let mut v = vec![0.0_f32; 16];
v[topic] = 1.0;
v
}
# fn main() -> Result<(), Box<dyn std::error::Error>> {
// A knowledge base your agent retrieves over. "memory://" is in-process;
// use "./data" or "s3://bucket/prefix" to persist.
let db = connect("memory://")?;
let item = Arc::new(Field::new("item", DataType::Float32, true));
let schema = Arc::new(Schema::new(vec![
Field::new("source", DataType::LargeUtf8, false),
Field::new("body", DataType::LargeUtf8, false),
Field::new("embedding", DataType::FixedSizeList(item.clone(), 16), false),
]));
let docs = db.create_table(
"docs",
schema.clone(),
IndexSpec::new().fts("body").vector("embedding", 16, Metric::Cosine),
)?;
let flat: Vec<f32> = [0usize, 0, 1].iter().flat_map(|&t| embed(t)).collect();
docs.append(&RecordBatch::try_new(
schema,
vec![
Arc::new(LargeStringArray::from(vec!["help-center", "help-center", "blog"])),
Arc::new(LargeStringArray::from(vec![
"To cancel a subscription, open Settings then Billing.",
"Refunds return to the original payment method.",
"Enable dark mode under Settings then Appearance.",
])),
Arc::new(FixedSizeListArray::new(item, 16, Arc::new(Float32Array::from(flat)), None)),
],
)?)?;
// Retrieve context to ground the agent's next answer:
let keyword = docs.bm25_search("body", "cancel subscription", 5, BoolMode::Or, None)?;
let semantic = docs.vector_search("embedding", &embed(0), 5, VectorSearchOptions::new(), None, None)?;
// vector kNN, restricted to rows whose body matches a keyword (pushdown filter):
let filtered = docs.vector_search(
"embedding", &embed(0), 5, VectorSearchOptions::new(),
Some(VectorFilter { column: "body", query: "billing", mode: BoolMode::Or }), None,
)?;
let billing = db.query_sql("SELECT body FROM docs WHERE source = 'help-center'")?;
assert_eq!(keyword.iter().map(|b| b.num_rows()).sum::<usize>(), 1); // BM25
assert!(semantic.iter().map(|b| b.num_rows()).sum::<usize>() >= 1); // vector kNN
assert_eq!(filtered.iter().map(|b| b.num_rows()).sum::<usize>(), 1); // vector + keyword filter
assert_eq!(billing.iter().map(|b| b.num_rows()).sum::<usize>(), 2); // SQL filter
# Ok(())
# }
Los bindings viven en infino-python/ (PyO3 + maturin) y infino-node/; consulta sus README para compilar desde el código fuente. La API de Node es síncrona — objetos de entrada, registros simples de salida, con _id devuelto como un bigint de JavaScript.
Formato abierto: léelo como Parquet
Un superarchivo es un archivo Parquet conforme a la especificación. Las regiones incrustadas de índice BM25 y vectorial se insertan antes de un pie de página Parquet estándar y son señaladas por claves de metadatos clave/valor inf.*, que cualquier lector Parquet conforme ignora. Así, el cuerpo columnar se abre en DuckDB, pandas, pyarrow o DataFusion sin infino en la ruta de lectura y sin paso de exportación:
import infino, pyarrow as pa, glob, duckdb
db = infino.connect("./data") # persist to disk (not "memory://")
docs = db.create_table(
"docs",
pa.schema([
pa.field("source", pa.large_utf8(), nullable=False),
pa.field("body", pa.large_utf8(), nullable=False),
]),
infino.IndexSpec().fts("body"),
)
docs.append([
{"source": "help-center", "body": "To cancel a subscription, open Settings then Billing."},
{"source": "help-center", "body": "Refunds return to the original payment method."},
{"source": "blog", "body": "Enable dark mode under Settings then Appearance."},
])
# The superfiles are ordinary files on disk (one write can shard into
# several, so read them as a set):
files = glob.glob("data/**/*.sf.parquet", recursive=True)
print(files[0]) # e.g. data/docs-18bc4051eb6a9468-0/data/seg-....sf.parquet
# Read them with a third-party engine, no infino in this line:
duckdb.sql("SELECT source, count(*) FROM read_parquet('data/**/*.sf.parquet') GROUP BY source").show()
# ┌─────────────┬──────────────┐
# │ source │ count_star() │
# ├─────────────┼──────────────┤
# │ help-center │ 2 │
# │ blog │ 1 │
# └─────────────┴──────────────┘
Apertura de solo lectura. Las herramientas estándar leen las columnas de un superarchivo sin paso de exportación. Reescribirlo a través de un escritor Parquet genérico (p. ej. pyarrow.parquet.write_table) produce Parquet válido que ha eliminado silenciosamente los índices BM25/vectoriales incrustados, por lo que ya no es un superarchivo. La compatibilidad es unidireccional.
La demostración de extremo a extremo más corta (escribir un corpus, ejecutar recuperación BM25 + vectorial + SQL/híbrida contra él, y luego leer el mismo archivo con DuckDB y pyarrow) es infino-python/examples/parquet_interop.py.
Almacenamiento en la nube
El backend se elige por el esquema de URI — s3://bucket/prefix, az://container/prefix, gs://bucket/prefix, file://path, una ruta simple, o memory://. Las credenciales pasan por ConnectOptions, con claves basadas en las cadenas de configuración de object_store (aws_* / azure_* / google_* — los nombres que usan los SDK de AWS/Azure/GCS). Infino no lee credenciales del entorno; omítelas para usar identidad de nube ambiental (rol de instancia IAM / identidad administrada / ADC de identidad de carga de trabajo).
use infino::{connect_with, ConnectOptions};
// S3
let db = connect_with("s3://bucket/prefix", ConnectOptions::new()
.with_storage_option("aws_access_key_id", "…")
.with_storage_option("aws_secret_access_key", "…")
.with_storage_option("aws_region", "us-east-1"))?;
// Azure
let db = connect_with("az://container/prefix", ConnectOptions::new()
.with_storage_option("azure_storage_account_name", "…")
.with_storage_option("azure_storage_account_key", "…"))?;
// GCS
let db = connect_with("gs://bucket/prefix", ConnectOptions::new()
.with_storage_option("google_service_account_key", "…"))?;
# Ok::<(), Box<dyn std::error::Error>>(())
Claves comunes:
| Backend | Claves |
|---|---|
| S3 | aws_access_key_id, aws_secret_access_key, aws_region, aws_session_token, aws_endpoint |
| Azure | azure_storage_account_name, azure_storage_account_key, azure_storage_sas_key, azure_storage_client_id, azure_storage_client_secret, azure_storage_tenant_id |
| GCS | google_service_account (ruta), google_service_account_key (JSON en línea), google_application_credentials, google_skip_signature |
El conjunto completo es lo que object_store acepta para el backend; una clave desconocida o de otro backend se rechaza al conectar. with_validate(true) opta por una sonda de alcanzabilidad en tiempo de conexión, por lo que las credenciales incorrectas fallan en connect en lugar de en la primera consulta. Las mismas opciones existen en el archivo de configuración (storage.storage_options) y en ambos bindings (storage_options + validate).
Arquitectura
Tres documentos cubren el diseño, desde el recorrido de alto nivel hasta los bytes en disco:
- Descripción general → — el recorrido en lenguaje sencillo: qué es infino, el modelo mental y cómo se compara con otros sistemas.
- Formato superarchivo → — el formato de superarchivo de un solo archivo: un archivo Parquet válido con índices de texto completo y vectoriales incrustados. Cubre el diseño, la compatibilidad con Parquet y el diseño de los índices de texto completo y vectorial.
- Capa de supertabla → — la capa de tabla sobre muchos superarchivos: instantáneas de manifiesto, la ruta de confirmación/publicación, almacenamiento conectable, fan-out de consultas con poda de omisión solo por manifiesto, y concurrencia de lector/escritor.
Para conceptos, inicio rápido, guías y ejemplos (Python, Node.js y Rust), consulta la documentación completa en infino.ai/docs.
Uniones SQL entre tablas
query_sql resuelve cada tabla que la consulta nombra a través del catálogo en un solo motor, y las funciones de tabla bm25_search / vector_search / hybrid_search también son relaciones — por lo que una sola consulta puede fusionar la recuperación por palabras clave y vectorial y unir el resultado a una tabla ordinaria. Esta es la recuperación canónica de agentes, de extremo a extremo: búsqueda híbrida en una base de conocimiento, fusión de los dos rankings (fusión de rango recíproco) y unión de procedencia — una instantánea, sin costura en el lado del cliente.
use std::sync::Arc;
use infino::arrow_array::{FixedSizeListArray, Float32Array, Int64Array, LargeStringArray, RecordBatch};
use infino::arrow_schema::{DataType, Field, Schema};
use infino::{connect, IndexSpec, Metric};
// Tiny stand-in for your embedding model so this runs as-is; real
// embeddings are dense and higher-dimensional (e.g. 1536).
fn embed(topic: usize) -> Vec<f32> {
let mut v = vec![0.0_f32; 16];
v[topic] = 1.0;
v
}
# fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = connect("memory://")?;
// `docs`: text (BM25) + embedding (vector) + the source it came from.
let item = Arc::new(Field::new("item", DataType::Float32, true));
let docs_schema = Arc::new(Schema::new(vec![
Field::new("source", DataType::LargeUtf8, false),
Field::new("body", DataType::LargeUtf8, false),
Field::new("embedding", DataType::FixedSizeList(item.clone(), 16), false),
]));
let docs = db.create_table(
"docs",
docs_schema.clone(),
IndexSpec::new().fts("body").vector("embedding", 16, Metric::Cosine),
)?;
let flat: Vec<f32> = [0usize, 0, 1].iter().flat_map(|&t| embed(t)).collect();
docs.append(&RecordBatch::try_new(
docs_schema,
vec![
Arc::new(LargeStringArray::from(vec!["help-center", "help-center", "blog"])),
Arc::new(LargeStringArray::from(vec![
"To cancel a subscription, open Settings then Billing.",
"Refunds return to the original payment method.",
"Enable dark mode under Settings then Appearance.",
])),
Arc::new(FixedSizeListArray::new(item, 16, Arc::new(Float32Array::from(flat)), None)),
],
)?)?;
// `sources`: a plain table — where each source came from, and its trust.
let sources_schema = Arc::new(Schema::new(vec![
Field::new("source", DataType::LargeUtf8, false),
Field::new("url", DataType::LargeUtf8, false),
Field::new("trust", DataType::Int64, false),
]));
let sources = db.create_table("sources", sources_schema.clone(), IndexSpec::new())?;
sources.append(&RecordBatch::try_new(
sources_schema,
vec![
Arc::new(LargeStringArray::from(vec!["help-center", "blog"])),
Arc::new(LargeStringArray::from(vec![
"https://help.example.com",
"https://blog.example.com",
])),
Arc::new(Int64Array::from(vec![2, 1])),
],
)?)?;
// The agent's question, embedded like the corpus. The vector TVF takes
// the query vector as a comma-separated string, so build the SQL with it.
let qvec = embed(0).iter().map(|x| x.to_string()).collect::<Vec<_>>().join(",");
let sql = format!(
"WITH lexical AS ( -- BM25 candidates, ranked
SELECT _id, source, body, ROW_NUMBER() OVER (ORDER BY score DESC) AS rank
FROM bm25_search('docs', 'body', 'how do I cancel my subscription?', 50)
),
semantic AS ( -- vector candidates (nearer = lower score)
SELECT _id, source, body, ROW_NUMBER() OVER (ORDER BY score ASC) AS rank
FROM vector_search('docs', 'embedding', '{qvec}', 50)
)
SELECT s.url,
COALESCE(l.body, v.body) AS chunk,
COALESCE(1.0/(60+l.rank), 0.0) + COALESCE(1.0/(60+v.rank), 0.0) AS relevance
FROM lexical l
FULL OUTER JOIN semantic v ON l._id = v._id -- fuse lexical + semantic
JOIN sources s ON s.source = COALESCE(l.source, v.source) -- + provenance
WHERE s.trust >= 1
ORDER BY relevance DESC
LIMIT 5"
);
let context = db.query_sql(&sql)?;
assert!(context.iter().map(|b| b.num_rows()).sum::<usize>() >= 1);
# Ok(())
# }
Haciéndolo real. embed() aquí es un juguete de 16 dimensiones para que el ejemplo funcione tal como está; cambia tu modelo de incrustación y eleva dim para que coincida (p. ej. 1536 / 256). La TVF vectorial toma el vector de consulta como una cadena separada por comas — esa es la única razón por la que la consulta se construye con format!. El SQL en sí es idéntico desde Python y Node; solo difieren la creación de tablas y la incrustación.
Búsqueda híbrida
Infino también conecta índices en la ejecución de SQL como rutas de acceso físicas:
-- The text predicate is answered from the FTS index — inverted index →
-- candidate rows → decode only those rows — never a full column scan.
SELECT category, AVG(rating)
FROM reviews
WHERE title = 'battery life'
GROUP BY category;
Igualdad, IN y combinaciones booleanas en una columna de texto indexada se resuelven a través del índice a un conjunto exacto de filas candidatas antes de leer cualquier dato de columna. Los superarchivos que no pueden coincidir nunca se abren: los blooms de términos, los rangos de valores y los centroides vectoriales viven lado a lado en el manifiesto, por lo que las señales escalares, de palabras clave y vectoriales podan a través de una capa compartida.
La recuperación se compone de la misma manera. Los bm25_search / vector_search / hybrid_search clasificados y los token_match / exact_match no clasificados son funciones de tabla, por lo que un conjunto candidato es la primera etapa de un plan en lugar de su resultado:
-- Rank first; join and aggregate over just the candidates.
SELECT a.name, COUNT(*) AS hits
FROM bm25_search('posts', 'body', 'rust async', 100) p
JOIN authors a ON a.author_id = p.author_id
GROUP BY a.name
ORDER BY hits DESC;
-- Set algebra over index-bounded candidate sets: "rust but not compiler".
SELECT _id FROM token_match('posts', 'body', 'rust')
EXCEPT
SELECT _id FROM token_match('posts', 'body', 'compiler');
Una instantánea, una copia de los datos: los predicados dispersos (BM25), densos (vectoriales) y estructurados (escalares) se componen dentro del motor — sin segundo sistema que sincronizar, sin costura de resultados en el lado del cliente.
Estabilidad
La API pública es lo que se re-exporta desde la raíz del crate — connect / connect_with, Connection, Supertable, IndexSpec, InfinoError y los tipos de valor que sus firmas nombran. Está fijada por una instantánea cargo-public-api (public-api.txt); cualquier cambio se revisa como un cambio de contrato en la misma solicitud de extracción.
- Versionado. 0.x mientras la superficie se asienta; 1.0 una vez que haya sido lanzada sin cambios durante una o dos versiones. Pre-1.0 puede romper, pero cada ruptura se muestra en el diff de la instantánea y se menciona en las notas de la versión.
#[non_exhaustive]en enums/structs públicos ampliables (p. ej.InfinoError,MutationStats), por lo que agregar una variante o campo no es un cambio que rompa.- Arrow / DataFusion son parte del contrato. La API es nativa de Arrow (
RecordBatch,SchemaRef,Expr); un aumento mayor de arrow / datafusion que cambie un tipo expuesto es un cambio que rompe para infino. El rango de versiones soportadas está documentado y probado en CI. - MSRV. La versión mínima de Rust soportada es 1.95 (impuesta por
rust-versionenCargo.toml). Elevarla es un aumento menor, nunca un parche. - Deprecación. Después de 1.0, las eliminaciones pasan por
#[deprecated]durante al menos una versión menor primero. - Los bindings versionan de forma independiente. Los paquetes de Python (
pip install infino) y Node (npm install @infino-ai/infino) se versionan en sus propias líneas SemVer — cada uno incrusta su propia copia del motor, por lo que la versión de un binding no necesita coincidir con la de este crate. Verdocs/versioning.md.
Desarrollo
git clone git@github.com:infino-ai/infino.git
cd infino
cargo build
cargo run --example demo # end-to-end tour: build, BM25 + vector search, read back as Parquet
La cadena de herramientas está fijada por rust-toolchain.toml, por lo que rustup instala el Rust estable correcto en la primera compilación. Ejecuta cargo test --features test-helpers para la suite (las pruebas de integración usan infino::test_helpers) y make ci antes de abrir una solicitud de extracción. Explora la API completa localmente con make doc (cargo doc --no-deps --open — los mismos documentos que docs.rs renderiza).
Para una experiencia de desarrollo local mejorada, instala y configura los hooks de pre-commit con pre-commit install para detectar problemas de formato y lint antes de confirmar.
Consulta CONTRIBUTING.md para la guía completa de desarrollo.
Rendimiento
Los benchmarks viven bajo benches/ y usan el arnés de benchmarks personalizado de Infino para que la compilación, la corrección, las lecturas en caliente, las lecturas en frío de almacenamiento de objetos, el RSS y la salida de markdown compartan un ciclo de vida medido. Ejecuta cargo bench para reproducirlos en tu hardware.
Pruebas
Ejecuta cargo test --workspace para la suite completa. Cubre los pipelines de extremo a extremo de texto completo, vectorial y superarchivo, ingesta y confirmación, y compatibilidad de formato abierto — DataFusion lee superarchivos como Parquet simple, con proyección de columnas, GROUP BY y pushdown de predicados que coinciden con los datos columnares.
Seguridad de memoria. La superficie de texto completo se ejecuta limpia bajo miri (Stacked Borrows + detección de UB) y AddressSanitizer; ejecuta make miri y make asan.