La incrustación densa es un método para convertir el significado de un texto en un vector numérico de modo que "si el significado es similar, el vector está cerca", y el índice BM25 es un método de puntuación basado en la frecuencia de aparición y la rareza de la palabra misma incluida en el texto. El primero puede encontrar “sinónimos u otras expresiones”, mientras que el segundo puede encontrar “documentos con exactamente esa palabra”.
Motivo de la selección:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
embeddings = model.encode(
texts,
batch_size=64,
normalize_embeddings=True, # L2 정규화 → 코사인 유사도 = 내적
)
Por qué la regularización L2 es clave:
@)scores = embeddings @ query_embedding.T # (9586,) 벡터, <100ms
Aquí, numpy es la biblioteca de cálculo numérico de Python que puede calcular rápidamente grandes conjuntos de números. Gracias a numpy, puedes calcular la similitud de 9586 vectores a la vez usando el operador @ anterior.La concordancia precisa de palabras clave es débil solo con una incrustación densa. Busqué "甲木" y apareció la palabra semánticamente similar "energía yang". En dominios donde la coincidencia precisa de términos es importante, BM25 es un complemento esencial.
# 한국어: Kiwi 형태소 분석기 (명사/동사/형용사만 추출)
def tokenize_ko(text: str) -> list[str]:
kiwi = Kiwi()
tokens = []
for token in kiwi.tokenize(text):
if token.tag.startswith(("NN", "VV", "VA", "SL", "SH", "SN")):
min_len = 1 if token.tag.startswith("NN") else 2
if len(token.form) >= min_len:
tokens.append(token.form)
return tokens
# 중국어: jieba 분절 (불용어 제거)
def tokenize_zh(text: str) -> list[str]:
return [w.strip() for w in jieba.cut(text)
if w.strip() and w not in _ZH_STOP]
# 영어: regex + stopwords (100개)
def tokenize_en(text: str) -> list[str]:
words = re.findall(r"[a-zA-Z]+", text.lower())
return [w for w in words if w not in _ENGLISH_STOP and len(w) > 2]
Razón para permitir sustantivos coreanos de una letra:
Los sustantivos de una letra como los Cinco Elementos (viernes/miércoles/jueves/martes/sábado) y Cheongan (Gap/Eul/Byeong/Jeong) son los términos clave del dominio. En PNL general, los sustantivos de una letra se eliminan como ruido, pero aquí solo se permiten sustantivos bajo la condición min_len = 1 if NN.
Mismo problema en los servicios de TI:
Incluso en el ámbito de TI, las abreviaturas cortas suelen ser términos clave: CI, CD, DB, VM, LB, DR, QA, etc. Si aplica el filtro len(w) > 2 en el tokenizador en inglés, todas estas abreviaturas de dos letras se eliminarán. Dependiendo de su dominio, es posible que deba ajustar el límite de longitud mínima del tokenizador o agregar abreviaturas importantes a la lista de excepciones de palabras vacías.
JSONL (JSON Lines) es un formato que almacena un objeto JSON por línea y se puede transmitir línea por línea sin cargar todo en la memoria, lo que lo hace adecuado para manejar grandes cantidades de datos fragmentados. Una matriz JSON normal ([{...}, {...}]) requiere analizar el archivo completo, pero JSONL es libre de leer solo líneas específicas o agregarlas al final.
data/
├── chunks.jsonl # 원본 청크 (chunk_id, source, page, lang, text)
├── chunks_meta.jsonl # 검색 결과 반환용 메타+텍스트
├── embeddings.npy # Dense 벡터 (9586 × 384, float32)
├── bm25_index.pkl # BM25Okapi 직렬화
├── tokenized_corpus.pkl # 사전 토큰화 코퍼스 (검색 시 재토큰화 방지)
└── .last_build_count # 마지막 빌드 시 청크 수 (변경 감지용)
Tiempo total de reconstrucción: aproximadamente de 3 a 5 minutos para 9586 fragmentos (M1 Mac local)
쿼리: "갑목 인월 용신 조후"
│
┌──────────┼──────────┐
▼ │ ▼
┌──────────┐ │ ┌──────────┐
│ BM25 │ │ │ Dense │
│ top-K │ │ │ top-K │
└────┬─────┘ │ └────┬─────┘
│ │ │
└─────┬─────┘─────────┘
│
합집합 (K=max(20, top_k×4), 기본 top_k=5 시 최대 40개)
│
▼
┌─────────────────────┐
│ Stage 2: Reranker │
│ bge-reranker-v2-m3 │
│ (Cross-Encoder) │
└──────────┬──────────┘
│
top-K 결과 반환
BM25 y la búsqueda densa son métodos fundamentalmente diferentes, y cuál es más efectivo dependerá de la naturaleza de la consulta.** Cuando se combinan en una proporción fija (por ejemplo, BM25 40 % + Dense 60 %), obtendrá resultados subóptimos para ciertos tipos de consultas.
Veamos el problema específicamente:
| tipo de consulta | Lo que hace bien el BM25 | Lo que Dense hace bien | El problema de los ratios fijos |
|---|---|---|---|
"窮通寶鑑 甲木 寅月" (palabra clave en caracteres chinos) |
Encuentre documentos con exactamente estos caracteres chinos | Importar interpretaciones coreanas semánticamente similares (ruido) | Si la proporción de densos es alta, las interpretaciones coreanas incorrectas tendrán una clasificación alta. |
"갑목 일간이 봄에 태어났을 때 보충해야 할 기운" (descriptivo) |
Resultados fragmentarios al hacer coincidir solo las palabras "Gapmok" y "Spring" | Comprenda el significado de la oración completa y encuentre una respuesta como "Cuando la energía de la garganta sea fuerte, contrólela con oro". | Si la proporción de BM25 es alta, los documentos que contengan elementos “negros” de baja relevancia tendrán una clasificación alta. |
"도화살" (jerga corta) |
Encuentra el documento con la palabra exacta "hacer flecha" | La incorporación de "Dohwasal" también coincide con significados generales como "amor" y "atracción". | Si te inclinas hacia un lado, pierdes precisión o variedad. |
Pesos adaptables para este proyecto:
def _adaptive_weights(query, lang):
hanja_ratio = (쿼리 내 한자 비율)
domain_term_count = (도메인 사전에 매칭된 용어 수)
if hanja_ratio > 0.3: # 한자 쿼리
return 0.6, 0.4 # BM25↑: 한자는 정확한 글자 매칭이 핵심
elif len(query) <= 8 and domain_term_count >= 1: # 짧은 전문용어
return 0.5, 0.5 # 균등: 양쪽 모두 필요
elif len(query) > 20 and domain_term_count == 0: # 긴 서술형
return 0.25, 0.75 # Dense↑: 의미 파악이 핵심
else:
return 0.4, 0.6 # 기본: Dense 약간 우세
Implicaciones de producción:
Los pesos adaptativos no sólo son necesarios para Saju Myeongrihak. Por ejemplo:
GET /api/v2/users → Mejoras de BM25, “Cómo obtener una lista de usuarios” → Mejoras densasAl analizar la naturaleza de la consulta en tiempo de ejecución y ajustar automáticamente el peso, es posible responder a una variedad de patrones de consulta.
La técnica más central de este proyecto. Dispositivos que permiten consultas coreanas para encontrar fuentes chinas:
# 143개 키(209쌍)의 한↔漢 매핑 테이블 (일부)
_KOREAN_TO_HANJA = {
"갑목": ["甲木"],
"용신": ["用神"],
"도화살": ["桃花煞", "桃花"],
"합충형파해": ["合沖刑破害", "合冲刑破害"], # 정체자+간체자 모두 포함
"인신충": ["寅申沖", "寅申冲"],
...
}
Proceso de procesamiento de consultas:
입력 쿼리: "갑목 인월 용신"
↓ Kiwi 토큰화
토큰: ["갑목", "인", "월", "용신"]
↓ 복합 도메인 용어 추출 (형태소 분석 우회)
추가: ["갑목", "용신"] # 이미 포함됨 확인
↓ 한→漢 확장
확장: ["갑목", "인", "월", "용신", "甲木", "寅", "用神"]
↓ BM25 검색 (확장된 토큰으로)
Mismo patrón en servicios TI:
Incluso en entornos de TI, los términos con el mismo significado se utilizan indistintamente en coreano/inglés/abreviatura. Sin tablas de extensión de consulta, no encontrará documentos escritos como "Kubernetes" o "K8s" cuando busque "Kubernetes":
_IT_TERM_EXPANSION = {
"쿠버네티스": ["Kubernetes", "K8s", "k8s"],
"도커": ["Docker", "docker"],
"로드밸런서": ["Load Balancer", "LB", "L4", "L7"],
"배포": ["deploy", "deployment", "릴리즈", "release"],
"장애": ["incident", "outage", "장애 대응", "postmortem"],
"인증": ["authentication", "auth", "OAuth", "SSO"],
"캐시": ["cache", "Redis", "Memcached", "CDN"],
"모니터링": ["monitoring", "Grafana", "Prometheus", "옵저버빌리티", "observability"],
...
}
En esta tabla, también puede encontrar documentos en inglés donde la consulta coreana "método de reversión de implementación" está escrita como "procedimiento de reversión de implementación". Crear un diccionario de dominio es un costo único, pero el efecto de mejorar la recuperación de la búsqueda es permanente.
Por qué necesitas una extracción compleja de términos de dominio:
Hay casos en los que los kiwis separan “Daeun” en “Dae” + “Un” o segmentan incorrectamente “Gungtongbogam”. Las palabras compuestas registradas en el diccionario se comparan y conservan primero antes del análisis morfológico:
def _extract_compound_terms(query: str) -> list[str]:
# 길이 내림차순 정렬 → 긴 용어 우선 매칭 (합충형파해 > 합충 > 합)
sorted_terms = sorted(_KOREAN_TO_HANJA.keys(), key=len, reverse=True)
found = []
for term in sorted_terms:
if len(term) >= 2 and term in query:
found.append(term)
return found
Si varios fragmentos de la misma fuente obtienen una clasificación alta, las respuestas estarán sesgadas. Atenuar en un 5% a partir del segundo trozo de la misma fuente:
# 같은 소스 2번째부터 5%씩 감쇠 (최대 15%)
penalty = min(seen * 0.05, 0.15)
adjusted = original_score * (1.0 - penalty)
Ejemplo: Incluso si 5 fragmentos de 梁湘润_명리전승반필기.pdf están entre los 10 primeros, la penalización por diversidad permite que otros fragmentos de origen entren y obtengan múltiples perspectivas.
Mismo problema en los servicios de TI:
Cuando busca "tiempo de espera de pago", si los 10 fragmentos principales del espacio Confluence del equipo de pagos dominan la parte superior, se pierden documentos desde otras perspectivas escritos por los equipos de infraestructura o control de calidad. Al aplicar la penalización por diversidad de fuentes, puede obtener resultados equilibrados, como 3 documentos del equipo de pagos + 2 documentos de respuesta a fallas del equipo de infraestructura + 1 escenario de prueba de control de calidad.