Главная / Статьи / Практические заметки: векторные базы данных для производственных систем RAG: индексация, гибридный поиск

Практические заметки: векторные базы данных для производственных систем RAG: индексация, гибридный поиск

Пошаговое руководство по практическим заметкам: векторные базы данных для производственных систем RAG: индексация, гибридный поиск: контракты, проверки и готовые блоки кода для команд, использующих эту модель.

7876 слов

В следующих заметках описывается практический подход к теме «Векторные базы данных для производственных систем RAG: индексация, гибридный поиск и масштабирование процесса получения данных». Основное внимание уделяется контрактам, проверкам и шаблонам кода, которые можно легко вставить, а не мотивирующему описанию. При работе над этапом обзора сначала запишите контракт: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой список поможет сохранять честность при последующих изменениях кода. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь код.

Векторная база данных — это алгоритм поиска

База данных векторов работает наилучшим образом, когда её рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объёма работ. Документируйте одновременно успешный сценарий работы и сценарий восстановления. Повторные попытки, проверки человеком и обработка неработающих сообщений являются частью продукта, а не этапом последующей доработки. Разделяйте политику разбиения данных на части и политику поиска. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества.

Поиск точного ближайшего соседа: базовый вариант, который нельзя использовать в масштабах

Этап поиска наиболее близкого соседа работает наилучшим образом, если рассматривать его как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объема работ. Предпочитайте небольшие, тестируемые единицы вместо обширных скриптов. Когда какой-либо шаг терпит неудачу, причина сбоя должна указывать на конкретную ответственность, а не на запутанную цепочку операций. Разделяйте политику разбиения данных на части и политику поиска. Изменение одной из них не должно вынуждать переписывать другую при изменении показателей качества.

import faiss
import numpy as np
from typing import Tuple


def build_exact_index(
    embeddings: np.ndarray,
    use_cosine: bool = True
) -> faiss.IndexFlatIP:
    """
    Build a FAISS flat index for exact nearest neighbour search.

    embeddings: (N, D) float32 array.
    use_cosine: If True, normalises a copy of the embeddings and uses inner
                product (equivalent to cosine similarity). The caller's array
                is not mutated.

    Returns a FAISS flat index. Benchmark latency against your corpus and
    latency SLO before deciding whether ANN indexing is necessary.
    """
    dimension = embeddings.shape[1]

    if use_cosine:
        # Copy before normalising to avoid mutating the caller's array.
        embeddings_copy = embeddings.astype(np.float32).copy()
        faiss.normalize_L2(embeddings_copy)
        index = faiss.IndexFlatIP(dimension)
        index.add(embeddings_copy)
        return index
    else:
        index = faiss.IndexFlatL2(dimension)
        index.add(embeddings.astype(np.float32).copy())
        return index


