跪拜 Guibai
← Back to the summary

Turning a Million-Word EPUB into a Retrievable Knowledge Base with Milvus and Qwen

How to Build RAG for a Million-Word EPUB? Running Through Loader, Splitter, Milvus, and Q&A with Demi-Gods and Semi-Devils

The previous AI diary only had a few short texts; each diary entry could directly generate a single vector and be written into Milvus as a whole.

After switching the data source to a full-length novel, the processing method must change. An EPUB contains multiple chapters and a large amount of body text. If the entire book is converted into a single vector, when a user asks "What martial arts does Duan Yu know?", the system can only know whether the question is related to the whole book, but cannot locate the paragraph that truly contains the answer.

The key to long-document RAG is not directly calling a large model, but first turning the original e-book into a set of retrievable, locatable text fragments:

EPUB E-book
→ Load by chapter
→ Split each chapter into multiple Chunks
→ Generate Embedding for each Chunk
→ Save vector, body text, and chapter info
→ Retrieve Top-K fragments based on the question
→ Pass relevant fragments to the large model for answering

This article uses Demi-Gods and Semi-Devils as the data source, using LangChain, Milvus, and Qwen to run through the e-book knowledge base construction, retrieval, and RAG Q&A process.

Long-Document RAG is Divided into Two Phases

The complete process does not re-read the entire novel every time a question is asked; it is divided into two phases: database construction and querying.

Database Construction Phase

Load EPUB
→ Get Documents by chapter
→ Further split each chapter into multiple Chunks
→ Convert Chunks into 1024-dimensional vectors
→ Write into Milvus

This phase is executed in advance, responsible for processing the original e-book into a knowledge base that can be semantically searched.

Query Phase

User question
→ Convert question into a vector
→ Milvus retrieves similar fragments
→ Concatenate relevant chapter body text
→ Qwen answers based on the body text

The query phase no longer scans the EPUB file but directly searches the vectors and text fragments already saved in Milvus.

Both phases use the same Embedding Model and the same 1024-dimensional vector space, so that the body text vectors written later can be correctly compared with the question vector.

Step 1: Determine E-book and Slicing Parameters

The project first defines the Collection, vector dimension, chunk size, and EPUB path:

const COLLECTION_NAME = 'ebook';
const VECTOR_DIM = 1024;
const CHUNK_SIZE = 500;
const EPUB_FILE = './天龙八部.epub';

path.parse is used to extract the book name from the file path:

import { parse } from 'path';

const { name: BOOK_NAME } = parse(EPUB_FILE);

For ./天龙八部.epub, the value of BOOK_NAME is 天龙八部. This field is saved when writing to Milvus, so that when the same Collection stores multiple books later, you can still know which book each fragment comes from.

Step 2: Load by Chapter with EPubLoader

LangChain Community provides EPubLoader:

import {
  EPubLoader
} from '@langchain/community/document_loaders/fs/epub';

Enable chapter splitting when creating the Loader:

const loader = new EPubLoader(EPUB_FILE, {
  splitterChapters: true,
});

const document = await loader.load();
console.log(`Loading complete, total ${document.length} chapters`);

The loading result is not a single huge string, but an array of multiple Document objects. The pageContent of each Document holds the body text of one chapter:

const chapter = document[chapterIndex];
const chapterContent = chapter.pageContent;

Loading by chapter solves the first layer of structural problems: the system first preserves the original chapter boundaries of the novel, then continues slicing within each chapter.

It is necessary to accurately understand the execution method here: loader.load() completes the EPUB loading first, and the subsequent code processes these Document objects chapter by chapter. Processing chapter by chapter avoids generating vectors for all Chunks of the entire book at once, but it is not a file stream reading byte by byte from disk.

Step 3: Slice Each Chapter into Retrievable Chunks

A single chapter can still contain thousands to tens of thousands of words; generating a single chapter vector directly is still too coarse.

The project uses RecursiveCharacterTextSplitter to continue splitting:

import {
  RecursiveCharacterTextSplitter
} from '@langchain/textsplitters';

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: CHUNK_SIZE,
  chunkOverlap: 50,
});

The meaning of the current settings:

Parameter Current Value Role
chunkSize 500 Controls the target size of a single text fragment
chunkOverlap 50 Allows adjacent fragments to retain a portion of repeated context

Slicing loops by chapter:

