Cómo Crear una Base de Conocimiento con IA para Tu Equipo en 48 Horas
El mes pasado, nuestro equipo de producto de 35 personas estaba completamente desbordado de documentación. Teníamos wikis, Google Docs, páginas de Confluence, bases de datos de Notion, hilos de Slack con decisiones clave enterradas en ellos y una unidad compartida con cientos de PDFs en los que nadie encontraba nada. ¿Te suena familiar?
Me pasé un fin de semana construyendo una base de conocimiento impulsada por IA que permite a cualquier miembro del equipo hacer preguntas en lenguaje natural y obtener respuestas precisas extraídas de toda nuestra documentación. Tiempo total de construcción: unas 48 horas. Coste mensual de funcionamiento: 153 $. En menos de una semana, el equipo pasó del "no encuentro ese documento" al "pregúntale a la base de conocimiento".
Aquí te cuento exactamente cómo lo construí, paso a paso, con estimaciones de tiempo para que puedas planificar tu propio proyecto.
Qué vamos a construir
La arquitectura se llama RAG — Generación Aumentada por Recuperación. En lugar de pedirle a un modelo de IA que lo sepa todo (algo que ninguno es capaz de hacer), le damos acceso a nuestros propios documentos y le dejamos que los busque para responder preguntas. Piensa en ello como darle a ChatGPT acceso al cerebro de tu empresa.
El stack tecnológico:
- Python 3.11+ — la base de todo
- LangChain — framework de orquestación para conectar LLMs con datos
- ChromaDB — base de datos vectorial para almacenar embeddings de documentos
- OpenAI Embeddings (text-embedding-3-small) — convierte texto en vectores indexables
- OpenAI GPT-4o-mini — genera respuestas a partir del contexto recuperado
- Streamlit — interfaz web rápida y limpia
Al final de este tutorial tendrás un sistema funcional donde los usuarios escriben una pregunta, el sistema encuentra los fragmentos de documentos más relevantes y un LLM sintetiza una respuesta con citas de las fuentes.
Horas 0-2: Configuración del entorno y dependencias
Primero, vamos a montar la estructura del proyecto. Crea un nuevo directorio y configura un entorno virtual:
mkdir team-knowledge-base
cd team-knowledge-base
python -m venv venv
source venv/bin/activate # En Windows: venv\Scripts\activate
pip install langchain langchain-openai langchain-community
pip install chromadb
pip install streamlit
pip install python-dotenv
pip install pypdf docx2txt unstructured
pip install tiktoken
Crea un archivo .env para tu clave de API:
OPENAI_API_KEY=sk-tu-clave-aquí
Y configura la estructura del proyecto:
team-knowledge-base/
├── .env
├── app.py # Frontend de Streamlit
├── ingest.py # Pipeline de procesamiento de documentos
├── query_engine.py # Lógica de consulta RAG
├── config.py # Configuración compartida
├── documents/ # Deja tus documentos aquí
│ ├── pdfs/
│ ├── markdown/
│ └── text/
└── chroma_db/ # Almacenamiento de la base de datos vectorial
Control de tiempo: esto debería llevarte unos 30 minutos si ya tienes experiencia con entornos Python, o hasta 2 horas si necesitas instalar Python desde cero y configurar las claves de API.
Horas 2-8: Pipeline de ingesta de documentos
Esta es la parte más crítica de todo el sistema. La forma en que divides tus documentos en fragmentos determina la calidad de las respuestas. Lo aprendí por las malas — en mi primer intento usé fragmentos ingenuos de 1000 caracteres y las respuestas eran un desastre.
La estrategia de fragmentación que realmente funciona
Tras probar cinco enfoques distintos de fragmentación, esto es lo que mejor funcionó para nuestra documentación en formatos mixtos:
# config.py
CHUNK_SIZE = 1500 # caracteres por fragmento
CHUNK_OVERLAP = 200 # solapamiento entre fragmentos
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4o-mini"
COLLECTION_NAME = "team_knowledge"
CHROMA_DIR = "./chroma_db"
¿Por qué 1500 caracteres con 200 de solapamiento? Tras las pruebas, descubrí que:
- Demasiado pequeño (500 chars): Las respuestas carecen de contexto. El modelo recibe un fragmento de frase y no puede sintetizar una respuesta útil.
- Demasiado grande (3000+ chars): La precisión de recuperación baja. Cuando un fragmento abarca varios temas, se recupera para consultas en las que solo una parte es relevante, añadiendo ruido al contexto.
- 1500 chars: El punto óptimo para párrafos de documentación típicos. La mayoría de las ideas completas caben en esta ventana.
- 200 chars de solapamiento: Evita que se pierdan ideas que cruzan los límites entre fragmentos. Fundamental para listas numeradas e instrucciones paso a paso.
Y aquí está el script de ingesta:
# ingest.py
import os
from dotenv import load_dotenv
from langchain_community.document_loaders import (
PyPDFLoader,
TextLoader,
Docx2txtLoader,
UnstructuredMarkdownLoader,
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from config import *
load_dotenv()
def load_documents(docs_dir="./documents"):
'Carga todos los tipos de documentos compatibles del árbol de directorios.'
documents = []
loaders_map = {
".pdf": PyPDFLoader,
".txt": TextLoader,
".docx": Docx2txtLoader,
".md": UnstructuredMarkdownLoader,
}
for root, dirs, files in os.walk(docs_dir):
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext in loaders_map:
file_path = os.path.join(root, file)
try:
loader = loaders_map[ext](file_path)
docs = loader.load()
# Añadir metadatos de origen
for doc in docs:
doc.metadata["source"] = file_path
doc.metadata["filename"] = file
documents.extend(docs)
print(f" Cargado: {file} ({len(docs)} páginas/secciones)")
except Exception as e:
print(f" Error al cargar {file}: {e}")
return documents
def chunk_documents(documents):
'Divide los documentos en fragmentos solapados.'
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = splitter.split_documents(documents)
print(f" {len(documents)} documentos divididos en {len(chunks)} fragmentos")
return chunks
def create_vector_store(chunks):
'Genera embeddings de los fragmentos y los almacena en ChromaDB.'
embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=COLLECTION_NAME,
persist_directory=CHROMA_DIR,
)
print(f" Base de datos vectorial creada con {len(chunks)} embeddings")
return vector_store
if __name__ == "__main__":
print("Paso 1: Cargando documentos...")
docs = load_documents()
print(f" Total de documentos cargados: {len(docs)}")
print("Paso 2: Fragmentando documentos...")
chunks = chunk_documents(docs)
print("Paso 3: Creando base de datos vectorial...")
store = create_vector_store(chunks)
print("¡Listo! La base de conocimiento está preparada.")
El RecursiveCharacterTextSplitter es clave aquí. Intenta dividir primero por saltos de párrafo (\n\n), luego por saltos de línea, después por frases y finalmente por palabras. Esto significa que tus fragmentos casi siempre contendrán ideas completas en lugar de cortarse a mitad de frase.
Cómo gestionar diferentes tipos de documentos
Una nota rápida sobre los tipos de documentos, porque esto me costó bastante al principio:
- PDFs: PyPDF funciona para la mayoría de los documentos, pero los PDFs escaneados necesitan OCR. Si tienes documentos escaneados, añade
pip install pytesseracty usaUnstructuredPDFLoaderen su lugar. - Google Docs: Expórtalos primero como .docx. La API de Google Drive puede automatizarlo, pero para el MVP la exportación manual es suficiente.
- Confluence: Exporta los espacios como HTML y usa
UnstructuredHTMLLoader. Gestiona el formato anidado sorprendentemente bien. - Hilos de Slack: Este es el más complicado. Escribí un pequeño script que usa la API de Slack para exportar hilos guardados como archivos de texto. Es otro tutorial aparte, pero lo importante es incluir el contexto del hilo (nombre del canal, participantes, fecha) como metadatos.
Horas 8-16: Motor de consultas
Ahora viene la parte entretenida: hacer que la base de conocimiento responda preguntas de verdad.
# query_engine.py
import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from config import *
load_dotenv()
# Prompt personalizado que exige citar las fuentes
PROMPT_TEMPLATE = (
"Eres un asistente útil para un equipo de producto.\n"
"Usa el siguiente contexto para responder la pregunta.\n"
"Si no conoces la respuesta basándote en el contexto, dilo honestamente.\n"
"Cita siempre los documentos de los que extraes la información.\n\n"
"Contexto:\n{context}\n\n"
"Pregunta: {question}\n\n"
"Respuesta (cita tus fuentes):"
)
def get_query_engine():
'Inicializa y devuelve el motor de consulta RAG.'
embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)
vector_store = Chroma(
collection_name=COLLECTION_NAME,
persist_directory=CHROMA_DIR,
embedding_function=embeddings,
)
retriever = vector_store.as_retriever(
search_type="mmr", # Relevancia Marginal Máxima
search_kwargs={
"k": 6, # Devuelve los 6 fragmentos más relevantes
"fetch_k": 20, # Considera los 20 primeros antes del filtrado MMR
"lambda_mult": 0.7, # Balance entre diversidad y relevancia
}
)
llm = ChatOpenAI(model=LLM_MODEL, temperature=0.1)
prompt = PromptTemplate(
template=PROMPT_TEMPLATE,
input_variables=["context", "question"]
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True,
chain_type_kwargs={"prompt": prompt}
)
return qa_chain
def query(question):
'Consulta la base de conocimiento y devuelve la respuesta + fuentes.'
engine = get_query_engine()
result = engine.invoke({"query": question})
sources = set()
for doc in result.get("source_documents", []):
sources.add(doc.metadata.get("filename", "Desconocido"))
return {
"answer": result["result"],
"sources": list(sources),
}
Por qué usar MMR en lugar de búsqueda por similitud simple
Quiero destacar el parámetro search_type="mmr" porque marca una diferencia enorme. La búsqueda por similitud estándar devuelve los 6 fragmentos más similares a tu consulta, pero esos 6 fragmentos podrían venir todos del mismo documento diciendo más o menos lo mismo. Es redundante y desaprovecha la ventana de contexto.
MMR (Relevancia Marginal Máxima) equilibra relevancia con diversidad. Elige el primer fragmento basándose puramente en similitud, y para cada fragmento siguiente penaliza los que son demasiado parecidos a los ya seleccionados. El resultado son 6 fragmentos que son todos relevantes pero cubren aspectos diferentes del tema.
El parámetro lambda_mult=0.7 controla este equilibrio: 1.0 es similitud pura (sin diversidad), 0.0 es diversidad pura (puede no ser relevante). Con 0.7 se logra el punto óptimo para consultas sobre documentación.
Horas 16-24: Frontend con Streamlit
Es el momento de hacer que esto lo pueda usar cualquier persona del equipo, no solo quien tenga un terminal abierto todo el día.
# app.py
import streamlit as st
from query_engine import query, get_query_engine
import time
st.set_page_config(
page_title="Base de Conocimiento del Equipo",
page_icon="search",
layout="wide"
)
st.title("Base de Conocimiento del Equipo")
st.markdown("Pregunta lo que quieras sobre nuestra documentación, procesos o decisiones.")
# Inicializar el motor una sola vez
if "engine" not in st.session_state:
with st.spinner("Cargando base de conocimiento..."):
st.session_state.engine = get_query_engine()
# Historial de chat
if "messages" not in st.session_state:
st.session_state.messages = []
# Mostrar historial de chat
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if msg.get("sources"):
with st.expander("Fuentes"):
for src in msg["sources"]:
st.markdown(f"- {src}")
# Entrada
if prompt := st.chat_input("¿Qué quieres saber?"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("Buscando en la base de conocimiento..."):
start = time.time()
result = query(prompt)
elapsed = time.time() - start
st.markdown(result["answer"])
st.caption(f"Tiempo de respuesta: {elapsed:.1f}s")
if result["sources"]:
with st.expander("Fuentes"):
for src in result["sources"]:
st.markdown(f"- {src}")
st.session_state.messages.append({
"role": "assistant",
"content": result["answer"],
"sources": result["sources"],
})
# Barra lateral
with st.sidebar:
st.header("Acerca de")
st.markdown(
"Esta base de conocimiento busca en toda la documentación "
"del equipo para responder tus preguntas."
)
st.markdown("---")
st.markdown("**Consejos para mejores resultados:**")
st.markdown("- Sé específico con tus preguntas")
st.markdown("- Pregunta sobre un tema a la vez")
st.markdown("- Si la respuesta no es la esperada, reformula la pregunta")
Ejecútalo con streamlit run app.py y ya tendrás una base de conocimiento funcional con interfaz de chat.
Horas 24-36: Llevarlo al siguiente nivel
La versión básica funciona, pero esto es lo que añadí para hacerla apta para producción con un equipo de 35 personas:
1. Filtrado por metadatos
No todos los documentos tienen el mismo valor. Un documento de especificaciones de la semana pasada es más relevante que uno de hace dos años. Añadí metadatos de fecha a los fragmentos y modifiqué el recuperador para filtrar opcionalmente por rango de fechas o categoría de documento.
# En ingest.py, al cargar documentos:
doc.metadata["ingested_date"] = datetime.now().isoformat()
doc.metadata["category"] = determine_category(file_path) # Basado en la estructura de carpetas
2. Bucle de retroalimentación
Añadí botones de pulgares arriba/abajo a cada respuesta y registré los resultados en una base de datos SQLite. Después de una semana, tenía suficientes datos para identificar qué documentos generaban respuestas deficientes (normalmente porque estaban desactualizados o mal estructurados) y qué patrones de consulta necesitaban un mejor tratamiento.
3. Re-ingesta automática
Configuré un cron job que ejecuta el script de ingesta cada noche sobre una carpeta de Google Drive sincronizada. Cuando alguien actualiza un documento, la base de conocimiento recoge los cambios en menos de 24 horas. Para la unidad compartida, usé rclone para sincronizar con el servidor antes de que se ejecute la ingesta.
4. Control de acceso
Para nuestro equipo esto no era una preocupación importante (todos tienen acceso a todos los documentos), pero si lo necesitas, Streamlit admite autenticación mediante streamlit-authenticator o puedes ponerlo detrás de un proxy OAuth como oauth2-proxy.
Horas 36-48: Pruebas, ajustes y despliegue
Metodología de pruebas
Preparé un conjunto de 50 preguntas cuyas respuestas conocía de antemano. Luego categoricé las respuestas de la base de conocimiento:
- Correctas y bien referenciadas: 38 de 50 (76%)
- Parcialmente correctas (idea correcta, faltan detalles): 7 de 50 (14%)
- Incorrectas: 2 de 50 (4%)
- Correctamente respondió "no lo sé": 3 de 50 (6%)
Un 76% de precisión en el primer intento fue suficiente para lanzarlo. Después de ajustar el tamaño de los fragmentos, el solapamiento y el prompt, llegué al 84%. Los errores restantes se debían principalmente a preguntas ambiguas o a documentos con información contradictoria (que es un problema de documentación, no de IA).
Despliegue
Lo desplegué en una VM pequeña en la nube (4 GB de RAM, 2 vCPUs — unos 20 $/mes). La configuración:
- Aplicación Streamlit ejecutándose detrás de un proxy inverso nginx
- SSL mediante Let's Encrypt
- Servicio systemd para reinicio automático
- ChromaDB almacenado en disco (sin necesidad de servidor de base de datos separado)
Desglose de costes
Esto es lo que cuesta mantener el sistema para un equipo de 35 personas:
| Concepto | Coste mensual |
|---|---|
| API de OpenAI — Embeddings (re-ingesta + consultas) | 23 $ |
| API de OpenAI — GPT-4o-mini (respuestas a consultas) | 85 $ |
| VM en la nube (4 GB RAM) | 20 $ |
| Dominio + SSL | 5 $ |
| Google Drive (almacenamiento de documentos compartidos) | 20 $ |
| Total | 153 $ |
Eso son unos 4,37 $ por persona al mes. Para comparar, las herramientas comerciales de bases de conocimiento con IA como Guru AI o Glean cuestan entre 15 y 25 $ por usuario al mes. Estamos ahorrando entre 370 y 720 $ al mes en comparación con las soluciones listas para usar, y además tenemos control total sobre nuestros datos.
Lecciones aprendidas (a base de golpes)
El tamaño de los fragmentos importa más que la elección del modelo. Pasé horas probando diferentes LLMs cuando el verdadero problema era mi estrategia de fragmentación. Fragmentos malos generan respuestas malas. Ningún modelo puede arreglar una recuperación deficiente.
Los metadatos no son opcionales. Cuando un usuario pregunta "¿cuál es nuestra política de devoluciones?" y la base de conocimiento devuelve la política de 2023 en lugar de la versión actualizada de 2026, eso es peor que no devolver nada. Los metadatos de fecha y la atribución de fuentes son imprescindibles.
La gente hace preguntas de formas que no imaginas. Supuse que la gente preguntaría cosas como "¿Cuál es el proceso de despliegue del servicio X?" En cambio, preguntaban "¿cómo subo código?" y "¿dónde está ejecutándose X?". La ingeniería de prompts para gestionar preguntas informales y vagas requirió más trabajo del que esperaba.
Empieza con 20 documentos, no con 2.000. Al principio intenté ingestar todo a la vez. Los costes de embeddings se dispararon, la calidad era inconsistente y depurar era un calvario. Empieza poco a poco, valida las respuestas y luego escala.
La respuesta "no lo sé" es una funcionalidad. Entrena el modelo (a través del prompt del sistema) para que diga "no tengo información sobre eso en la base de conocimiento" en lugar de inventarse algo. Los usuarios confían más en el sistema cuando es honesto sobre sus limitaciones.
Problemas frecuentes y soluciones
"Las respuestas son demasiado genéricas." Aumenta el número de fragmentos recuperados de 4 a 6-8, y asegúrate de que tus fragmentos son lo suficientemente grandes como para contener ideas completas. Comprueba también si la información relevante está realmente en tu conjunto de documentos.
"Sigue citando el documento equivocado." Puede que tus embeddings estén desactualizados. Vuelve a ejecutar el pipeline de ingesta. Comprueba también si tienes documentos duplicados o diferentes versiones del mismo documento en el sistema.
"Va lento." El principal cuello de botella suele ser la llamada al LLM, no la recuperación. Cambia a GPT-4o-mini si aún no lo has hecho (es más rápido y barato que GPT-4o con una calidad casi idéntica para tareas de preguntas y respuestas). Además, cachea las preguntas más frecuentes.
"Alucina." Baja la temperatura a 0.0-0.1 y añade instrucciones explícitas en el prompt: "Responde únicamente basándote en el contexto proporcionado. Si el contexto no contiene la respuesta, dilo." Esto reduce las alucinaciones de forma drástica.
¿Qué viene después?
Desde que desplegué esto hace tres semanas, nuestro equipo ha hecho más de 1.200 preguntas. Las consultas más populares son sobre procesos de despliegue, documentación de API y "quién tomó esta decisión y por qué" (por eso fue tan valioso ingestar los hilos de decisiones de Slack).
Lo siguiente que planeo añadir: memoria conversacional (para que las preguntas de seguimiento funcionen de forma natural), integración con Slack (hacer preguntas directamente desde un canal) y detección automática de documentos obsoletos (marcar documentos que llevan más de 6 meses sin actualizarse pero que siguen siendo citados).
Construir esto me llevó un fin de semana, pero el ahorro de tiempo se acumula cada día. Si tu equipo pasa más de 30 minutos a la semana buscando información, este sistema se amortiza casi de inmediato.