Infino
Infino — recuperação por palavra-chave, vetorial, híbrida e SQL sobre dados em armazenamento de objetos, para agentes de IA.
Documentação
infino
O infino é um mecanismo de recuperação rápido que executa SQL, busca de texto completo e busca vetorial sobre uma única cópia dos seus dados em armazenamento de objetos. Os dados permanecem em Parquet no S3 (ou Azure, GCS ou disco local) e você pode consultá-los em escala.
Por que infino?
- Velocidade por dólar — o infino otimiza para velocidade por dólar, fazendo concessões para alcançar a economia de armazenamento de objetos em velocidades de mecanismo de busca. Em um índice de 1 milhão de documentos, consultas BM25 quentes retornam na faixa de microssegundos — veja benchmarks.
- Consultas multimodais — consultas por palavra-chave (BM25), vetoriais e SQL sobre as mesmas linhas, oferecendo caminhos de consulta flexíveis para agentes.
- Nativo de armazenamento de objetos — os dados vivem em S3, Azure, GCS ou disco local, com leituras isoladas por snapshot e commits atômicos.
- Formato aberto, sem aprisionamento — dados textuais e numéricos são armazenados como Parquet compatível com a especificação, então qualquer coisa que leia Parquet pode ler seus dados.
Conteúdo
- Instalação
- Início rápido
- Armazenamento em nuvem
- Arquitetura
- Junções SQL entre tabelas
- Busca híbrida
- Estabilidade
- Desenvolvimento
- Desempenho
- Testes
Instalação
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
ou em Cargo.toml:
[dependencies]
infino = "0.1"
A referência completa da API Rust está em docs.rs/infino.
O infino instala o alocador global mimalloc por padrão. Se você incorporar o infino em um processo que já define um alocador global, desative-o para evitar um segundo: infino = { version = "0.1", default-features = false }.
Início 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(())
# }
Os bindings estão em infino-python/ (PyO3 + maturin) e infino-node/; veja os READMEs deles para compilar a partir do código-fonte. A API Node é síncrona — objetos entram, registros simples saem, com _id retornado como um bigint JavaScript.
Formato aberto: leia como Parquet
Um superfile é um arquivo Parquet compatível com a especificação. As regiões de índice BM25 e vetorial embutidas são inseridas antes de um rodapé Parquet padrão e apontadas pelas chaves de metadados inf.* key/value, que qualquer leitor Parquet conforme ignora. Assim, o corpo colunar abre no DuckDB, pandas, pyarrow ou DataFusion com nenhum infino no caminho de leitura e sem etapa de exportação:
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 │
# └─────────────┴──────────────┘
Abertura somente leitura. Ferramentas padrão leem as colunas de um superfile sem etapa de exportação. Reescrevê-lo através de um gravador Parquet genérico (por exemplo, pyarrow.parquet.write_table) produz Parquet válido que silenciosamente descartou os índices BM25/vetoriais embutidos, então não é mais um superfile. A compatibilidade é unidirecional.
A demonstração mais curta de ponta a ponta (escrever um corpus, executar recuperação BM25 + vetorial + SQL/híbrida contra ele, e então ler o mesmo arquivo de volta com DuckDB e pyarrow) está em infino-python/examples/parquet_interop.py.
Armazenamento em nuvem
O backend é escolhido pelo esquema de URI — s3://bucket/prefix, az://container/prefix, gs://bucket/prefix, file://path, um caminho simples, ou memory://. As credenciais passam por ConnectOptions, chaveadas pelas strings de configuração do object_store (aws_* / azure_* / google_* — os nomes que os SDKs AWS/Azure/GCS usam). O infino não lê credenciais do ambiente; omita-as para usar identidade de nuvem ambiente (papel de instância IAM / identidade gerenciada / ADC de identidade de carga de trabalho).
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>>(())
Chaves comuns:
| Backend | Chaves |
|---|---|
| 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 (caminho), google_service_account_key (JSON inline), google_application_credentials, google_skip_signature |
O conjunto completo é o que object_store aceita para o backend; uma chave desconhecida ou entre backends é rejeitada na conexão. with_validate(true) opta por uma sondagem de alcançabilidade no momento da conexão, então credenciais ruins falham em connect em vez de na primeira consulta. As mesmas opções existem no arquivo de configuração (storage.storage_options) e em ambos os bindings (storage_options + validate).
Arquitetura
Três documentos cobrem o design, do tour de alto nível até os bytes no disco:
- Visão geral → — o tour em linguagem simples: o que é o infino, o modelo mental e como ele se compara a outros sistemas.
- Formato superfile → — o formato superfile de arquivo único: um arquivo Parquet válido com índices de texto completo e vetoriais embutidos. Cobre o layout, a compatibilidade com Parquet e o design dos índices de texto completo e vetorial.
- Camada supertable → — a camada de tabela sobre muitos superfiles: snapshots de manifesto, o caminho de commit/publicação, armazenamento plugável, fan-out de consulta com poda de salto apenas por manifesto e concorrência leitor/escritor.
Para conceitos, início rápido, guias e exemplos (Python, Node.js e Rust), veja a documentação completa em infino.ai/docs.
Junções SQL entre tabelas
query_sql resolve cada tabela que a consulta nomeia através do catálogo em um único mecanismo, e as funções de tabela bm25_search / vector_search / hybrid_search também são relações — então uma única consulta pode fundir recuperação por palavra-chave e vetorial e juntar o resultado a uma tabela comum. Esta é a recuperação canônica de agente, de ponta a ponta: busca híbrida em uma base de conhecimento, fusão dos dois rankings (fusão por classificação recíproca) e junção de proveniência — um snapshot, sem costura no lado do 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(())
# }
Tornando real. embed() aqui é um brinquedo de 16 dimensões para o exemplo rodar como escrito; troque pelo seu modelo de embedding e aumente dim para corresponder (por exemplo, 1536 / 256). A TVF vetorial recebe o vetor de consulta como uma string separada por vírgulas — essa é a única razão pela qual a consulta é construída com format!. O SQL em si é idêntico em Python e Node; apenas a criação de tabela e o embedding diferem.
Busca híbrida
O infino também conecta índices à execução SQL como caminhos de acesso físicos:
-- 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;
Igualdade, IN e combinações booleanas em uma coluna de texto indexada resolvem através do índice para um conjunto exato de linhas candidatas antes que qualquer dado de coluna seja lido. Superfiles que não podem corresponder nunca são abertos: blooms de termos, faixas de valores e centroides vetoriais vivem lado a lado no manifesto, então sinais escalares, de palavra-chave e vetoriais podam através de uma camada compartilhada.
A recuperação compõe da mesma forma. As funções de tabela classificadas bm25_search / vector_search / hybrid_search e as não classificadas token_match / exact_match são funções de tabela, então um conjunto candidato é o primeiro estágio de um plano em vez de seu 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');
Um snapshot, uma cópia dos dados: predicados esparsos (BM25), densos (vetorial) e estruturados (escalar) compõem dentro do mecanismo — sem segundo sistema para sincronizar, sem costura de resultados no lado do cliente.
Estabilidade
A API pública é o que é reexportado da raiz do crate — connect / connect_with, Connection, Supertable, IndexSpec, InfinoError e os tipos de valor que suas assinaturas nomeiam. Ela é fixada por um snapshot cargo-public-api (public-api.txt); qualquer mudança nela é revisada como uma mudança de contrato no mesmo pull request.
- Versionamento. 0.x enquanto a superfície amadurece; 1.0 assim que ela for lançada sem turbulência por uma ou duas versões. Pré-1.0 pode quebrar, mas cada quebra aparece no diff do snapshot e é destacada nas notas de versão.
#[non_exhaustive]em enums/structs públicos expansíveis (por exemplo,InfinoError,MutationStats), então adicionar uma variante ou campo não é uma mudança que quebra.- Arrow / DataFusion fazem parte do contrato. A API é nativa de Arrow (
RecordBatch,SchemaRef,Expr); um bump importante de arrow / datafusion que mude um tipo exposto é uma mudança que quebra para o infino. A faixa de versões suportada é documentada e testada em CI. - MSRV. A versão mínima suportada de Rust é 1.95 (imposta por
rust-versionemCargo.toml). Elevá-la é um bump menor, nunca um patch. - Deprecação. Pós-1.0, remoções passam por
#[deprecated]por pelo menos uma versão menor primeiro. - Bindings versionam independentemente. Os pacotes Python (
pip install infino) e Node (npm install @infino-ai/infino) são versionados em suas próprias linhas SemVer — cada um embute sua própria cópia do mecanismo, então a versão de um binding não precisa corresponder à deste crate. Vejadocs/versioning.md.
Desenvolvimento
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
A toolchain é fixada por rust-toolchain.toml, então rustup instala o Rust estável correto no primeiro build. Execute cargo test --features test-helpers para a suíte (testes de integração usam infino::test_helpers) e make ci antes de abrir um pull request. Navegue pela API completa localmente com make doc (cargo doc --no-deps --open — os mesmos docs que docs.rs renderiza).
Para uma experiência de desenvolvimento local aprimorada, instale e configure os hooks de pre-commit com pre-commit install para capturar problemas de formatação e lint antes de commitar.
Veja CONTRIBUTING.md para o guia completo de desenvolvimento.
Desempenho
Os benchmarks estão em benches/ e usam o harness de benchmark personalizado do Infino, para que build, correção, leituras quentes, leituras frias de object store, RSS e saída markdown compartilhem um ciclo de vida medido. Execute cargo bench para reproduzi-los no seu hardware.
Testes
Execute cargo test --workspace para a suíte completa. Ela cobre os pipelines de ponta a ponta de texto completo, vetorial e superfile, ingestão e commit, e compatibilidade de formato aberto — DataFusion lê superfiles como Parquet simples, com projeção de colunas, GROUP BY e pushdown de predicados todos correspondendo aos dados colunares.
Segurança de memória. A superfície de texto completo roda limpa sob miri (Stacked Borrows + detecção de UB) e AddressSanitizer; execute make miri e make asan.