for (
  let chapterIndex = 0;
  chapterIndex < document.length;
  chapterIndex++
) {
  const chapter = document[chapterIndex];
  const chapterContent = chapter.pageContent;
  const chunks = await textSplitter.splitText(chapterContent);

  if (chunks.length === 0) {
    continue;
  }

  await insertChunksBatch(
    chunks,
    bookId,
    chapterIndex + 1
  );
}

The value of chunkOverlap: 50 lies in preserving boundary context.

Suppose a martial arts description happens to fall right at the boundary of two Chunks. Without any overlap, the character's name might be separated from the move description. After adjacent fragments repeat a small amount of text, both Chunks can retain more complete semantic clues.

Chunks are not necessarily better when smaller. Too small loses the complete plot, too large mixes in too many characters and events. 500 and 50 are the starting point for this novel demo; a real knowledge base should continue adjusting based on document structure, model context, and retrieval effectiveness.

Step 4: Design the E-book Collection

The AI diary only needed to save diary ID, date, mood, tags, and body text. An e-book knowledge base also needs to record the chapter and slice position within a book.

The Schema created by the project includes:

await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    {
      name: 'id',
      data_type: DataType.VarChar,
      max_length: 100,
      is_primary_key: true,
    },
    {
      name: 'book_id',
      data_type: DataType.VarChar,
      max_length: 100,
    },
    {
      name: 'book_name',
      data_type: DataType.VarChar,
      max_length: 200,
    },
    {
      name: 'chapter_num',
      data_type: DataType.Int32,
    },
    {
      name: 'index',
      data_type: DataType.Int32,
    },
    {
      name: 'content',
      data_type: DataType.VarChar,
      max_length: 10000,
    },
    {
      name: 'vector',
      data_type: DataType.FloatVector,
      dim: VECTOR_DIM,
    },
  ],
});

These fields can be divided into three categories:

Type Fields Responsibility
Unique Locator id Identifies a specific novel fragment
Source Info book_id, book_name, chapter_num, index Records which book, chapter, and position the fragment comes from
Search Content content, vector Saves the original text and participates in semantic search

Vectors are responsible for "finding similarity", metadata is responsible for "knowing the source after finding". If only vectors are saved without chapters and body text, even if the retrieval is successful, the evidence cannot be passed to the model or displayed to the user.

Step 5: Create an IVF_FLAT Index for the Vector Field

The project uses IVF_FLAT and cosine similarity:

await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE,
  params: {
    nlist: 1024,
  },
});

IVF_FLAT first divides vectors into different clusters. During a query, it first narrows down the candidate range, then compares candidate vectors, thus avoiding scanning all data in the Collection every time.

nlist represents the number of clusters. It affects index construction, candidate range, and query effectiveness, and should not be copied directly without considering data scale. The 1024 here is the configuration for the current demo; real projects need to determine it through recall rate and query latency tests.

Load the Collection after creation is complete:

await client.loadCollection({
  collection_name: COLLECTION_NAME,
});

ensureCollection calls hasCollection first. When the Collection does not exist, it completes Schema and index creation; when it already exists, it directly enters the loading process, avoiding repeated database construction.

Step 6: Use Promise.all to Generate Chapter Vectors

After each chapter is split into multiple Chunks, the project generates vectors for the fragments of this chapter through Promise.all:

const insertData = await Promise.all(
  chunks.map(async (chunk, chunkIndex) => {
    const vector = await getEmbeddings(chunk);

    return {
      id: `${bookId}_${chapterNum}_${chunkIndex}`,
      book_id: bookId,
      book_name: BOOK_NAME,
      chapter_num: chapterNum,
      index: chunkIndex,
      content: chunk,
      vector,
    };
  })
);

Two layers of processing rhythm are preserved here:

Between chapters: for loop sequential processing
Within a chapter: Promise.all concurrently generates vectors for multiple Chunks

This way, it neither initiates requests for all fragments of the entire book at once, nor forces the Chunks within the same chapter to execute completely serially.

The primary key for each fragment is composed of three parts:

bookId_chapterNum_chunkIndex

For example:

1_12_3

Represents the 1st book, 12th chapter, 4th Chunk. A deterministic ID allows data fragments to form a stable mapping with their original positions.

After vector generation is complete, the entire chapter is written into Milvus at once:

const insertResult = await client.insert({
  collection_name: COLLECTION_NAME,
  data: insertData,
});

return Number(insertResult.insert_cnt) || 0;