def search_exact(
    index: faiss.IndexFlatIP,
    query_vector: np.ndarray,
    top_k: int = 10
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Search the flat index. Returns (distances, indices).
    query_vector must already be normalised if the index was built with
    normalised embeddings.
    """
    query = query_vector.reshape(1, -1).astype(np.float32)
    faiss.normalize_L2(query)
    distances, indices = index.search(query, top_k)
    return distances[0], indices[0]

HNSW: Почему он так популярен в производственных системах поиска векторов

Этап HNSW Why It Is работает наилучшим образом, когда рассматривается как измеримая поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Дайте названия всем элементам, определите критерии успеха и не допускайте молчаливого частичного выполнения задачи. Разделяйте политику разбиения данных на части и политику поиска. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества. Этап HNSW Why It Is работает наилучшим образом, когда рассматривается как измеримая поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь граф.

Как строится граф

На этапе «Как формируется граф» необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Необходимо одновременно задокументировать успешный сценарий работы и сценарий восстановления. Повторные попытки, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не этапом последующей доработки. Указывайте те фрагменты текста, которые легли в основу ответа. Без цитат операторы не смогут отличить галлюцинации от пробелов в индексации.

Параметры, определяющие баланс между точностью воспроизведения и задержкой

Для параметров, определяющих этап, необходимо заранее указать входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Лучше использовать небольшие, тестируемые единицы кода вместо обширных скриптов. При сбое шага причина должна быть связана с конкретной областью ответственности, а не с запутанной структурой обработки данных. Указывайте те части текста, на которых основан ответ. Без цитат операторы не смогут отличить вымысел от проблем с индексацией.

import faiss
import numpy as np
from typing import Tuple


def build_hnsw_index(
    embeddings: np.ndarray,
    m: int = 32,
    ef_construction: int = 200,
    ef_search: int = 100,
    use_cosine: bool = True
) -> faiss.IndexHNSWFlat:
    """
    Build a FAISS HNSW index for approximate nearest neighbour search.

    m: Graph connectivity parameter. Higher = better recall potential, more memory.
       Starting range for banking policy corpora: 16 to 32. Benchmark your corpus.
    ef_construction: Candidates explored during index build. Higher = better graph quality.
       One-time cost at index build; does not affect query latency.
    ef_search: Candidates explored at query time. Controls recall-latency trade-off.
       Can be changed without rebuilding. Starting range: 50 to 200.
    use_cosine: Normalise embeddings and use inner product (cosine similarity).

    Note: FAISS HNSW does not support GPU acceleration. For GPU-accelerated ANN,
    use IndexIVFPQ variants.
    """
    dimension = embeddings.shape[1]

    # Copy before normalising to avoid mutating the caller's array.
    embeddings_to_index = embeddings.astype(np.float32).copy()

    if use_cosine:
        faiss.normalize_L2(embeddings_to_index)
        index = faiss.IndexHNSWFlat(dimension, m, faiss.METRIC_INNER_PRODUCT)
    else:
        index = faiss.IndexHNSWFlat(dimension, m, faiss.METRIC_L2)

    index.hnsw.efConstruction = ef_construction
    index.hnsw.efSearch = ef_search
    index.add(embeddings_to_index)
    return index


def search_hnsw(
    index: faiss.IndexHNSWFlat,
    query_vector: np.ndarray,
    top_k: int = 10
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Search the HNSW index. Returns (scores, indices).
    query_vector must be normalised if the index was built with normalised embeddings.
    """
    query = query_vector.reshape(1, -1).astype(np.float32)
    faiss.normalize_L2(query)
    scores, indices = index.search(query, top_k)
    return scores[0], indices[0]

Требования к памяти HNSW

На этапе определения требований к памяти HNSW необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед внесением изменений в код. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Укажите названия создаваемых файлов, определите критерии успеха и не допускайте молчаливого частичного выполнения задачи. Цитируйте те участки текста, которые фактически легли в основу ответа. Без цитат операторы не смогут отличить галлюцинации от пробелов в индексации. На этапе определения требований к памяти HNSW необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед внесением изменений в код. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

IVF: Обратный индексирование файлов для сред с ограниченными ресурсами памяти

При работе над этапом обратного индексирования файлов IVF сначала запишите условия работы: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Документируйте как успешный, так и восстановительный пути работы. Повторные попытки, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не элементами последующей доработки. Измеряйте точность восстановления информации на фиксированном наборе вопросов перед настройкой подсказок. Частая смена подсказок редко помогает улучшить качество поиска.

import faiss
import numpy as np
from typing import Tuple


def build_ivf_index(
    embeddings: np.ndarray,
    nlist: int = 1024,
    nprobe: int = 64,
    use_cosine: bool = True
) -> faiss.IndexIVFFlat:
    """
    Build a FAISS IVF flat index.

    nlist: Number of Voronoi cells. A common starting heuristic is sqrt(N),
           where N is corpus size. For 100K vectors: 300-1000. For 1M: 1024-4096.
           Validate empirically.
    nprobe: Number of cells searched at query time. Higher = better recall, slower.
            Set based on your recall benchmark results.
    use_cosine: Use inner product on normalised vectors.

    Requires training on a representative sample before adding vectors.
    """
    dimension = embeddings.shape[1]
    embeddings_to_index = embeddings.astype(np.float32).copy()

    if use_cosine:
        faiss.normalize_L2(embeddings_to_index)
        quantiser = faiss.IndexFlatIP(dimension)
        index = faiss.IndexIVFFlat(quantiser, dimension, nlist, faiss.METRIC_INNER_PRODUCT)
    else:
        quantiser = faiss.IndexFlatL2(dimension)
        index = faiss.IndexIVFFlat(quantiser, dimension, nlist)

    # Use a random representative sample for training. Using the first N records
    # risks training on a non-representative slice if the corpus is ordered by
    # date, jurisdiction, or document type.
    n_available = len(embeddings_to_index)
    desired_training_size = min(n_available, 40 * nlist)
    rng = np.random.default_rng(seed=42)
    training_indices = rng.choice(n_available, size=desired_training_size, replace=False)
    training_sample = embeddings_to_index[training_indices]
    index.train(training_sample)

    index.nprobe = nprobe
    index.add(embeddings_to_index)
    return index

IVF с квантизацией продукта

При работе над этапом квантизации продукта в рамках технологии ИВФ сначала запишите условия контракта: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Предпочитайте небольшие, тестируемые модули большим скриптам. Если какой-то шаг не сработает, причина должна быть связана с конкретной областью ответственности, а не с запутанной цепочкой операций. Оцените точность восстановления информации на фиксированном наборе вопросов перед настройкой подсказок. Частая замена подсказок редко помогает улучшить качество поиска.

import faiss
import numpy as np
from typing import Tuple


def build_ivfpq_index(
    embeddings: np.ndarray,
    nlist: int = 1024,
    m_subvectors: int = 8,
    bits_per_code: int = 8,
    nprobe: int = 64
) -> Tuple[faiss.IndexIVFPQ, faiss.IndexFlatIP]:
    """
    Build a FAISS IVF-PQ index paired with a flat index for exact re-scoring.

    m_subvectors: Number of sub-vectors. Must divide dimension evenly.
                  For 1536 dimensions: m=8 (192 dims each), m=16 (96 dims each).
                  Select based on the storage-recall trade-off for your corpus.
    bits_per_code: Bits per sub-vector code. 8 bits = 256 centroids per sub-vector.
                   Lower bits = smaller code, larger recall degradation.

    Returns (pq_index, flat_index).
    Use pq_index to retrieve top-N candidates cheaply; use flat_index to re-score
    those candidates with full float32 precision.
    """
    dimension = embeddings.shape[1]
    assert dimension % m_subvectors == 0, (
        f"Dimension {dimension} must be divisible by m_subvectors {m_subvectors}"
    )

    norm_embeddings = embeddings.astype(np.float32).copy()
    faiss.normalize_L2(norm_embeddings)

    # Compressed IVF-PQ index for broad retrieval
    quantiser = faiss.IndexFlatIP(dimension)
    pq_index = faiss.IndexIVFPQ(
        quantiser, dimension, nlist, m_subvectors, bits_per_code,
        faiss.METRIC_INNER_PRODUCT
    )
    training_size = min(len(norm_embeddings), 50 * nlist)
    rng = np.random.default_rng(seed=42)
    training_indices = rng.choice(len(norm_embeddings), size=training_size, replace=False)
    pq_index.train(norm_embeddings[training_indices])
    pq_index.nprobe = nprobe
    pq_index.add(norm_embeddings)

    # Flat index for exact re-scoring of PQ candidates
    flat_index = faiss.IndexFlatIP(dimension)
    flat_index.add(norm_embeddings)

    return pq_index, flat_index


def two_stage_search(
    pq_index: faiss.IndexIVFPQ,
    flat_index: faiss.IndexFlatIP,
    query_vector: np.ndarray,
    top_k: int = 10,
    candidate_multiplier: int = 10
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Two-stage retrieval: broad PQ candidate recall followed by exact flat re-scoring.

    Stage 1: IVF-PQ retrieves top_k * candidate_multiplier candidates cheaply.
    Stage 2: The flat index re-scores those candidates with full float32 precision.

    The flat index must have been built with the same normalised embeddings added
    in the same corpus order so that IVF-PQ indices align to flat index positions.

    candidate_multiplier: Higher values improve recall at higher latency cost.
    """
    query = query_vector.reshape(1, -1).astype(np.float32)
    faiss.normalize_L2(query)

    n_candidates = top_k * candidate_multiplier
    _, candidate_indices = pq_index.search(query, n_candidates)

    valid_mask = candidate_indices[0] >= 0
    valid_candidates = candidate_indices[0][valid_mask]

    if len(valid_candidates) == 0:
        return np.array([]), np.array([])

    # Reconstruct candidate vectors from the flat index and score them exactly.
    candidate_vectors = np.zeros(
        (len(valid_candidates), flat_index.d), dtype=np.float32
    )
    for i, idx in enumerate(valid_candidates):
        flat_index.reconstruct(int(idx), candidate_vectors[i])

    exact_scores = (candidate_vectors @ query.T).flatten()
    reranked_order = np.argsort(exact_scores)[::-1][:top_k]

    final_indices = valid_candidates[reranked_order]
    final_scores = exact_scores[reranked_order]
    return final_scores, final_indices

Сравнение векторных баз данных на 2026 год

При работе над этапом сравнения векторных баз данных сначала запишите условия соглашения: необходимые входные данные, сигнал о успешном выполнении и что происходит при частичной неудаче. Такой список помогает сохранять честность при последующих изменениях кода. Рассматривайте этот этап как соглашение между входными данными и проверенными выходными результатами. Дайте названия создаваемым элементам, определите критерии успеха и не допускайте безупречного завершения работы при частичных ошибках. Измеряйте показатель воспроизводимости на фиксированном наборе вопросов перед настройкой подсказок. Частая смена подсказок редко помогает улучшить качество поиска. При работе над этапом сравнения векторных баз данных сначала запишите условия соглашения: необходимые входные данные, сигнал о успешном выполнении и что происходит при частичной неудаче. Такой список помогает сохранять честность при последующих изменениях кода. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

FAISS

Этап FAISS работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объёма работ. Документируйте одновременно успешный и восстановительный пути работы. Повторные попытки, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не этапом последующей доработки. Разделяйте политику разбиения данных на части и политику поиска. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества.

pgvector

Этап pgvector работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объёма работ. Предпочитайте небольшие, тестируемые единицы кода вместо обширных скриптов. Когда какой-то шаг сбивается, причина сбоя должна указывать на конкретную ответственность, а не на запутанную цепочку операций. Разделяйте политику разбиения данных на части и политику их извлечения. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества.

-- Enable the pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;

-- Policy chunk table with vector and structured metadata
CREATE TABLE policy_chunks (
    chunk_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id       TEXT NOT NULL,
    document_version  TEXT NOT NULL,
    policy_id         TEXT,
    jurisdiction      TEXT,
    effective_date    DATE,
    section           TEXT,
    content_type      TEXT NOT NULL,
    chunk_text        TEXT NOT NULL,
    classification    TEXT NOT NULL DEFAULT 'INTERNAL',
    permitted_roles   TEXT[] NOT NULL DEFAULT '{}',
    embedding_model   TEXT NOT NULL,
    embedding         vector(1536),
    indexed_at        TIMESTAMPTZ DEFAULT NOW()
);

-- HNSW index for cosine similarity retrieval
CREATE INDEX ON policy_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 200);

-- Partial index for jurisdiction-scoped retrieval (common query pattern)
CREATE INDEX ON policy_chunks
    USING hnsw (embedding vector_cosine_ops)
    WHERE jurisdiction = 'EU';

-- Standard indexes for metadata filter columns
CREATE INDEX ON policy_chunks (policy_id);
CREATE INDEX ON policy_chunks (jurisdiction);
CREATE INDEX ON policy_chunks (classification);
CREATE INDEX ON policy_chunks (effective_date);
import psycopg2
import numpy as np
from typing import List, Dict, Optional


def search_policy_chunks(
    query_embedding: List[float],
    jurisdiction: Optional[str] = None,
    classification_ceiling: str = "INTERNAL",
    permitted_role: Optional[str] = None,
    top_k: int = 10,
    ef_search: int = 100,
    connection_string: str = "postgresql://user:password@localhost:5432/rag_db"
) -> List[Dict]:
    """
    Retrieve policy chunks from pgvector with jurisdiction and access filtering.

    ef_search: Controls the HNSW recall-latency trade-off for this session.
               Set per-session; does not require index rebuild.
    """
    conn = psycopg2.connect(connection_string)
    cur = conn.cursor()

    cur.execute(f"SET hnsw.ef_search = {ef_search};")

    classification_levels = {"PUBLIC": 0, "INTERNAL": 1, "CONFIDENTIAL": 2}
    max_level = classification_levels.get(classification_ceiling, 1)
    permitted_classifications = [
        k for k, v in classification_levels.items() if v <= max_level
    ]

    filters = ["classification = ANY(%s)"]
    params: List = [permitted_classifications]

    if jurisdiction:
        filters.append("jurisdiction = %s")
        params.append(jurisdiction)

    if permitted_role:
        filters.append("%s = ANY(permitted_roles) OR cardinality(permitted_roles) = 0")
        params.append(permitted_role)

    where_clause = " AND ".join(filters)
    embedding_str = "[" + ",".join(str(x) for x in query_embedding) + "]"

    query = f"""
        SELECT
            chunk_id,
            document_id,
            document_version,
            policy_id,
            jurisdiction,
            effective_date,
            section,
            content_type,
            chunk_text,
            1 - (embedding <=> %s::vector) AS cosine_similarity
        FROM policy_chunks
        WHERE {where_clause}
        ORDER BY embedding <=> %s::vector
        LIMIT %s;
    """

    params_with_embedding = [embedding_str] + params + [embedding_str, top_k]
    cur.execute(query, params_with_embedding)
    rows = cur.fetchall()

    columns = [
        "chunk_id", "document_id", "document_version", "policy_id",
        "jurisdiction", "effective_date", "section", "content_type",
        "chunk_text", "cosine_similarity"
    ]
    results = [dict(zip(columns, row)) for row in rows]

    cur.close()
    conn.close()
    return results

Qdrant

Этап Qdrant работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Дайте названия соответствующим элементам, определите критерии успешного выполнения и не допускайте молчаливого частичного завершения задачи. Разделяйте политику разбиения данных на части и политику их извлечения. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества. Этап Qdrant работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь код.

from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams, Distance, HnswConfigDiff,
    PointStruct, Filter, FieldCondition, MatchValue, MatchAny,
    SparseVectorParams, SparseIndexParams, SparseVector
)
from typing import List, Dict, Optional

client = QdrantClient(host="localhost", port=6333)

COLLECTION_NAME = "banking_policy"
DENSE_VECTOR_NAME = "dense"
SPARSE_VECTOR_NAME = "sparse"


def create_policy_collection(
    dimension: int = 1536,
    m: int = 16,
    ef_construction: int = 200
) -> None:
    """
    Create a Qdrant collection configured for both dense and sparse vectors.
    """
    client.recreate_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={
            DENSE_VECTOR_NAME: VectorParams(
                size=dimension,
                distance=Distance.COSINE,
                hnsw_config=HnswConfigDiff(
                    m=m,
                    ef_construct=ef_construction,
                    full_scan_threshold=10000
                )
            )
        },
        sparse_vectors_config={
            SPARSE_VECTOR_NAME: SparseVectorParams(
                index=SparseIndexParams(on_disk=False)
            )
        }
    )


def upsert_policy_chunks(chunks: List[Dict]) -> None:
    """
    Index policy chunks with dense vectors, sparse vectors, and metadata payloads.

    Each chunk dict must contain:
        chunk_id, dense_vector, sparse_indices, sparse_values,
        chunk_text, document_id, document_version, policy_id,
        jurisdiction, effective_date, content_type, classification,
        permitted_roles
    """
    points = [
        PointStruct(
            id=chunk["chunk_id"],
            vector={
                DENSE_VECTOR_NAME: chunk["dense_vector"],
                SPARSE_VECTOR_NAME: SparseVector(
                    indices=chunk["sparse_indices"],
                    values=chunk["sparse_values"]
                )
            },
            payload={
                "chunk_text": chunk["chunk_text"],
                "document_id": chunk["document_id"],
                "document_version": chunk["document_version"],
                "policy_id": chunk.get("policy_id"),
                "jurisdiction": chunk.get("jurisdiction"),
                "effective_date": chunk.get("effective_date"),
                "content_type": chunk["content_type"],
                "classification": chunk["classification"],
                "permitted_roles": chunk.get("permitted_roles", []),
                "status": "active"
            }
        )
        for chunk in chunks
    ]
    client.upsert(collection_name=COLLECTION_NAME, points=points)


def search_dense_filtered(
    dense_query: List[float],
    jurisdiction: Optional[str] = None,
    permitted_classifications: List[str] = None,
    top_k: int = 10,
    score_threshold: float = 0.3
) -> List[Dict]:
    """
    Dense vector search with integrated payload filtering.
    Filtering is applied inside the HNSW graph traversal, not as a post-filter.
    """
    if permitted_classifications is None:
        permitted_classifications = ["PUBLIC", "INTERNAL"]

    must_conditions = [
        FieldCondition(
            key="classification",
            match=MatchAny(any=permitted_classifications)
        ),
        FieldCondition(key="status", match=MatchValue(value="active"))
    ]

    if jurisdiction:
        must_conditions.append(
            FieldCondition(key="jurisdiction", match=MatchValue(value=jurisdiction))
        )

    search_filter = Filter(must=must_conditions)

    results = client.search(
        collection_name=COLLECTION_NAME,
        query_vector=(DENSE_VECTOR_NAME, dense_query),
        query_filter=search_filter,
        limit=top_k,
        score_threshold=score_threshold,
        with_payload=True
    )

    return [
        {"chunk_id": hit.id, "score": hit.score, **hit.payload}
        for hit in results
    ]


def search_hybrid_qdrant(
    dense_query: List[float],
    sparse_query_indices: List[int],
    sparse_query_values: List[float],
    jurisdiction: Optional[str] = None,
    permitted_classifications: List[str] = None,
    top_k: int = 10
) -> List[Dict]:
    """
    Hybrid search using both dense and sparse vectors with access-control filtering.

    This uses Qdrant's native prefetch-and-fuse API. Both the dense and sparse
    signals contribute to retrieval. The Fusion.RRF strategy applies Reciprocal
    Rank Fusion internally.
    """
    from qdrant_client.models import Prefetch, FusionQuery, Fusion

    if permitted_classifications is None:
        permitted_classifications = ["PUBLIC", "INTERNAL"]

    must_conditions = [
        FieldCondition(
            key="classification",
            match=MatchAny(any=permitted_classifications)
        ),
        FieldCondition(key="status", match=MatchValue(value="active"))
    ]
    if jurisdiction:
        must_conditions.append(
            FieldCondition(key="jurisdiction", match=MatchValue(value=jurisdiction))
        )
    search_filter = Filter(must=must_conditions)

    results = client.query_points(
        collection_name=COLLECTION_NAME,
        prefetch=[
            Prefetch(
                query=dense_query,
                using=DENSE_VECTOR_NAME,
                limit=top_k * 5,
                filter=search_filter
            ),
            Prefetch(
                query=SparseVector(
                    indices=sparse_query_indices,
                    values=sparse_query_values
                ),
                using=SPARSE_VECTOR_NAME,
                limit=top_k * 5,
                filter=search_filter
            ),
        ],
        query=FusionQuery(fusion=Fusion.RRF),
        limit=top_k,
        with_payload=True
    )

    return [
        {"chunk_id": hit.id, "score": hit.score, **hit.payload}
        for hit in results.points
    ]

Weaviate

Для этапа Weaviate необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Необходимо одновременно задокументировать успешный сценарий выполнения и сценарий восстановления. Повторные попытки, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не элементами последующей доработки. Указывайте те фрагменты текста, которые легли в основу ответа. Без цитат операторы не смогут отличить галлюцинации от проблем с индексацией.

Milvus

Для этапа Milvus необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не пытаясь угадать скрытое состояние. Лучше использовать небольшие, тестируемые модули вместо обширных скриптов. При сбое шага причина должна быть связана с конкретной функцией, а не с запутанной структурой обработки данных. Указывайте те части текста, на которых основан ответ. Без цитат операторы не смогут отличить вымысел от проблем с индексацией.

ChromaDB

Для этапа ChromaDB необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Укажите названия файлов, определите критерии успеха и не допускайте молчаливого частичного завершения работы. Цитируйте те фрагменты, которые фактически легли в основу ответа. Без цитат операторы не смогут отличить галлюцинации от пробелов в индексации. Для этапа ChromaDB необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

Pinecone

При работе над этапом Pinecone сначала запишите условия работы: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Документируйте одновременно успешный и восстановительный сценарии работы. Повторные попытки, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не элементами последующей доработки. Измеряйте точность восстановления информации на фиксированном наборе вопросов перед настройкой подсказок; частая смена подсказок редко помогает улучшить качество поиска.

Гибридный поиск: сочетание плотного и разреженного поиска

При работе над этапом объединения гибридных поисков с высокой плотностью результатов сначала запишите спецификацию: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Предпочитайте небольшие, тестируемые модули большим скриптам. Когда какой-то шаг терпит неудачу, она должна указывать на конкретную ответственность, а не на запутанную цепочку операций. Измеряйте показатель воспроизводимости на фиксированном наборе вопросов перед настройкой подсказок. Частая смена подсказок редко помогает улучшить качество поиска.

Слияние рангов с учетом взвешенной обратной связи

При работе над этапом слияния взвешенных обратных рангов сначала запишите контракт: необходимые входные данные, сигнал успешного выполнения и действия при частичной неудаче. Такой список помогает сохранять честность последующих изменений в коде. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Дайте названия элементам, определите критерии успешности и не допускайте безупречного завершения работы при частичных ошибках. Измеряйте показатель воспроизводимости на фиксированном наборе вопросов перед настройкой подсказок. Частая замена подсказок редко помогает улучшить качество поиска. При работе над этапом слияния взвешенных обратных рангов сначала запишите контракт: необходимые входные данные, сигнал успешного выполнения и действия при частичной неудаче. Такой список помогает сохранять честность последующих изменений в коде. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

from typing import List, Dict, Tuple
from collections import defaultdict


def weighted_reciprocal_rank_fusion(
    dense_results: List[Tuple[str, float]],
    sparse_results: List[Tuple[str, float]],
    k: int = 60,
    dense_weight: float = 0.6,
    sparse_weight: float = 0.4
) -> List[Tuple[str, float]]:
    """
    Fuse dense vector search results with sparse BM25 results using weighted RRF.

    dense_results: List of (chunk_id, dense_score) sorted by dense score descending.
    sparse_results: List of (chunk_id, sparse_score) sorted by sparse score descending.
    k: RRF constant. Higher k reduces the influence of top-ranked documents.
       Conventional default: 60.
    dense_weight / sparse_weight: Relative weights. Tune against your evaluation set.
       For corpora with high-precision identifier queries, increase sparse_weight.

    Returns fused list of (chunk_id, rrf_score) sorted by rrf_score descending.
    """
    rrf_scores: Dict[str, float] = defaultdict(float)

    for rank, (chunk_id, _) in enumerate(dense_results, start=1):
        rrf_scores[chunk_id] += dense_weight * (1.0 / (k + rank))

    for rank, (chunk_id, _) in enumerate(sparse_results, start=1):
        rrf_scores[chunk_id] += sparse_weight * (1.0 / (k + rank))

    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)

Настройка плотности-рассеянности весов

Этап настройки плотности-рассеянности весов работает наилучшим образом, если рассматривать его как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объема работ. Задокументируйте одновременно успешный сценарий работы и сценарий восстановления. Повторные попытки, проверка человеком и обработка неработоспособных сообщений являются частью продукта, а не этапом последующей доработки. Разделяйте политику разбиения на части и политику поиска. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества.

Фильтрация метаданных: объем поиска и границы авторизации

Этап фильтрации метаданных и определения объема поиска работает наилучшим образом, если рассматривать его как измеримую структуру. Соберите один идеальный пример обработки данных, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Предпочитайте небольшие, тестируемые единицы кода вместо обширных скриптов. При сбое какого-либо шага причина должна быть связана с конкретной функцией, а не с запутанной цепочкой операций. Разделяйте политику разбиения данных на части и политику поиска. Изменение одной из них не должно вынуждать переписывать другую при изменении показателей качества.

from typing import List, Dict, Optional
from enum import Enum


class ClassificationLevel(Enum):
    PUBLIC = 0
    INTERNAL = 1
    CONFIDENTIAL = 2


def build_access_filter(
    user_classification_ceiling: str,
    user_jurisdiction: Optional[str] = None,
    user_roles: Optional[List[str]] = None
) -> Dict:
    """
    Build a Qdrant-compatible filter dict enforcing access control rules.

    user_classification_ceiling: Highest classification the user can see.
    user_jurisdiction: If set, restrict to chunks applicable to that jurisdiction.
    user_roles: If set, restrict to chunks permitted for those roles.

    Integrate with your identity provider at request time, not at index time.
    This filter represents one layer of the authorisation model; it does not
    replace identity verification, audit logging, tenant isolation, or
    downstream response controls.
    """
    ceiling = ClassificationLevel[user_classification_ceiling].value
    permitted = [
        level.name
        for level in ClassificationLevel
        if level.value <= ceiling
    ]

    must_conditions = [
        {"key": "classification", "match": {"any": permitted}},
        {"key": "status", "match": {"value": "active"}}
    ]

    if user_jurisdiction:
        must_conditions.append(
            {"key": "jurisdiction", "match": {"value": user_jurisdiction}}
        )

    if user_roles:
        # Chunks with empty permitted_roles are accessible to all roles.
        must_conditions.append({
            "should": [
                {"key": "permitted_roles", "match": {"any": user_roles}},
                {"is_empty": {"key": "permitted_roles"}}
            ]
        })

    return {"must": must_conditions}

Инкрементное индексирование: добавление новых документов без полной перестройки

Этап постепенной индексации с добавлением новых элементов работает наилучшим образом, если рассматриваться как измеримая сфера. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Рассматривайте этот этап как контракт между входными данными и проверенными результатами. Дайте названия создаваемым элементам, определите критерии успеха и не допускайте молчаливого частичного выполнения задач. Разделяйте политику разбиения данных на части и политику их извлечения. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества. Этап постепенной индексации с добавлением новых элементов работает наилучшим образом, если рассматриваться как измеримая сфера. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь код.

import logging
from typing import List, Dict
from datetime import datetime

logger = logging.getLogger(__name__)


class IncrementalIndexManager:
    """
    Manages incremental updates to a Qdrant collection using soft deletion.

    Production pattern:
    1. New chunks are inserted immediately with status='active'.
    2. Superseded chunks are marked status='deleted' (soft delete).
    3. Retrieval filters exclude deleted chunks without graph rebuild.
    4. Full rebuild is triggered on schedule or when deleted fraction exceeds threshold.
    """

    def __init__(self, qdrant_client, collection_name: str):
        self.client = qdrant_client
        self.collection = collection_name
        self.deleted_threshold = 0.15  # Rebuild when 15% of index is soft-deleted

    def upsert_policy_version(
        self,
        new_chunks: List[Dict],
        superseded_chunk_ids: List[str],
        policy_id: str,
        new_version: str
    ) -> Dict:
        """
        Insert new policy version chunks and soft-delete superseded ones.
        """
        if superseded_chunk_ids:
            self.client.set_payload(
                collection_name=self.collection,
                payload={
                    "status": "deleted",
                    "deleted_at": datetime.now().isoformat(),
                    "superseded_by_version": new_version
                },
                points=superseded_chunk_ids
            )
            logger.info(
                f"Soft-deleted {len(superseded_chunk_ids)} chunks "
                f"from policy {policy_id}, superseded by version {new_version}"
            )

        from qdrant_client.models import PointStruct
        points = [
            PointStruct(
                id=chunk["chunk_id"],
                vector={"dense": chunk["dense_vector"]},
                payload={
                    **{k: v for k, v in chunk.items()
                       if k not in ("chunk_id", "dense_vector")},
                    "status": "active",
                    "indexed_at": datetime.now().isoformat()
                }
            )
            for chunk in new_chunks
        ]

        self.client.upsert(collection_name=self.collection, points=points)
        logger.info(
            f"Inserted {len(new_chunks)} chunks for policy {policy_id} version {new_version}"
        )

        return {
            "inserted": len(new_chunks),
            "soft_deleted": len(superseded_chunk_ids),
            "policy_id": policy_id,
            "new_version": new_version
        }

    def should_rebuild(self) -> bool:
        """Check whether the fraction of soft-deleted vectors justifies a full rebuild."""
        from qdrant_client.models import Filter, FieldCondition, MatchValue

        total = self.client.get_collection(self.collection).vectors_count
        deleted_filter = Filter(must=[
            FieldCondition(key="status", match=MatchValue(value="deleted"))
        ])
        deleted_count = self.client.count(
            collection_name=self.collection,
            count_filter=deleted_filter
        ).count

        fraction = deleted_count / total if total > 0 else 0
        logger.info(
            f"Index health: {deleted_count}/{total} soft-deleted ({fraction:.1%})"
        )
        return fraction >= self.deleted_threshold

Внедрение изменений версий модели

На этапе изменений версии модели встраивания необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед внесением изменений в код. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Необходимо одновременно задокументировать успешный сценарий выполнения и сценарий восстановления. Повторные попытки, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не этапом последующей доработки. При следующем шаге, представляющем собой код или вызов инструмента, следует отдавать предпочтение структурированным выводам с проверкой по схеме перед свободным текстовым описанием.

Шардинг и репликация: масштабирование за пределы одного узла

На этапе шардинга и масштабирования через репликацию необходимо заранее определить входные данные, ответственного за выполнение шага и критерии завершения перед внесением изменений в код. Операторы должны иметь возможность перезапустить шаг, исходя из известной точки контроля, без необходимости угадывать скрытое состояние системы. Лучше использовать небольшие, тестируемые единицы кода вместо обширных скриптов. При сбое шага причина должна быть связана с конкретной областью ответственности, а не с запутанной структурой обработки данных. Обязательно приводите те участки текста, на которых основан ответ; без цитат операторы не смогут отличить вымысел от проблем с индексацией.

Стратегии шардинга

На этапе стратегий шардинга необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед внесением изменений в код. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Укажите названия файлов, определите критерии успеха и не допускайте молчаливого частичного завершения работы. Цитируйте те фрагменты, которые фактически легли в основу ответа. Без цитат операторы не смогут отличить галлюцинации от пробелов в индексации. На этапе стратегий шардинга необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед внесением изменений в код. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь кодовый граф.

Планирование мощности

На этапе планирования мощности сначала запишите условия контракта: необходимые входные данные, сигналы успешного выполнения и действия при частичной неудаче. Такой список помогает сохранять честность при последующих изменениях кода. Задокументируйте одновременно успешный и восстановительный сценарии работы. Повторные попытки, проверки со стороны человека и обработка неработоспособных сообщений являются частью продукта, а не элементами последующей доработки. Перед настройкой вопросов измерьте точность поиска на фиксированном наборе вопросов. Частая смена формулировок вопросов редко помогает улучшить качество поиска.

from dataclasses import dataclass


@dataclass
class VectorIndexCapacityPlan:
    """
    Illustrative capacity model for HNSW vector indexes.
    All figures are approximations for planning purposes.
    Benchmark against your actual implementation and workload.
    """
    n_vectors: int
    dimension: int
    hnsw_m: int = 16
    replication_factor: int = 2
    avg_payload_bytes: int = 2048
    memory_headroom_factor: float = 1.5

    def vector_storage_gb(self) -> float:
        return (self.n_vectors * self.dimension * 4) / (1024 ** 3)

    def hnsw_graph_gb(self) -> float:
        # Approximate; actual graph overhead varies by implementation and configuration.
        return (self.n_vectors * self.hnsw_m * 2 * 8) / (1024 ** 3)

    def payload_storage_gb(self) -> float:
        return (self.n_vectors * self.avg_payload_bytes) / (1024 ** 3)

    def total_index_gb(self) -> float:
        return self.vector_storage_gb() + self.hnsw_graph_gb() + self.payload_storage_gb()

    def memory_per_replica_gb(self) -> float:
        return self.total_index_gb() * self.memory_headroom_factor

    def total_cluster_memory_gb(self) -> float:
        # Each replica holds a full copy of the index.
        return self.memory_per_replica_gb() * self.replication_factor

    def report(self) -> str:
        return (
            f"Illustrative capacity model — {self.n_vectors:,} vectors at {self.dimension}d:\n"
            f"  Vector storage (approx):         {self.vector_storage_gb():.2f} GB\n"
            f"  HNSW graph estimate:             {self.hnsw_graph_gb():.2f} GB\n"
            f"  Payload storage (approx):        {self.payload_storage_gb():.2f} GB\n"
            f"  Index footprint before overhead: {self.total_index_gb():.2f} GB\n"
            f"  Per-replica memory + headroom:   {self.memory_per_replica_gb():.2f} GB\n"
            f"  Total cluster memory (approx):   {self.total_cluster_memory_gb():.2f} GB\n"
            f"  ({self.replication_factor} replicas, each holding a full copy)\n"
            f"  Treat these as planning estimates, not deployment guarantees.\n"
            f"  Benchmark against your implementation before provisioning."
        )


# Illustrative example: banking policy corpus
plan = VectorIndexCapacityPlan(
    n_vectors=500_000,
    dimension=1536,
    hnsw_m=16,
    replication_factor=2,
    avg_payload_bytes=2048,
    memory_headroom_factor=1.5
)
print(plan.report())

Полный цикл поиска

При работе над этапом «Полный цикл извлечения информации» сначала запишите спецификацию: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Предпочитайте небольшие, тестируемые модули большим скриптам. Когда какой-то шаг терпит неудачу, ошибка должна указывать на конкретную ответственность, а не на запутанный цикл действий. Измеряйте показатель воспроизводимости на фиксированном наборе вопросов перед настройкой подсказок. Частая смена подсказок редко помогает улучшить качество извлечения информации.

User Query
    ↓
Query Embedding (dense + sparse)
    ↓
Metadata / Authorisation Constraints
    ↓
Dense Vector Retrieval (filtered HNSW)
        +
Sparse Retrieval (BM25)
    ↓
Weighted RRF Fusion
    ↓
Candidate Documents with Provenance Metadata
    ↓
[Part 7: Reranking and Context Assembly]
    ↓
LLM Generation
import logging
import re
from typing import List, Dict, Optional
from dataclasses import dataclass
from collections import defaultdict

logger = logging.getLogger(__name__)


@dataclass
class RetrievalConfig:
    dense_candidate_pool: int = 50
    sparse_candidate_pool: int = 50
    rrf_k: int = 60
    dense_weight: float = 0.6
    sparse_weight: float = 0.4
    final_top_k: int = 10
    score_threshold: float = 0.2


class BankingPolicyRetriever:
    """
    Production retrieval pipeline for a regulated banking policy corpus.
    Combines dense vector search, BM25 sparse retrieval, access filtering,
    and weighted RRF score fusion.

    Retrieval ends at the fused candidate list. Reranking and context assembly
    are handled in Part 7.
    """

    def __init__(
        self,
        qdrant_client,
        collection_name: str,
        embedding_pipeline,
        bm25_index,
        chunk_store: Dict[str, Dict],
        config: Optional[RetrievalConfig] = None
    ):
        self.client = qdrant_client
        self.collection = collection_name
        self.embedder = embedding_pipeline
        self.bm25 = bm25_index
        self.chunk_store = chunk_store
        self.config = config or RetrievalConfig()

    def retrieve(
        self,
        query: str,
        user_classification_ceiling: str = "INTERNAL",
        user_jurisdiction: Optional[str] = None,
        user_roles: Optional[List[str]] = None
    ) -> List[Dict]:
        """
        Full hybrid retrieval with access control.

        Returns top-k chunks with provenance metadata, access-filtered
        for the requesting user's classification ceiling and jurisdiction.
        """
        from qdrant_client.models import Filter, FieldCondition, MatchValue, MatchAny

        query_embedding = self.embedder.embed_query(query)

        classification_levels = {"PUBLIC": 0, "INTERNAL": 1, "CONFIDENTIAL": 2}
        ceiling = classification_levels.get(user_classification_ceiling, 1)
        permitted_classifications = [
            k for k, v in classification_levels.items() if v <= ceiling
        ]

        must_conditions = [
            FieldCondition(
                key="classification",
                match=MatchAny(any=permitted_classifications)
            ),
            FieldCondition(key="status", match=MatchValue(value="active"))
        ]
        if user_jurisdiction:
            must_conditions.append(
                FieldCondition(
                    key="jurisdiction",
                    match=MatchValue(value=user_jurisdiction)
                )
            )

        access_filter = Filter(must=must_conditions)

        # Dense vector search with integrated access filtering
        dense_hits = self.client.search(
            collection_name=self.collection,
            query_vector=("dense", query_embedding),
            query_filter=access_filter,
            limit=self.config.dense_candidate_pool,
            score_threshold=self.config.score_threshold,
            with_payload=True
        )
        dense_results = [(hit.id, hit.score) for hit in dense_hits]

        # Sparse BM25 retrieval with post-retrieval access filtering
        tokens = re.findall(r'\b\w+\b', query.lower())
        bm25_scores = self.bm25.get_scores(tokens)
        sparse_ranked = sorted(enumerate(bm25_scores), key=lambda x: x[1], reverse=True)

        chunk_ids = list(self.chunk_store.keys())
        sparse_results = []
        for corpus_idx, score in sparse_ranked:
            if score <= 0 or len(sparse_results) >= self.config.sparse_candidate_pool:
                break
            chunk_id = chunk_ids[corpus_idx]
            chunk_meta = self.chunk_store.get(chunk_id, {})
            if chunk_meta.get("classification") not in permitted_classifications:
                continue
            if user_jurisdiction and chunk_meta.get("jurisdiction") != user_jurisdiction:
                continue
            if chunk_meta.get("status") != "active":
                continue
            sparse_results.append((chunk_id, score))

        # Weighted RRF fusion
        rrf_scores: Dict[str, float] = defaultdict(float)
        k = self.config.rrf_k

        for rank, (chunk_id, _) in enumerate(dense_results, start=1):
            rrf_scores[chunk_id] += self.config.dense_weight * (1.0 / (k + rank))

        for rank, (chunk_id, _) in enumerate(sparse_results, start=1):
            rrf_scores[chunk_id] += self.config.sparse_weight * (1.0 / (k + rank))

        fused = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
        top_chunk_ids = [cid for cid, _ in fused[:self.config.final_top_k]]

        # Assemble results with provenance metadata
        results = []
        for chunk_id in top_chunk_ids:
            chunk_data = self.chunk_store.get(chunk_id, {})
            results.append({
                "chunk_id": chunk_id,
                "rrf_score": rrf_scores[chunk_id],
                **chunk_data
            })

        logger.info(
            f"Retrieval complete: query={query[:60]!r}, "
            f"dense_candidates={len(dense_results)}, "
            f"sparse_candidates={len(sparse_results)}, "
            f"final_results={len(results)}"
        )
        return results

Наблюдаемость: что измерять в производственной среде

При работе над этапом «Что измерять в рамках возможности наблюдения» сначала запишите контракт: необходимые входные данные, сигнал о успешном выполнении и что происходит при частичной неудаче. Такой чек-лист помогает сохранять честность при последующих изменениях кода. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Дайте названия элементам, определите критерии успешности и не допускайте беззвучного частичного выполнения задачи. Измеряйте степень воспроизводимости на фиксированном наборе вопросов перед настройкой подсказок. Частая смена подсказок редко помогает улучшить качество поиска. При работе над этапом «Что измерять в рамках возможности наблюдения» сначала запишите контракт: необходимые входные данные, сигнал о успешном выполнении и что происходит при частичной неудаче. Такой чек-лист помогает сохранять честность при последующих изменениях кода. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

import time
import logging
from typing import Callable, TypeVar, Any
from functools import wraps

logger = logging.getLogger(__name__)
F = TypeVar("F", bound=Callable[..., Any])


def retrieval_instrumented(func: F) -> F:
    """
    Decorator that adds structured latency logging and empty-result alerting
    to retrieval functions. Wrap your primary retrieve() method in production.
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = None
        error = None

        try:
            result = func(*args, **kwargs)
            return result
        except Exception as e:
            error = str(e)
            raise
        finally:
            elapsed_ms = (time.perf_counter() - start) * 1000
            n_results = len(result) if result is not None else 0

            log_payload = {
                "function": func.__name__,
                "latency_ms": round(elapsed_ms, 2),
                "n_results": n_results,
                "error": error
            }

            query = kwargs.get("query", args[1] if len(args) > 1 else None)
            if query:
                log_payload["query_prefix"] = str(query)[:80]

            if error:
                logger.error("retrieval_error", extra=log_payload)
            elif n_results == 0:
                logger.warning("retrieval_empty_result", extra=log_payload)
            elif elapsed_ms > 500:
                logger.warning("retrieval_high_latency", extra=log_payload)
            else:
                logger.info("retrieval_success", extra=log_payload)

    return wrapper  # type: ignore

Возврат к предложению о кредите в размере 12 миллионов евро

Этап возврата к стадии EUR работает наилучшим образом, если рассматриваться как измеримая среда. Соберите один идеальный пример выполнения, один случай сбоя и запись о откате перед расширением объема работ. Задокументируйте одновременно успешный сценарий работы и сценарий восстановления. Попытки повтора, проверки человеком и обработка неработоспособных сообщений являются частью продукта, а не этапом последующей доработки. Разделяйте политику разбиения на части и политику извлечения данных. Изменение одной из них не должно приводить к переписыванию другой при изменении показателей качества.

Перед переходом к повторной оценке: чек-лист для производственной среды

Этап «Перед переходом к реализации» работает наилучшим образом, если его рассматривать как измеримую поверхность. Соберите один идеальный пример выполнения, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объема работ. Предпочитайте небольшие, тестируемые единицы кода вместо обширных скриптов. Когда какой-то шаг сбивается, причина сбоя должна указывать на конкретную ответственность, а не на запутанную цепочку операций. Разделяйте политику разбиения на части и политику извлечения данных. Изменение одной из них не должно вынуждать переписывать другую при изменении показателей качества.

Что дальше

Этап «Что будет дальше» работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объёма работ. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Дайте названия создаваемым элементам, определите критерии успеха и не соглашайтесь на молчаливое частичное выполнение задачи. Разделяйте политику разбиения на части и политику извлечения данных. Изменение одной из них не должно принуждать к переписыванию другой при изменении показателей качества. Этап «Что будет дальше» работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объёма работ. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

Чек-лист операционной деятельности

На этапе операционного чек-листа необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии.

Записывайте время выполнения и стоимость токенов или запросов рядом с функциональными результатами. Отображение стоимости заранее предотвращает неожиданные счета при переходе с демо-среды в общедоступные среды.

Указывайте конкретные фрагменты текста, на которых основан ответ. Без цитат операторы не могут отличить галлюцинации от пробелов в индексации.

Отслеживайте стоимость и задержку вместе с качеством. Ответ, который немного хуже, но стоит в 10 раз дешевле, может оказаться оптимальным решением для производственной среды.

Фиксируйте версии зависимостей и сохраняйте хэш-сумму изображения, использованного для демонстрации. Воспроизводимость важнее коллективных знаний.

Лучше использовать небольшие, тестируемые единицы вместо обширных скриптов. Когда какой-то шаг терпит неудачу, ошибка должна указывать на конкретную ответственность, а не на запутанную цепочку операций.

Перед внедрением новой стек-технологии заморозьте версии, сохраните эталонный вариант выполнения для критически важных этапов и убедитесь, что существуют шаги для возврата к предыдущему состоянию. В совместных средах необходимы ограничения на частоту запросов, проверки принадлежности пользователя и четко определенный ответственный за обновление секретов. Лучше выбирать простую надежность, чем креативные одноразовые демонстрации.

Примечание для fa68a70d815a: не храните ключи поставщика в репозитории, установите лимит токенов на одну сессию и сохраняйте записи выполнения рядом с фикстурами для оценки, чтобы позже можно было сравнивать результаты работы разных моделей.

Для этапа 0 записки по укреплению безопасности необходимо определить входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Укажите названия результатов работы, определите критерии успеха и не допускайте молчаливого частичного завершения задачи.

Подробности укрепления безопасности 0/864: измерьте время выполнения, класс ошибок и расход токенов для данной записки, затем решите, следует ли сохранять изменения, опираясь на заранее установленный набор критериев, а не на субъективные оценки.

При работе над первым этапом записей по усилению безопасности сначала запишите условия работы: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Храните конфигурацию отдельно от кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, чтобы операторы могли их проверять, не читая весь код.

Подробность усиления безопасности 1/864: измерьте время выполнения, класс ошибки и расход токенов для данной записи, затем решите, следует ли сохранять изменение, опираясь на установленный набор критериев, а не на случайные наблюдения.

Второй этап записей по усилению безопасности лучше всего работает, если рассматривать его как измеримую область. Соберите один эталонный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Предпочитайте небольшие, проверяемые на тестах единицы кода большим скриптам. Когда какой-то шаг терпит неудачу, причина должна быть связана с конкретной функцией, а не с запутанной цепочкой операций.

Подробности усиления безопасности 2/864: измерьте время выполнения, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранить изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.

На третьем этапе работы над усилением безопасности определите входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность повторно выполнить шаг, исходя из известной точки контроля, без необходимости угадывать скрытое состояние. Записывайте время выполнения, а также стоимость токенов или запросов рядом с функциональными результатами. Отображение стоимости заранее предотвращает неожиданные счета при переходе с демо-среды в общедоступные среды.

Подробности усиления безопасности 3/864: измерьте время выполнения, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранить изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.

При работе над четвертым этапом записки по укреплению безопасности сначала запишите условия соглашения: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой список поможет сохранять честность при последующих изменениях кода. Документируйте одновременно успешный сценарий работы и сценарий восстановления. Повторные попытки, проверки со стороны человека и обработка неработоспособных сообщений являются частью продукта, а не элементами последующей доработки.

Деталь укрепления безопасности 4/864: измерьте время выполнения, класс ошибки и расход токенов для данной записки, затем решите, следует ли сохранять изменение, опираясь на заранее определенный набор критериев, а не на устные оценки.

Четвертый этап записки по укреплению безопасности работает наилучшим образом, когда его рассматривают как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объема работ. Рассматривайте этот этап как соглашение между входными данными и проверенными выходными результатами. Дайте названия соответствующим элементам, определите критерии успеха и не допускайте молчаливого частичного завершения работы.

Подробности усиления безопасности 5/864: измерьте время выполнения, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранить изменение на основе фиксированного набора вопросов, а не на основе единичных примеров.

На шестом этапе работы над усилением безопасности определите входные данные, ответственного за выполнение шага и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Храните конфигурацию вне кода приложения: файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь код.

Подробности усиления безопасности 6/864: измерьте время выполнения, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранить изменение на основе фиксированного набора вопросов, а не на основе единичных примеров.

При работе над этапом 7 записки по усилению безопасности сначала запишите условия работы: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Лучше использовать небольшие, тестируемые модули вместо обширных скриптов. Если какой-то шаг не сработает, причина неудачи должна указывать на конкретную ответственность, а не на запутанную цепочку операций.

Подробности усиления безопасности 7/864: измерьте время выполнения, класс ошибки и расход токенов для этой записки, затем решите, следует ли сохранять изменение, опираясь на фиксированный набор критериев, а не на устные оценки.

Этап 8 записки по усилению безопасности работает лучше всего, когда его рассматривают как измеримую область. Соберите один идеальный пример работы, один случай сбоя и записку о возврате к предыдущему состоянию перед расширением объема работ. Записывайте временные показатели и стоимость токенов или запросов рядом с функциональными результатами. Отслеживание затрат на раннем этапе предотвращает неожиданные расходы при переходе с демо-среды в общедоступные среды.

Подробности усиления безопасности 8/864: измерьте время выполнения, класс ошибки и количество потраченных токенов для этой записи, затем решите, следует ли сохранить изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.

На этапе 9 записи о усилении безопасности определите входные данные, ответственного за шаг и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Документируйте как успешный путь выполнения, так и путь восстановления одновременно. Повторные попытки, проверки человеком и обработка неработающих сообщений являются частью продукта, а не последующими улучшениями.

Подробности усиления безопасности 9/864: измерьте время выполнения, класс ошибки и количество потраченных токенов для этой записи, затем решите, следует ли сохранить изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.

При работе над этапом усиления безопасности №10 сначала запишите контракт: необходимые входные данные, сигнал успешного выполнения и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Рассматривайте этот этап как контракт между входными данными и проверенными выходными результатами. Дайте названия соответствующим элементам, определите критерии успешности и не допускайте молчаливого частичного выполнения задач.

Подробности усиления безопасности 10/864: измерьте время выполнения, класс ошибки и расход токенов для данного этапа, затем решите, следует ли сохранять изменение на основе фиксированного набора критериев, а не на основе единичных примеров.

Этап усиления безопасности №11 будет работать наилучшим образом, если рассматривать его как измеримую поверхность. Соберите один эталонный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Храните конфигурацию вне кода приложения. Файлы среды, хранилища секретов и флаги функций должны находиться в одном месте, которое операторы могут проверять, не читая весь кодовый граф.

Подробности усиления безопасности 11/864: измерьте время выполнения, класс ошибки и количество потраченных токенов для этой записи, затем решите, следует ли сохранить изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.

На этапе 12 процесса усиления безопасности определите входные данные, ответственного за шаг и критерии завершения перед изменением кода. Операторы должны иметь возможность перезапустить шаг с известной точки контроля, не догадываясь о скрытом состоянии. Предпочтительнее использовать небольшие, проверяемые единицы кода вместо обширных скриптов. При сбое шага он должен указывать на конкретную причину, а не на сложную взаимосвязь компонентов.

Подробности усиления безопасности 12/864: измерьте время выполнения, класс ошибки и количество потраченных токенов для этой записи, затем решите, следует ли сохранить изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.

При работе над этапом усиления безопасности №13 сначала запишите условия работы: необходимые входные данные, сигнал о успешном выполнении и действия при частичной неудаче. Такой чек-лист поможет сохранять честность при последующих изменениях кода. Рядом с функциональными результатами записывайте время выполнения, стоимость токенов или запросов. Очевидность затрат с самого начала предотвращает неожиданные расходы при переходе с демо-среды в общедоступные среды.

Подробности усиления безопасности 13/864: измерьте время выполнения, класс ошибки и расход токенов для данного этапа, затем решите, следует ли сохранять изменения на основе определенного набора критериев, а не на основе устных оценок.

Этап усиления безопасности №14 будет работать наилучшим образом, если рассматривать его как измеримую поверхность. Соберите один идеальный пример работы, один случай сбоя и запись о возврате к предыдущему состоянию перед расширением объема работ. Документируйте одновременно успешный сценарий работы и сценарий восстановления. Повторные попытки, проверки человеком и обработка неработающих сообщений являются частью продукта, а не элементами последующей доработки.

Подробности усиления безопасности 14/864: измерьте время обработки стены, класс ошибки и расход токенов для этой записи, затем решите, следует ли сохранять изменения на основе фиксированного набора вопросов, а не на основе единичных примеров.