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:
- Use
book_idto distinguish multiple e-books; - Filter by
book_idbefore vector retrieval; - Display chapter numbers and cited fragments in answers;
- Design cleanup, update, or idempotent write processes for repeated imports;
- Add concurrency control for a large number of Chunks based on model interface limits;
- Record retrieval scores to evaluate the effects of different
chunkSize, Overlap, and Top-K.
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.