By uniformly converting insert_cnt to a number, the caller can continuously accumulate the number of fragments already written:

totalInserted += insertedCount;

Step 7: Retrieve Novel Fragments Using Natural Language

After the knowledge base is established, query.mjs no longer needs to read the EPUB, only needs to convert the question into a vector:

const query = 'What martial arts does Duan Yu know';
const queryVector = await getEmbedding(query);

Then retrieve Top-3 from the ebook Collection:

const searchResult = await client.search({
  collection_name: COLLECTION_NAME,
  vector: queryVector,
  limit: 3,
  metric_type: MetricType.COSINE,
  output_fields: [
    'id',
    'book_id',
    'chapter_num',
    'index',
    'content',
  ],
});

Milvus returns searchResult.results, each item containing a similarity score and original fragment information:

searchResult.results.forEach((item, index) => {
  console.log(`${index + 1}. ${item.score}`);
  console.log(item.chapter_num);
  console.log(item.content);
});

Semantic retrieval does not require the question and the original text to use exactly the same keywords. As long as characters, martial arts, and related plots are close in the vector space, the corresponding fragments have a chance to enter the Top-K.

Step 8: Turn Retrieval Results into RAG Context

A standalone vector search only returns relevant original text. To make the system directly answer questions, these fragments need to be organized into a Prompt.

The project first encapsulates a retrieval function:

async function retrieveRelevantChunks(question, k = 3) {
  const queryVector = await getEmbedding(question);
  const searchResult = await client.search({
    collection_name: COLLECTION_NAME,
    vector: queryVector,
    limit: k,
    metric_type: MetricType.COSINE,
    output_fields: [
      'id',
      'book_id',
      'chapter_num',
      'index',
      'content',
    ],
  });

  return searchResult.results;
}

Then concatenate the hit chapters and body text into context:

const content = retrievedContent
  .map((item, index) => `
    [Fragment ${index + 1}]
    Chapter ${item.chapter_num}
    Content: ${item.content}
  `)
  .join('\n\n---\n\n');

The context not only includes the body text but also retains the chapter number. The large model can know which chapter the evidence comes from, making the output easier to trace.

Finally, construct the novel Q&A Prompt:

const prompt = `
You are a professional assistant for the novel "Demi-Gods and Semi-Devils".
Answer questions based on the novel, using accurate and detailed language.

Novel fragments:
${content}

User question: ${question}

If there is no relevant information in the fragments, please honestly inform the user.
The answer must conform to the plot and character settings of the novel.
`;

const response = await model.invoke(prompt);

In this step, the large model does not answer freely based on novel content it might have seen during training, but prioritizes organizing answers based on the currently retrieved original text.

What Loader, Splitter, Milvus, and Qwen Are Each Responsible For

The entire project integrates multiple components, but the responsibility boundaries are very clear:

Component Responsible Work Not Responsible For
EPubLoader Reads EPUB, generates Documents by chapter Does not generate vectors
RecursiveCharacterTextSplitter Splits chapters into Chunks suitable for retrieval Does not judge answers
Embedding Model Maps body text and questions into vectors Does not save original text
Milvus Saves data and retrieves similar fragments Does not generate natural language answers
Qwen Organizes answers based on questions and relevant fragments Is not responsible for traversing the entire EPUB

The effectiveness of a RAG system depends on the entire chain. If the Loader loses body text, Chunks are split unreasonably, or Milvus fails to recall key fragments, the final large model cannot conjure up reliable evidence out of thin air.

From Novel Demo to General E-book Knowledge Base

The current code has completed the core process for a single e-book. When continuing to expand, the existing fields can be used to support more capabilities:

These extensions will not change the core architecture, which remains "first process documents and build the database, then retrieve evidence and answer."

Summary

Turning a full-length EPUB into a RAG knowledge base requires solving not just model invocation, but how long documents are loaded, split, located, and retrieved.

This complete process can be summarized as:

EPubLoader loads by chapter
→ RecursiveCharacterTextSplitter slices
→ Promise.all generates Chunk vectors within a chapter
→ Milvus saves body text, chapter metadata, and vector
→ Question vector retrieves COSINE Top-K
→ Relevant fragments form Context
→ Qwen answers based on the novel's original text

Short text demos prove that vector retrieval works; full-length e-book practical combat truly connects Loader, Splitter, Embedding, vector database, and generative model into a reusable RAG system.